@fulate/ui 1.0.9 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -159,10 +159,13 @@ import { measureTextRun } from "@fulate/ui";
159
159
  const metrics = measureTextRun(ctx, text, root.skin.text, 14, 500);
160
160
  ```
161
161
 
162
- 每次调用只执行一次 `ctx.measureText()`,返回 `font`、`width`、`lineHeight`、`drawY` 和
163
- `alphabeticBaseline`。空文本的 `width` `0`,但仍测量一个空格以取得稳定基线。函数会把本次
164
- 测量所需的文字状态写入 `ctx`,不会调用 `save()` `restore()`;Canvas 生命周期以及业务特有的
165
- 文字顺序、对齐和绘制仍由 Shape 自己拥有。
162
+ 每次调用只执行一次 `ctx.measureText()`,返回 `font`、`width`、`lineHeight`、`drawY`、
163
+ `alphabeticBaseline`、`textBaseline` `letterSpacing`。`drawY` 根据原生 Canvas 的
164
+ `fontBoundingBoxAscent`/`fontBoundingBoxDescent` 把字体行框放到行框中心;`alphabeticBaseline`
165
+ 是同一行框内的规范 alphabetic baseline,因此不同字号的 run 可以直接对齐。空文本的 `width`
166
+ 为 `0`,但仍测量一个空格以取得稳定字体度量。函数会把本次测量所需的文字状态写入 `ctx`,不会
167
+ 调用 `save()` 或 `restore()`;Canvas 生命周期以及业务特有的文字顺序、对齐和绘制仍由 Shape
168
+ 自己拥有。
166
169
 
167
170
  ### 组合型多行文本
168
171
 
package/dist/index.js CHANGED
@@ -8,8 +8,10 @@ import { Easing, Tween } from "@tweenjs/tween.js";
8
8
  var graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
9
9
  var measurementListeners = /* @__PURE__ */ new Set();
10
10
  var observesFontLoading = false;
11
+ var textLineBoxMetricsCache = /* @__PURE__ */ new Map();
11
12
  function invalidateFontMeasurements() {
12
13
  clearCache();
14
+ textLineBoxMetricsCache.clear();
13
15
  for (const listener of measurementListeners) listener();
14
16
  }
15
17
  /**
@@ -50,14 +52,35 @@ function measurePlainText(text, font) {
50
52
  return context.measureText(text).width;
51
53
  }
52
54
  /**
53
- * Return the painted alphabetic baseline relative to the Canvas draw Y.
54
- * The same shared Canvas context and font string also own Text width metrics.
55
+ * Resolve one line box from a native Canvas measurement. The font bounding
56
+ * box is stable for the font state, while `alphabeticBaseline` translates the
57
+ * selected Canvas baseline into the same canonical alphabetic coordinate used
58
+ * by TextRow and composite owners.
55
59
  */
56
- function measureTextAlphabeticBaselineOffset(text, font, textBaseline) {
60
+ function resolveTextLinePlacement(metrics, lineHeight) {
61
+ const drawY = (lineHeight + metrics.fontBoundingBoxAscent - metrics.fontBoundingBoxDescent) / 2;
62
+ return {
63
+ drawY,
64
+ alphabeticBaseline: drawY - metrics.alphabeticBaseline,
65
+ height: lineHeight
66
+ };
67
+ }
68
+ /**
69
+ * Measure the font line box once and reuse it for Text painting, placeholders,
70
+ * and composite TextBlock owners. The representative space supplies the
71
+ * font-level Canvas metrics without making placement depend on the content of
72
+ * an individual line.
73
+ */
74
+ function measureTextLinePlacement(font, textBaseline, lineHeight) {
75
+ const key = `${font}\u0000${textBaseline}\u0000${lineHeight}`;
76
+ const cached = textLineBoxMetricsCache.get(key);
77
+ if (cached) return cached;
57
78
  const context = getPlainMeasureContext();
58
79
  context.font = font;
59
80
  context.textBaseline = textBaseline;
60
- return -context.measureText(text || " ").alphabeticBaseline;
81
+ const metrics = resolveTextLinePlacement(context.measureText(" "), lineHeight);
82
+ textLineBoxMetricsCache.set(key, metrics);
83
+ return metrics;
61
84
  }
62
85
  /** A no-wrap run can stay one Canvas string until rich layout is requested. */
63
86
  function canUsePlainText(text, letterSpacing) {
@@ -809,40 +832,39 @@ function clearTextShadow(ctx) {
809
832
  ctx.shadowOffsetX = 0;
810
833
  ctx.shadowOffsetY = 0;
811
834
  }
812
- function getTextLineStartY(textBaseline, lineHeight) {
813
- return textBaseline === "middle" ? lineHeight / 2 : textBaseline === "bottom" ? lineHeight : 0;
814
- }
815
- function getTextLineDrawY(textBaseline, fontSize, lineHeight) {
816
- return getTextLineStartY(textBaseline, lineHeight) + (lineHeight - fontSize) / 2;
817
- }
818
835
  /**
819
836
  * Measure one native Canvas text run with Fulate's resolved font and line-box
820
837
  * semantics. This performs one `measureText()` call and writes the run's font,
821
838
  * baseline, and letter spacing to the supplied context without saving or
822
- * restoring it. Empty text measures a space for baseline data but reports zero
839
+ * restoring it. The font bounding box determines the centered draw position;
840
+ * the returned alphabetic baseline is the canonical coordinate for row
841
+ * alignment. Empty text measures a space for baseline data but reports zero
823
842
  * width.
824
843
  */
825
844
  function measureTextRun(ctx, text, style, fontSize = style.fontSize, fontWeight = style.fontWeight) {
826
845
  const font = buildFontString(style, fontSize, fontWeight);
827
846
  const lineHeight = fontSize * style.lineHeight;
828
- const drawY = getTextLineDrawY(style.textBaseline, fontSize, lineHeight);
829
847
  setTextState(ctx, font, style.letterSpacing);
830
848
  ctx.textBaseline = style.textBaseline;
831
- const metrics = ctx.measureText(text || " ");
849
+ const measured = ctx.measureText(text || " ");
850
+ const lineMetrics = resolveTextLinePlacement(measured, lineHeight);
832
851
  return {
833
852
  font,
834
- width: text ? metrics.width : 0,
853
+ width: text ? measured.width : 0,
835
854
  lineHeight,
836
- drawY,
837
- alphabeticBaseline: drawY - metrics.alphabeticBaseline
855
+ drawY: lineMetrics.drawY,
856
+ alphabeticBaseline: lineMetrics.alphabeticBaseline,
857
+ textBaseline: style.textBaseline,
858
+ letterSpacing: style.letterSpacing
838
859
  };
839
860
  }
840
- /** Measure the line box produced by the existing Text painter. */
841
- function measureTextLineBoxMetrics(text, style) {
861
+ /** Measure the line box shared by Text, Span, and composite text owners. */
862
+ function measureTextLineBoxMetrics(style) {
842
863
  const lineHeight = style.fontSize * style.lineHeight;
864
+ const { alphabeticBaseline, height } = measureTextLinePlacement(buildFontString(style), style.textBaseline, lineHeight);
843
865
  return {
844
- alphabeticBaseline: getTextLineDrawY(style.textBaseline, style.fontSize, lineHeight) + measureTextAlphabeticBaselineOffset(text, buildFontString(style), style.textBaseline),
845
- height: lineHeight
866
+ alphabeticBaseline,
867
+ height
846
868
  };
847
869
  }
848
870
  /**
@@ -856,7 +878,7 @@ function paintTextBlock(ctx, layout, style, width, height) {
856
878
  if (layout.lines.length === 0) return;
857
879
  const font = buildFontString(style);
858
880
  const lineHeight = style.fontSize * style.lineHeight;
859
- const drawY = getTextLineDrawY(style.textBaseline, style.fontSize, lineHeight);
881
+ const lineMetrics = measureTextLinePlacement(font, style.textBaseline, lineHeight);
860
882
  const blockOffset = layout.getBlockVerticalOffset(height, style.verticalAlign);
861
883
  setTextState(ctx, font, style.letterSpacing);
862
884
  ctx.textAlign = "left";
@@ -871,7 +893,7 @@ function paintTextBlock(ctx, layout, style, width, height) {
871
893
  for (let index = 0; index < layout.lines.length; index++) {
872
894
  const line = layout.lines[index];
873
895
  const x = isLeftAligned ? 0 : layout.getLineX(index, width, style.textAlign);
874
- const y = drawY + index * lineHeight + blockOffset;
896
+ const y = lineMetrics.drawY + index * lineHeight + blockOffset;
875
897
  if (hasTextShadow) applyTextShadow(ctx, style.textShadow);
876
898
  if (hasStroke) {
877
899
  ctx.strokeStyle = strokeColor;
@@ -898,7 +920,6 @@ function paintTextContent(self, ctx, offsetY = 0) {
898
920
  const w = self.width || 0;
899
921
  const h = self.height || 0;
900
922
  const lineHeightPx = style.fontSize * style.lineHeight;
901
- const drawY = getTextLineDrawY(style.textBaseline, style.fontSize, lineHeightPx);
902
923
  const ls = style.letterSpacing;
903
924
  const layout = self.textLayout;
904
925
  const blockOffset = layout.getBlockVerticalOffset(h, style.verticalAlign);
@@ -944,7 +965,8 @@ function paintTextContent(self, ctx, offsetY = 0) {
944
965
  const phWidth = measureTextWidth(self.placeholder, font, ls);
945
966
  const phX = style.textAlign === "center" ? (w - phWidth) / 2 : style.textAlign === "right" ? w - phWidth : 0;
946
967
  const phBlockOffset = layout.getBlockVerticalOffset(h, style.verticalAlign, lineHeightPx);
947
- drawTextLine(ctx, self.placeholder, phX, drawY + phBlockOffset, font, ls, true, false);
968
+ const lineMetrics = measureTextLinePlacement(font, style.textBaseline, lineHeightPx);
969
+ drawTextLine(ctx, self.placeholder, phX, lineMetrics.drawY + phBlockOffset, font, ls, true, false);
948
970
  ctx.fillStyle = fillStyle;
949
971
  }
950
972
  if (self.isEditing && self.caretVisible && self.textarea) {
@@ -3140,13 +3162,12 @@ var Text = class extends Rectangle {
3140
3162
  return this.textBlock.layout;
3141
3163
  }
3142
3164
  /** @internal Shared by TextRow layout and the final Text painter. */
3143
- getTextLineBoxMetrics(text = this.text) {
3165
+ getTextLineBoxMetrics() {
3144
3166
  const style = this.getResolvedTextStyle();
3145
3167
  const cached = this.textLineBoxMetricsCache;
3146
- if (cached?.text === text && cached.style === style) return cached.metrics;
3147
- const metrics = measureTextLineBoxMetrics(text, style);
3168
+ if (cached?.style === style) return cached.metrics;
3169
+ const metrics = measureTextLineBoxMetrics(style);
3148
3170
  this.textLineBoxMetricsCache = {
3149
- text,
3150
3171
  style,
3151
3172
  metrics
3152
3173
  };
@@ -3155,7 +3176,7 @@ var Text = class extends Rectangle {
3155
3176
  /** @internal Alphabetic baseline relative to this Text box. */
3156
3177
  getTextBlockFirstAlphabeticBaseline() {
3157
3178
  const style = this.getResolvedTextStyle();
3158
- return this.textLayout.getBlockVerticalOffset(this.height ?? 0, style.verticalAlign) + this.getTextLineBoxMetrics(this.textLayout.lines[0]?.text ?? "").alphabeticBaseline;
3179
+ return this.textLayout.getBlockVerticalOffset(this.height ?? 0, style.verticalAlign) + this.getTextLineBoxMetrics().alphabeticBaseline;
3159
3180
  }
3160
3181
  /** @internal The editor frame follows the Text box, not visible overflow. */
3161
3182
  getSelectionFrameBounds() {
@@ -118,7 +118,7 @@ export declare class Text<P extends TextProperties = TextProperties, O extends T
118
118
  getFontString(style?: Required<TextStyleConfig>): string;
119
119
  get textLayout(): import("./layout").TextLayoutSnapshot;
120
120
  /** @internal Shared by TextRow layout and the final Text painter. */
121
- getTextLineBoxMetrics(text?: string): TextLineBoxMetrics;
121
+ getTextLineBoxMetrics(): TextLineBoxMetrics;
122
122
  /** @internal Alphabetic baseline relative to this Text box. */
123
123
  getTextBlockFirstAlphabeticBaseline(): number;
124
124
  /** @internal The editor frame follows the Text box, not visible overflow. */
@@ -1,4 +1,19 @@
1
1
  import { type LayoutLine } from "@chenglou/pretext";
2
+ /**
3
+ * The vertical placement of a Canvas text run is a font property, not a
4
+ * `fontSize` approximation. Keep one resolved line box per native font state
5
+ * so painting can reuse the measurement without measuring on every frame.
6
+ */
7
+ export interface TextLineBoxMetrics {
8
+ /** Alphabetic baseline relative to the line-box top. */
9
+ readonly alphabeticBaseline: number;
10
+ /** Line-box height in logical pixels. */
11
+ readonly height: number;
12
+ }
13
+ export interface TextLinePlacementMetrics extends TextLineBoxMetrics {
14
+ /** Canvas draw Y relative to the line-box top. */
15
+ readonly drawY: number;
16
+ }
2
17
  /**
3
18
  * Subscribe a non-Text owner to browser font measurement completion.
4
19
  *
@@ -29,10 +44,23 @@ export interface TextLayoutMetrics {
29
44
  readonly truncated: boolean;
30
45
  }
31
46
  /**
32
- * Return the painted alphabetic baseline relative to the Canvas draw Y.
33
- * The same shared Canvas context and font string also own Text width metrics.
47
+ * Resolve one line box from a native Canvas measurement. The font bounding
48
+ * box is stable for the font state, while `alphabeticBaseline` translates the
49
+ * selected Canvas baseline into the same canonical alphabetic coordinate used
50
+ * by TextRow and composite owners.
51
+ */
52
+ export declare function resolveTextLinePlacement(metrics: Pick<TextMetrics, "fontBoundingBoxAscent" | "fontBoundingBoxDescent" | "alphabeticBaseline">, lineHeight: number): {
53
+ drawY: number;
54
+ alphabeticBaseline: number;
55
+ height: number;
56
+ };
57
+ /**
58
+ * Measure the font line box once and reuse it for Text painting, placeholders,
59
+ * and composite TextBlock owners. The representative space supplies the
60
+ * font-level Canvas metrics without making placement depend on the content of
61
+ * an individual line.
34
62
  */
35
- export declare function measureTextAlphabeticBaselineOffset(text: string, font: string, textBaseline: CanvasTextBaseline): number;
63
+ export declare function measureTextLinePlacement(font: string, textBaseline: CanvasTextBaseline, lineHeight: number): TextLinePlacementMetrics;
36
64
  /**
37
65
  * Measure one text run through the same engine used by Text and Span.
38
66
  * `white-space: pre-wrap` keeps the caller's spaces and hard breaks intact.
@@ -1,7 +1,8 @@
1
- import { type TextLayoutSnapshot } from "./layout";
1
+ import { type TextLayoutSnapshot, type TextLineBoxMetrics } from "./layout";
2
2
  import type { Text } from "./index";
3
3
  import { type ResolvedTextStyle } from "./style";
4
4
  import type { ShadowOption, TextStyleConfig } from "@fulate/core";
5
+ export type { TextLineBoxMetrics } from "./layout";
5
6
  export declare function resolveColorFill(ctx: CanvasRenderingContext2D, color: ResolvedTextStyle["color"], w: number, h: number): string | CanvasGradient;
6
7
  export declare function drawTextLine(ctx: CanvasRenderingContext2D, line: string, x: number, y: number, font: string, letterSpacing: number, doFill: boolean, doStroke: boolean): void;
7
8
  export interface TextDecorationOptions {
@@ -29,21 +30,23 @@ export interface TextRunMetrics {
29
30
  readonly drawY: number;
30
31
  /** Alphabetic baseline relative to the run's line-box top. */
31
32
  readonly alphabeticBaseline: number;
33
+ /** Canvas baseline used when the run was measured. */
34
+ readonly textBaseline: CanvasTextBaseline;
35
+ /** Letter spacing used when the run was measured. */
36
+ readonly letterSpacing: number;
32
37
  }
33
38
  /**
34
39
  * Measure one native Canvas text run with Fulate's resolved font and line-box
35
40
  * semantics. This performs one `measureText()` call and writes the run's font,
36
41
  * baseline, and letter spacing to the supplied context without saving or
37
- * restoring it. Empty text measures a space for baseline data but reports zero
42
+ * restoring it. The font bounding box determines the centered draw position;
43
+ * the returned alphabetic baseline is the canonical coordinate for row
44
+ * alignment. Empty text measures a space for baseline data but reports zero
38
45
  * width.
39
46
  */
40
47
  export declare function measureTextRun(ctx: CanvasRenderingContext2D, text: string, style: Readonly<Required<TextStyleConfig>>, fontSize?: number, fontWeight?: string | number): TextRunMetrics;
41
- export interface TextLineBoxMetrics {
42
- readonly alphabeticBaseline: number;
43
- readonly height: number;
44
- }
45
- /** Measure the line box produced by the existing Text painter. */
46
- export declare function measureTextLineBoxMetrics(text: string, style: ResolvedTextStyle): TextLineBoxMetrics;
48
+ /** Measure the line box shared by Text, Span, and composite text owners. */
49
+ export declare function measureTextLineBoxMetrics(style: ResolvedTextStyle): TextLineBoxMetrics;
47
50
  /**
48
51
  * Paint a previously synchronized text block.
49
52
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fulate/ui",
3
- "version": "1.0.9",
3
+ "version": "1.0.10",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",