diff --git a/package.json b/package.json index 97bcae5..9f9531f 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,9 @@ "vitest": "^2.1.9", "vitest-mock-extended": "^2.0.2" }, + "peerDependencies": { + "@vitejs/plugin-legacy": "^7.0.0 || ^8.0.0" + }, "lint-staged": { "*.ts": [ "prettier --write", diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts index 42cd324..72652b4 100644 --- a/src/core/CoreTextNode.test.ts +++ b/src/core/CoreTextNode.test.ts @@ -147,6 +147,75 @@ describe('CoreTextNode (canvas) clearing text', () => { }); }); +describe('CoreTextNode eager dimensions', () => { + const makeCanvasRendererWithFont = ( + isFontLoaded: boolean, + measured: TextRenderInfo, + ): TextRenderer => { + const font = mock({ + type: 'canvas', + isFontLoaded: vi.fn(() => isFontLoaded), + waitingForFont: vi.fn(), + }); + return { + type: 'canvas', + font, + renderText: vi.fn(), + measureText: vi.fn(() => measured), + addQuads: vi.fn(), + renderQuads: vi.fn(), + init: vi.fn(), + } as unknown as TextRenderer; + }; + + it('measures dimensions synchronously at construction when the font is loaded, without rendering', () => { + const renderer = makeCanvasRendererWithFont(true, { + width: 120, + height: 40, + }); + const stage = makeStage(makeLoadedTexture()); + + const onLoaded = vi.fn(); + // Spy must be wired through the renderer-agnostic path; emit happens only + // on render, not on measure. + const node = new CoreTextNode( + stage, + defaultProps({ text: 'Hello' }), + renderer, + ); + node.on('loaded', onLoaded); + + // Dimensions are available immediately — no frame, no microtask. + expect(renderer.measureText).toHaveBeenCalledTimes(1); + expect(node.props.w).toBe(120); + expect(node.props.h).toBe(40); + + // Measuring must NOT render: no rasterization, no texture, no loaded event. + expect(renderer.renderText).not.toHaveBeenCalled(); + expect(node.texture).toBe(null); + expect(onLoaded).not.toHaveBeenCalled(); + }); + + it('does not measure or render when the font is not yet loaded', () => { + const renderer = makeCanvasRendererWithFont(false, { + width: 120, + height: 40, + }); + const stage = makeStage(makeLoadedTexture()); + + const node = new CoreTextNode( + stage, + defaultProps({ text: 'Hello' }), + renderer, + ); + + expect(renderer.measureText).not.toHaveBeenCalled(); + expect(renderer.renderText).not.toHaveBeenCalled(); + expect(node.props.w).toBe(0); + expect(node.props.h).toBe(0); + }); +}); + describe('CoreTextNode (sdf) renderOnlyInViewport', () => { const makeSdfRenderer = (): TextRenderer => { const font = mock({ type: 'sdf' }); diff --git a/src/core/CoreTextNode.ts b/src/core/CoreTextNode.ts index 4d33db9..6dd7c08 100644 --- a/src/core/CoreTextNode.ts +++ b/src/core/CoreTextNode.ts @@ -15,7 +15,6 @@ import { } from './CoreNode.js'; import type { Stage } from './Stage.js'; import type { - NodeTextFailedPayload, NodeTextLoadedPayload, NodeTextureLoadedPayload, } from '../common/CommonTypes.js'; @@ -85,6 +84,39 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps { this._containType = TextConstraint[props.contain]; this.setUpdateType(UpdateType.All); + + // Eagerly measure the text dimensions at construction (when the font is + // already loaded) so layout consumers can read the node's real w/h before + // it is rendered. This computes layout only — the actual rasterization / + // glyph upload stays on update()'s visibility-gated path, exactly as + // before, so off-screen text still does no render work. Measuring is cheap + // and emits no `loaded` event, so it runs synchronously here. + if (this.fontHandler.isFontLoaded(props.fontFamily) === true) { + this.generateDimensions(); + } + } + + /** + * Measure the text and apply its dimensions to the node without rendering. + * + * Sets w/h from the renderer's measure-only path and flags the + * dimension-dependent updates, but leaves `_layoutGenerated` false so + * update() still runs the full (deferred, visibility-gated) render later. + */ + private generateDimensions(): void { + const info = this.textRenderer.measureText(this.textProps); + if (info.width === this.props.w && info.height === this.props.h) { + return; + } + this.props.w = info.width; + this.props.h = info.height; + this._renderInfo = info; + // Direct props.w/h writes bypass the w/h setters, so raise RecalcUniforms + // (dimensions feed shader uniforms) alongside Local/RenderBounds — mirrors + // handleRenderResult. + this.setUpdateType( + UpdateType.Local | UpdateType.RenderBounds | UpdateType.RecalcUniforms, + ); } protected override onTextureLoaded: TextureLoadedEventHandler = ( @@ -192,25 +224,36 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps { } } + /** + * Generate the text layout for the current props. + * + * If the font is already loaded the layout is produced and `_layoutGenerated` + * is latched so `update()` won't redo it. If the font is not yet loaded the + * node registers with the font handler and the layout is produced on a later + * frame, once the load promise resolves. + */ + private generateLayout(): void { + if (this.fontHandler.isFontLoaded(this.textProps.fontFamily)) { + this._waitingForFont = false; + this._cachedLayout = null; // Invalidate cached layout + const resp = this.textRenderer.renderText(this.textProps); + this.handleRenderResult(resp); + this._layoutGenerated = true; + } else if (!this._waitingForFont) { + this.fontHandler.waitingForFont(this.textProps.fontFamily, this); + this._waitingForFont = true; + } + } + /** * Override CoreNode's update method to handle text-specific updates */ override update(delta: number, parentClippingRect: RectWithValid): void { if ( - (this.textProps.forceLoad === true || - this.allowTextGeneration() === true) && - this._layoutGenerated === false + (this.textProps.forceLoad || this.allowTextGeneration()) && + !this._layoutGenerated ) { - if (this.fontHandler.isFontLoaded(this.textProps.fontFamily) === true) { - this._waitingForFont = false; - this._cachedLayout = null; // Invalidate cached layout - const resp = this.textRenderer.renderText(this.textProps); - this.handleRenderResult(resp); - this._layoutGenerated = true; - } else if (this._waitingForFont === false) { - this.fontHandler.waitingForFont(this.textProps.fontFamily, this); - this._waitingForFont = true; - } + this.generateLayout(); } // First run the standard CoreNode update diff --git a/src/core/text-rendering/CanvasTextRenderer.ts b/src/core/text-rendering/CanvasTextRenderer.ts index 43bded2..73961c3 100644 --- a/src/core/text-rendering/CanvasTextRenderer.ts +++ b/src/core/text-rendering/CanvasTextRenderer.ts @@ -83,6 +83,53 @@ const init = (stage: Stage): void => { CanvasFontHandler.init(context, measureContext); }; +/** + * Measure-only path: lay out the text via the layout engine and return its + * dimensions, without rasterizing. Used to populate a text node's w/h eagerly + * at construction. + * + * Kept as a standalone function (rather than a helper shared with + * {@link renderText}) so renderText stays byte-identical to upstream — the + * mapTextLayout invocation below is intentionally a mirror of the one in + * renderText and must be kept in sync with it. See {@link TextRenderer.measureText}. + */ +const measureText = (props: CoreTextNodeProps): TextRenderInfo => { + assertTruthy(measureContext, 'Canvas measureContext is not available'); + + if (props.text.length === 0) { + return { + width: 0, + height: 0, + }; + } + + const { fontFamily, fontStyle, fontSize } = props; + measureContext.font = `${fontStyle} ${fontSize}px Unknown, ${fontFamily}`; + measureContext.textBaseline = 'alphabetic'; + + const metrics = CanvasFontHandler.getFontMetrics(fontFamily, fontSize); + + const [, , , , , effectiveWidth, effectiveHeight] = mapTextLayout( + CanvasFontHandler.measureText, + metrics, + props.text, + props.textAlign, + fontFamily, + props.lineHeight, + props.overflowSuffix, + props.wordBreak, + props.letterSpacing, + props.maxLines, + props.maxWidth, + props.maxHeight, + ); + + return { + width: effectiveWidth, + height: effectiveHeight, + }; +}; + /** * Canvas text renderer * @@ -273,6 +320,7 @@ const CanvasTextRenderer = { type, font, renderText, + measureText, addQuads, renderQuads, init, diff --git a/src/core/text-rendering/SdfTextRenderer.ts b/src/core/text-rendering/SdfTextRenderer.ts index b6172e5..7626527 100644 --- a/src/core/text-rendering/SdfTextRenderer.ts +++ b/src/core/text-rendering/SdfTextRenderer.ts @@ -255,6 +255,59 @@ const renderQuads = ( return null; }; +/** + * Measure-only path: lay out the text via the layout engine and return its + * dimensions, without building the per-glyph vertex buffer. Used to populate a + * text node's w/h eagerly at construction. + * + * Kept as a standalone function (rather than a helper shared with + * {@link generateTextLayout}) so generateTextLayout stays byte-identical to + * upstream — the mapTextLayout invocation and the design-unit -> pixel scaling + * below mirror generateTextLayout and must be kept in sync with it. See + * {@link TextRenderer.measureText}. + */ +const measureText = (props: CoreTextNodeProps): TextRenderInfo => { + if (props.text.length === 0) { + return { width: 0, height: 0 }; + } + + const fontCache = SdfFontHandler.getFontData(props.fontFamily); + if (fontCache === undefined) { + return { width: 0, height: 0 }; + } + + const fontSize = props.fontSize; + const fontFamily = props.fontFamily; + const metrics = SdfFontHandler.getFontMetrics(fontFamily, fontSize); + + // Pixel scale from atlas design units to pixels. + const fontScale = fontSize / fontCache.data.info.size; + const letterSpacing = props.letterSpacing / fontScale; + const maxWidth = props.maxWidth / fontScale; + + const [, , , , , effectiveWidth, effectiveHeight] = mapTextLayout( + SdfFontHandler.measureText, + metrics, + props.text, + props.textAlign, + fontFamily, + props.lineHeight, + props.overflowSuffix, + props.wordBreak, + letterSpacing, + props.maxLines, + maxWidth, + props.maxHeight, + ); + + // generateTextLayout returns width in pixel space (effectiveWidth * fontScale) + // and height already in pixels — mirror that here. + return { + width: effectiveWidth * fontScale, + height: effectiveHeight, + }; +}; + /** * Generate complete text layout with glyph positioning for caching */ @@ -439,6 +492,7 @@ const SdfTextRenderer = { type, font, renderText, + measureText, addQuads, renderQuads, init, diff --git a/src/core/text-rendering/TextRenderer.ts b/src/core/text-rendering/TextRenderer.ts index 72703da..e13d27b 100644 --- a/src/core/text-rendering/TextRenderer.ts +++ b/src/core/text-rendering/TextRenderer.ts @@ -436,6 +436,14 @@ export interface TextRenderer { type: 'canvas' | 'sdf'; font: FontHandler; renderText: (props: CoreTextNodeProps) => TextRenderInfo; + /** + * Compute only the laid-out dimensions of the text, without rasterizing + * (Canvas) or building glyph vertex data (SDF). Used to populate a text + * node's `w`/`h` eagerly at construction so layout consumers can react to + * the real size before the node is actually rendered. The returned width + * and height must match what {@link renderText} produces for the same props. + */ + measureText: (props: CoreTextNodeProps) => TextRenderInfo; // Updated to accept layout data and return vertex buffer for performance addQuads: (layout?: TextLayout) => Float32Array | null; renderQuads: (