From 6952b867f31d394007ba968deae385004c6fdb1d Mon Sep 17 00:00:00 2001 From: frouaix Date: Fri, 1 May 2026 23:10:30 -0700 Subject: [PATCH 1/8] Add custom selection geometry hook --- packages/core/src/core/block-registry.ts | 11 ++++ packages/core/src/index.ts | 5 +- .../src/selection/character-bounds-adapter.ts | 56 ++++++++++++++----- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/packages/core/src/core/block-registry.ts b/packages/core/src/core/block-registry.ts index 294561a..d7da983 100644 --- a/packages/core/src/core/block-registry.ts +++ b/packages/core/src/core/block-registry.ts @@ -27,11 +27,22 @@ export interface CustomBlockDebugApi { context: RenderContext; } +export interface CustomBlockSelectionApi { + context?: RenderContext; +} + +export interface CustomBlockSelectionGeometry { + blockId: string; + layoutSignature: string; + rgrclCharacterBounds: Rc[]; +} + export interface CustomBlockHandlers { render?: (block: CustomBlockDescriptor, api: CustomBlockRenderApi) => void; hitTestShape?: (block: CustomBlockDescriptor, xl: number, yl: number, api: CustomBlockHitTestApi) => boolean; rcl?: (block: CustomBlockDescriptor) => Rc | null; getDebugOutlineBounds?: (block: CustomBlockDescriptor, api: CustomBlockDebugApi) => Rc | null; + getSelectionGeometry?: (block: CustomBlockDescriptor, api: CustomBlockSelectionApi) => CustomBlockSelectionGeometry | null; } const mpCustomBlockHandlers = new Map(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a6ebbc..fb9a452 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,10 +56,13 @@ export { } from './core/block-registry.ts'; export type { BlockLayoutCache, + CustomBlockDescriptor, CustomBlockHandlers, CustomBlockRenderApi, CustomBlockHitTestApi, - CustomBlockDebugApi + CustomBlockDebugApi, + CustomBlockSelectionApi, + CustomBlockSelectionGeometry } from './core/block-registry.ts'; // Block factory functions diff --git a/packages/gui/src/selection/character-bounds-adapter.ts b/packages/gui/src/selection/character-bounds-adapter.ts index d07ce05..36db9a9 100644 --- a/packages/gui/src/selection/character-bounds-adapter.ts +++ b/packages/gui/src/selection/character-bounds-adapter.ts @@ -5,11 +5,14 @@ import { Matrix2D, Canvas2DContext, layoutTextCharacterBounds, + getBlockTypeHandlers, getBlockTransform, transformRc } from 'vitrine'; import type { Block, + CustomBlockDescriptor, + CustomBlockSelectionGeometry, Rc, RenderContext, TextProps @@ -30,18 +33,17 @@ export interface CharacterBoundsUpdateResult { selectableTextBlockIds: string[]; } -interface TextBlockDescriptor { +interface CharacterBoundsDescriptor { blockId: string; - text: string; - props: TextProps; transformWorld: Matrix2D; layoutSignature: string; + resolveLocalBounds: () => Rc[]; worldSignature: string; } /** Cache bounds per character for text block, keyed by id+hash of text/props/transform */ interface TextBoundsCacheEntry { - descriptor: TextBlockDescriptor; + descriptor: CharacterBoundsDescriptor; rgrcl?: Rc[]; rgrcs?: Rc[]; } @@ -98,7 +100,7 @@ function buildWorldTransformSignature(transform: Matrix2D): string { ].map(signaturePart).join('|'); } -function toTextDescriptor(block: Block, xfCur: Matrix2D): TextBlockDescriptor | null { +function toTextDescriptor(block: Block, xfCur: Matrix2D, context?: RenderContext): CharacterBoundsDescriptor | null { if ( typeof block.props.id !== 'string' || block.props.id.length === 0 @@ -110,10 +112,26 @@ function toTextDescriptor(block: Block, xfCur: Matrix2D): TextBlockDescriptor | const blockId = block.props.id; return { blockId, - text: props.text, - props, transformWorld: xfCur, layoutSignature: buildTextLayoutSignature(props), + resolveLocalBounds: () => layoutTextCharacterBounds(props.text, props, context), + worldSignature: buildWorldTransformSignature(xfCur) + }; +} + +function toCustomSelectionDescriptor( + geometry: CustomBlockSelectionGeometry, + xfCur: Matrix2D +): CharacterBoundsDescriptor | null { + if (geometry.blockId.length === 0) { + return null; + } + + return { + blockId: geometry.blockId, + transformWorld: xfCur, + layoutSignature: geometry.layoutSignature, + resolveLocalBounds: () => geometry.rgrclCharacterBounds, worldSignature: buildWorldTransformSignature(xfCur) }; } @@ -133,11 +151,7 @@ export class CharacterBoundsAdapter { private ensureRgrcl(entry: TextBoundsCacheEntry): Rc[] { if (!entry.rgrcl) { - entry.rgrcl = layoutTextCharacterBounds( - entry.descriptor.text, - entry.descriptor.props, - this.context - ); + entry.rgrcl = entry.descriptor.resolveLocalBounds(); } return entry.rgrcl; } @@ -149,8 +163,8 @@ export class CharacterBoundsAdapter { return entry.rgrcs; } - private buildDescriptorMap(blRoot: Block): Map { - const descriptorsByBlockId = new Map(); + private buildDescriptorMap(blRoot: Block): Map { + const descriptorsByBlockId = new Map(); const stack: Array<{ block: Block; transform: Matrix2D }> = [{ block: blRoot, transform: Matrix2D.identity() }]; while (stack.length > 0) { @@ -162,10 +176,22 @@ export class CharacterBoundsAdapter { const { block, transform: transformParent } = current; const transformWorld = transformParent.multiply(getBlockTransform(block.props)); if (block.type === BlockType.Text) { - const descriptor = toTextDescriptor(block, transformWorld); + const descriptor = toTextDescriptor(block, transformWorld, this.context); if (descriptor) { descriptorsByBlockId.set(descriptor.blockId, descriptor); } + } else { + const handlers = getBlockTypeHandlers(block.type); + const geometry = handlers?.getSelectionGeometry?.( + block as unknown as CustomBlockDescriptor, + { context: this.context } + ); + if (geometry) { + const descriptor = toCustomSelectionDescriptor(geometry, transformWorld); + if (descriptor) { + descriptorsByBlockId.set(descriptor.blockId, descriptor); + } + } } const rgblChildren = block.rgblChildren; From aed1b68cf0b6cd3b3e0bdd959eb2c2c7fdc19e76 Mon Sep 17 00:00:00 2001 From: frouaix Date: Fri, 1 May 2026 23:13:50 -0700 Subject: [PATCH 2/8] Add shared Texta selection layout --- packages/texta/src/vitrine.ts | 423 ++++++++++++++++++++++++++-------- 1 file changed, 323 insertions(+), 100 deletions(-) diff --git a/packages/texta/src/vitrine.ts b/packages/texta/src/vitrine.ts index be1ed68..2cbecf6 100644 --- a/packages/texta/src/vitrine.ts +++ b/packages/texta/src/vitrine.ts @@ -1,5 +1,5 @@ import type { AttributedTextValue } from './types.ts'; -import { getRgRenderBridgeRun } from './render-bridges.ts'; +import { getRgRenderBridgeBoundaryUtf16, getRgRenderBridgeRun } from './render-bridges.ts'; import { customBlock, registerBlockType, @@ -32,11 +32,15 @@ type StyleEntryLike = { type Segment = { text: string; style: StyleEntryLike; + iStart: number; + iEnd: number; }; type SegmentMetrics = { text: string; style: StyleEntryLike; + iStart: number; + iEnd: number; font: string | undefined; width: number; ascent: number; @@ -45,6 +49,26 @@ type SegmentMetrics = { lineHeight: number; }; +type PositionedSegmentMetrics = SegmentMetrics & { + x: number; +}; + +type TextaLayoutLine = { + segments: PositionedSegmentMetrics[]; + width: number; + height: number; + ascent: number; + y: number; + yBaseline: number; +}; + +type TextaLayout = { + lines: TextaLayoutLine[]; + bounds: Rc; + rgrclCharacterBounds: Rc[]; + styleDefault: StyleEntryLike; +}; + export interface TextaBlockProps extends BaseBlockProps { texta: AttributedTextValue; align?: 'left' | 'center' | 'right' | 'start' | 'end'; @@ -99,6 +123,8 @@ function measureSegment( return { text: segment.text, style: segment.style, + iStart: segment.iStart, + iEnd: segment.iEnd, font, width: metrics.width, ascent: metrics.ascent, @@ -108,34 +134,146 @@ function measureSegment( }; } +function createMeasureTextFn( + props: TextaBlockProps, + context?: { measureText?: (text: string, props: { font?: string; fontSize?: number }) => TextMeasure } +): (text: string, props: { font?: string; fontSize?: number }) => TextMeasure { + return (text, metricsProps) => { + if (context?.measureText) { + return context.measureText(text, metricsProps); + } + const fontSize = metricsProps.fontSize ?? props.fontSize ?? 16; + return { + width: text.length * fontSize * SF_TEXT_ADVANCE_APPROX_DEFAULT, + height: fontSize, + ascent: fontSize, + descent: 0 + }; + }; +} + +function getUnitText(value: AttributedTextValue, rgBoundaryUtf16: number[], iUnit: number): string { + const iUtf16Start = rgBoundaryUtf16[iUnit] ?? value.strText.length; + const iUtf16End = rgBoundaryUtf16[iUnit + 1] ?? value.strText.length; + return value.strText.slice(iUtf16Start, iUtf16End); +} + +function buildTextaLayoutSignature(props: TextaBlockProps): string { + return [ + props.texta.iVersion, + props.texta.rgStorageMode, + props.align, + props.baseline, + props.dx, + props.font, + props.fontSize, + props.lineHeight + ].join('|'); +} + function splitRunLines(props: TextaBlockProps): Segment[][] { const runs = getRgRenderBridgeRun(props.texta); + const rgBoundaryUtf16 = getRgRenderBridgeBoundaryUtf16(props.texta); const styleDefault = getDefaultStyle(props); const mpStyleById = props.texta.mpId_StyleEntry as Record; const lineSegments: Segment[][] = [[]]; for (const run of runs) { const style = mpStyleById[run.idStyle] ?? styleDefault; - const parts = run.strSlice.split('\n'); - for (let i = 0; i < parts.length; i++) { - if (parts[i].length > 0) { - lineSegments[lineSegments.length - 1].push({ text: parts[i], style }); - } - if (i < parts.length - 1) { + let textCurrent = ''; + let iStartCurrent = run.iStart; + + for (let iUnit = run.iStart; iUnit < run.iEnd; iUnit++) { + const textUnit = getUnitText(props.texta, rgBoundaryUtf16, iUnit); + if (textUnit === '\n') { + if (textCurrent.length > 0) { + lineSegments[lineSegments.length - 1].push({ + text: textCurrent, + style, + iStart: iStartCurrent, + iEnd: iUnit + }); + textCurrent = ''; + } lineSegments.push([]); + iStartCurrent = iUnit + 1; + continue; } + + if (textCurrent.length === 0) { + iStartCurrent = iUnit; + } + textCurrent += textUnit; + } + + if (textCurrent.length > 0) { + lineSegments[lineSegments.length - 1].push({ + text: textCurrent, + style, + iStart: iStartCurrent, + iEnd: run.iEnd + }); } } return lineSegments; } +function splitSegmentWrapAtoms(segment: Segment, value: AttributedTextValue, rgBoundaryUtf16: number[]): Segment[] { + const atoms: Segment[] = []; + let textCurrent = ''; + let iStartCurrent = segment.iStart; + + for (let iUnit = segment.iStart; iUnit < segment.iEnd; iUnit++) { + const textUnit = getUnitText(value, rgBoundaryUtf16, iUnit); + if (textUnit === ' ') { + if (textCurrent.length > 0) { + textCurrent += textUnit; + atoms.push({ + text: textCurrent, + style: segment.style, + iStart: iStartCurrent, + iEnd: iUnit + 1 + }); + textCurrent = ''; + iStartCurrent = iUnit + 1; + } else { + atoms.push({ + text: textUnit, + style: segment.style, + iStart: iUnit, + iEnd: iUnit + 1 + }); + iStartCurrent = iUnit + 1; + } + continue; + } + + if (textCurrent.length === 0) { + iStartCurrent = iUnit; + } + textCurrent += textUnit; + } + + if (textCurrent.length > 0) { + atoms.push({ + text: textCurrent, + style: segment.style, + iStart: iStartCurrent, + iEnd: segment.iEnd + }); + } + + return atoms; +} + function computeLineMetrics( props: TextaBlockProps, contextMeasure: (text: string, props: { font?: string; fontSize?: number }) => TextMeasure ): { lineMetrics: SegmentMetrics[][]; lineWidths: number[]; lineHeights: number[]; lineAscents: number[]; styleDefault: StyleEntryLike } { const styleDefault = getDefaultStyle(props); const lineSegments = splitRunLines(props); + const rgBoundaryUtf16 = getRgRenderBridgeBoundaryUtf16(props.texta); let lineMetrics: SegmentMetrics[][]; if (props.dx !== undefined) { @@ -143,11 +281,10 @@ function computeLineMetrics( for (const segments of lineSegments) { const atoms: SegmentMetrics[] = []; for (const segment of segments) { - const parts = segment.text.split(' '); - for (let i = 0; i < parts.length; i++) { - const token = i < parts.length - 1 ? `${parts[i]} ` : parts[i]; - if (token.length > 0) { - atoms.push(measureSegment({ text: token, style: segment.style }, styleDefault, contextMeasure, props)); + const segmentAtoms = splitSegmentWrapAtoms(segment, props.texta, rgBoundaryUtf16); + for (const atom of segmentAtoms) { + if (atom.text.length > 0) { + atoms.push(measureSegment(atom, styleDefault, contextMeasure, props)); } } } @@ -189,22 +326,156 @@ function computeLineMetrics( return { lineMetrics, lineWidths, lineHeights, lineAscents, styleDefault }; } -function estimateBounds(props: TextaBlockProps): Rc { - const textValue = props.texta.strText; - const metrics = measureText(textValue, { font: props.font, fontSize: props.fontSize }); - const lineHeight = props.lineHeight ?? props.fontSize ?? 16; - const totalHeight = props.dx === undefined - ? metrics.height - : Math.max(lineHeight, Math.ceil(Math.max(1, metrics.width) / Math.max(1, props.dx)) * lineHeight); - const width = props.dx === undefined ? metrics.width : Math.min(metrics.width, props.dx); - const { xOffset, yOffset } = calculateTextOffset( - width, - totalHeight, - metrics.ascent, - props.align, - props.baseline +function buildTextaLayout(props: TextaBlockProps, context?: { measureText?: (text: string, props: { font?: string; fontSize?: number }) => TextMeasure }): TextaLayout { + const contextMeasure = createMeasureTextFn(props, context); + const { lineMetrics, lineWidths, lineHeights, lineAscents, styleDefault } = computeLineMetrics( + props, + contextMeasure ); - return { x: xOffset, y: yOffset, width, height: totalHeight }; + const iUnitCount = props.texta.rgIdStyleRef.length; + const rgrclCharacterBounds: Array = new Array(iUnitCount).fill(null); + + if (lineMetrics.length === 0) { + return { + lines: [], + bounds: { + x: 0, + y: 0, + width: 0, + height: 0 + }, + rgrclCharacterBounds: [], + styleDefault + }; + } + + const totalHeight = lineHeights.reduce((sum, height) => sum + height, 0); + const firstAscent = lineAscents[0] ?? (props.fontSize ?? styleDefault.fontSize ?? 16); + let yBaseline = 0; + if (props.baseline === 'top') { + yBaseline = firstAscent; + } else if (props.baseline === 'middle') { + yBaseline = -totalHeight / 2 + firstAscent; + } else if (props.baseline === 'bottom') { + yBaseline = -totalHeight + firstAscent; + } else if (props.baseline === 'hanging') { + yBaseline = firstAscent * 0.8; + } + + const getLineStartX = (lineWidth: number): number => { + if (props.align === 'center') { + return -lineWidth / 2; + } + if (props.align === 'right' || props.align === 'end') { + return -lineWidth; + } + return 0; + }; + + const yTop = yBaseline - firstAscent; + let xMin = 0; + let xMax = 0; + for (let i = 0; i < lineWidths.length; i++) { + const lineWidth = lineWidths[i] ?? 0; + const xStart = getLineStartX(lineWidth); + if (i === 0) { + xMin = xStart; + xMax = xStart + lineWidth; + } else { + xMin = Math.min(xMin, xStart); + xMax = Math.max(xMax, xStart + lineWidth); + } + } + + const bounds: Rc = { + x: xMin, + y: yTop, + width: Math.max(0, xMax - xMin), + height: Math.max(0, totalHeight) + }; + + const rgBoundaryUtf16 = getRgRenderBridgeBoundaryUtf16(props.texta); + const lines: TextaLayoutLine[] = []; + let yLineTop = yTop; + let yLineBaseline = yBaseline; + + for (let i = 0; i < lineMetrics.length; i++) { + const line = lineMetrics[i] ?? []; + const lineWidth = lineWidths[i] ?? 0; + const lineHeight = lineHeights[i] ?? 0; + const lineAscent = lineAscents[i] ?? firstAscent; + let xRun = getLineStartX(lineWidth); + const segments: PositionedSegmentMetrics[] = []; + + for (const segment of line) { + segments.push({ + ...segment, + x: xRun + }); + + let widthTotalUnits = 0; + const rgdxUnit: number[] = []; + for (let iUnit = segment.iStart; iUnit < segment.iEnd; iUnit++) { + const textUnit = getUnitText(props.texta, rgBoundaryUtf16, iUnit); + const metrics = contextMeasure(textUnit, segment.font ? { font: segment.font } : { fontSize: segment.fontSize }); + rgdxUnit.push(metrics.width); + widthTotalUnits += metrics.width; + } + + const scale = widthTotalUnits > 0 ? segment.width / widthTotalUnits : 1; + let widthBefore = 0; + for (let iUnitOffset = 0; iUnitOffset < rgdxUnit.length; iUnitOffset++) { + const widthAfter = widthBefore + rgdxUnit[iUnitOffset]!; + rgrclCharacterBounds[segment.iStart + iUnitOffset] = { + x: xRun + widthBefore * scale, + y: yLineTop, + width: Math.max(0, (widthAfter - widthBefore) * scale), + height: lineHeight + }; + widthBefore = widthAfter; + } + + xRun += segment.width; + } + + lines.push({ + segments, + width: lineWidth, + height: lineHeight, + ascent: lineAscent, + y: yLineTop, + yBaseline: yLineBaseline + }); + + yLineTop += lineHeight; + yLineBaseline += lineHeight; + } + + let xFallback = bounds.x + bounds.width; + let yFallback = bounds.y; + let heightFallback = lineHeights[0] ?? 0; + for (let i = 0; i < rgrclCharacterBounds.length; i++) { + const rc = rgrclCharacterBounds[i]; + if (rc) { + xFallback = rc.x + rc.width; + yFallback = rc.y; + heightFallback = rc.height; + continue; + } + rgrclCharacterBounds[i] = { + x: xFallback, + y: yFallback, + width: 0, + height: heightFallback + }; + } + + return { + lines, + bounds, + rgrclCharacterBounds: rgrclCharacterBounds as Rc[], + styleDefault + }; } function createTextaHandlers(): CustomBlockHandlers { @@ -212,88 +483,30 @@ function createTextaHandlers(): CustomBlockHandlers { render: (block, api): void => { const props = block.props as unknown as TextaBlockProps; const { - align, - baseline, fill: fillDefault, stroke: strokeDefault, strokeWidth: strokeWidthDefault } = props; - const { lineMetrics, lineWidths, lineHeights, lineAscents, styleDefault } = computeLineMetrics( - props, - (text, metricsProps) => { - if (api.context.measureText) { - return api.context.measureText(text, metricsProps); - } - const fontSize = metricsProps.fontSize ?? props.fontSize ?? 16; - return { width: text.length * fontSize * SF_TEXT_ADVANCE_APPROX_DEFAULT, height: fontSize, ascent: fontSize, descent: 0 }; - } - ); - if (lineMetrics.length === 0) { + const layout = buildTextaLayout(props, api.context); + if (layout.lines.length === 0) { return; } - const totalHeight = lineHeights.reduce((sum, height) => sum + height, 0); - const firstAscent = lineAscents[0] ?? (props.fontSize ?? styleDefault.fontSize ?? 16); - let yBaseline = 0; - if (baseline === 'top') { - yBaseline = firstAscent; - } else if (baseline === 'middle') { - yBaseline = -totalHeight / 2 + firstAscent; - } else if (baseline === 'bottom') { - yBaseline = -totalHeight + firstAscent; - } else if (baseline === 'hanging') { - yBaseline = firstAscent * 0.8; - } + api.setLayoutBounds(layout.bounds); - const getLineStartX = (lineWidth: number): number => { - if (align === 'center') { - return -lineWidth / 2; - } - if (align === 'right' || align === 'end') { - return -lineWidth; - } - return 0; - }; - - const yTop = yBaseline - firstAscent; - let xMin = 0; - let xMax = 0; - for (let i = 0; i < lineWidths.length; i++) { - const lineWidth = lineWidths[i] ?? 0; - const xStart = getLineStartX(lineWidth); - if (i === 0) { - xMin = xStart; - xMax = xStart + lineWidth; - } else { - xMin = Math.min(xMin, xStart); - xMax = Math.max(xMax, xStart + lineWidth); - } - } - - api.setLayoutBounds({ - x: xMin, - y: yTop, - width: Math.max(0, xMax - xMin), - height: Math.max(0, totalHeight) - }); - - for (let i = 0; i < lineMetrics.length; i++) { - const line = lineMetrics[i]!; - let xRun = getLineStartX(lineWidths[i] ?? 0); - - for (const segment of line) { + for (const line of layout.lines) { + for (const segment of line.segments) { const { style } = segment; const fill = style.fill ?? (typeof fillDefault === 'string' ? fillDefault : undefined) - ?? styleDefault.fill; + ?? layout.styleDefault.fill; const background = style.background; const stroke = style.stroke ?? (typeof strokeDefault === 'string' ? strokeDefault : undefined) - ?? styleDefault.stroke; + ?? layout.styleDefault.stroke; const opacity = style.opacity ?? 1; if (!fill && !stroke && !background) { - xRun += segment.width; continue; } @@ -302,10 +515,10 @@ function createTextaHandlers(): CustomBlockHandlers { if (background) { const bgHeight = segment.ascent + segment.descent; - api.context.drawRectangle(xRun, yBaseline - segment.ascent, segment.width, bgHeight, { fill: background }); + api.context.drawRectangle(segment.x, line.yBaseline - segment.ascent, segment.width, bgHeight, { fill: background }); } - api.context.drawText(segment.text, xRun, yBaseline, { + api.context.drawText(segment.text, segment.x, line.yBaseline, { font: segment.font, fontSize: segment.fontSize, fill, @@ -315,10 +528,7 @@ function createTextaHandlers(): CustomBlockHandlers { baseline: 'alphabetic' }); api.context.restore(); - xRun += segment.width; } - - yBaseline += lineHeights[i] ?? 0; } }, hitTestShape: (block, xl, yl, { layoutCache }): boolean => { @@ -329,14 +539,27 @@ function createTextaHandlers(): CustomBlockHandlers { && yl >= cachedBounds.y && yl <= cachedBounds.y + cachedBounds.height; } - const bounds = estimateBounds(block.props as unknown as TextaBlockProps); + const bounds = buildTextaLayout(block.props as unknown as TextaBlockProps).bounds; return xl >= bounds.x && xl <= bounds.x + bounds.width && yl >= bounds.y && yl <= bounds.y + bounds.height; }, - rcl: (block): Rc => estimateBounds(block.props as unknown as TextaBlockProps), - getDebugOutlineBounds: (block): Rc => estimateBounds(block.props as unknown as TextaBlockProps) + rcl: (block): Rc => buildTextaLayout(block.props as unknown as TextaBlockProps).bounds, + getDebugOutlineBounds: (block): Rc => buildTextaLayout(block.props as unknown as TextaBlockProps).bounds, + getSelectionGeometry: (block, api) => { + const props = block.props as unknown as TextaBlockProps; + if (typeof props.id !== 'string' || props.id.length === 0) { + return null; + } + + const layout = buildTextaLayout(props, api.context); + return { + blockId: props.id, + layoutSignature: buildTextaLayoutSignature(props), + rgrclCharacterBounds: layout.rgrclCharacterBounds + }; + } }; } From f84eeee4e3483b35a6dbe7cf8e40b8f7f5f354ee Mon Sep 17 00:00:00 2001 From: frouaix Date: Fri, 1 May 2026 23:15:04 -0700 Subject: [PATCH 3/8] Add texta selection regression coverage --- docs/TEXT_SELECTION_REGULAR_TEXT.md | 4 +- packages/texta/test/vitrine-selection.test.ts | 92 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 packages/texta/test/vitrine-selection.test.ts diff --git a/docs/TEXT_SELECTION_REGULAR_TEXT.md b/docs/TEXT_SELECTION_REGULAR_TEXT.md index bc5f346..f8e2131 100644 --- a/docs/TEXT_SELECTION_REGULAR_TEXT.md +++ b/docs/TEXT_SELECTION_REGULAR_TEXT.md @@ -78,6 +78,6 @@ Use this only for custom rendering paths where automatic `text()` extraction is ## Current scope -- Automatic adapter support is for regular `text()` blocks with `id`. +- Automatic adapter support covers regular `text()` blocks with `id`. +- It also covers custom blocks that expose selection geometry through the block registry, including `texta()` once `registerTextaBlockType()` has been called. - Cross-block selections are not supported. -- `texta()` requires custom handling if you need selection behavior equivalent to `text()`. diff --git a/packages/texta/test/vitrine-selection.test.ts b/packages/texta/test/vitrine-selection.test.ts new file mode 100644 index 0000000..b6f59dd --- /dev/null +++ b/packages/texta/test/vitrine-selection.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { group, text } from "vitrine"; +import { createCharacterBoundsProviderFromBlockTree } from "../../gui/src/index.ts"; +import { detectRgStorageMode, getRgCodePointBoundaryUtf16, getRgGraphemeBoundaryUtf16 } from "../src/index.ts"; +import { registerTextaBlockType, texta } from "../src/vitrine.ts"; +import type { AttributedTextValue, RgStorageMode, StyleEntry } from "../src/types.ts"; + +function getUnitCount(strText: string, rgStorageMode: RgStorageMode): number { + if (rgStorageMode === "fastCodeUnit") { + return strText.length; + } + if (rgStorageMode === "fastCodePoint") { + return getRgCodePointBoundaryUtf16(strText).length - 1; + } + return getRgGraphemeBoundaryUtf16(strText).length - 1; +} + +function getRgSegGraphemeToUtf16(strText: string, rgStorageMode: RgStorageMode): number[] { + if (rgStorageMode !== "segmentedGrapheme") { + return []; + } + return getRgGraphemeBoundaryUtf16(strText).slice(1); +} + +function createAttributedText(strText: string, styleDefault: StyleEntry): AttributedTextValue { + const rgStorageMode = detectRgStorageMode(strText); + const iUnits = getUnitCount(strText, rgStorageMode); + return { + iVersion: 1, + rgUnits: "grapheme", + rgStorageMode, + strText, + rgSegGraphemeToUtf16: getRgSegGraphemeToUtf16(strText, rgStorageMode), + rgIdStyleRef: new Array(iUnits).fill(1), + mpId_StyleEntry: { 1: styleDefault }, + idStyleDefault: 1 + }; +} + +describe("texta selection geometry", () => { + it("keeps regular text selection working while adding texta support", () => { + registerTextaBlockType(); + + const provider = createCharacterBoundsProviderFromBlockTree( + group({}, [ + text({ + id: "plain", + x: 10, + y: 5, + text: "Plain", + fontSize: 20, + baseline: "top" + }), + texta({ + id: "rich", + x: 30, + y: 40, + dx: 25, + baseline: "top", + texta: createAttributedText("A😀 B", { + fontFamily: "ui-sans-serif", + fontSize: 18, + lineHeight: 24, + fill: "#111827" + }) + }) + ]) + ); + + const rcPlain0 = provider("plain", 0); + const rcPlain1 = provider("plain", 1); + const rcRich0 = provider("rich", 0); + const rcRich1 = provider("rich", 1); + const rcRich2 = provider("rich", 2); + const rcRich3 = provider("rich", 3); + + expect(rcPlain0).not.toBeNull(); + expect(rcPlain1).not.toBeNull(); + expect(rcPlain1!.x).toBeGreaterThan(rcPlain0!.x); + + expect(rcRich0).not.toBeNull(); + expect(rcRich1).not.toBeNull(); + expect(rcRich2).not.toBeNull(); + expect(rcRich3).not.toBeNull(); + + expect(rcRich0!.x).toBeGreaterThanOrEqual(30); + expect(rcRich0!.y).toBeGreaterThanOrEqual(40); + expect(rcRich1!.x).toBeGreaterThan(rcRich0!.x); + expect(rcRich2!.x).toBeGreaterThanOrEqual(rcRich1!.x); + expect(rcRich3!.y).toBeGreaterThan(rcRich0!.y); + }); +}); \ No newline at end of file From f96f166f6afcdf7dacb6037d29e413b89ac77fda Mon Sep 17 00:00:00 2001 From: frouaix Date: Sat, 2 May 2026 10:35:13 -0700 Subject: [PATCH 4/8] demo page and bug fixes Co-authored-by: Copilot --- packages/demo/component-demo.html | 2 +- .../demo/text-selection-debug-texta-scene.js | 351 ++++++++++++++++++ .../demo/text-selection-debug-texta-setup.js | 87 +++++ packages/demo/text-selection-debug-texta.css | 152 ++++++++ packages/demo/text-selection-debug-texta.html | 48 +++ packages/demo/text-selection-debug.html | 2 +- packages/demo/vite.config.ts | 1 + packages/texta/src/vitrine.ts | 5 +- 8 files changed, 643 insertions(+), 5 deletions(-) create mode 100644 packages/demo/text-selection-debug-texta-scene.js create mode 100644 packages/demo/text-selection-debug-texta-setup.js create mode 100644 packages/demo/text-selection-debug-texta.css create mode 100644 packages/demo/text-selection-debug-texta.html diff --git a/packages/demo/component-demo.html b/packages/demo/component-demo.html index ab49e43..f500c0e 100644 --- a/packages/demo/component-demo.html +++ b/packages/demo/component-demo.html @@ -181,7 +181,7 @@

Raw Blocks

diff --git a/packages/demo/text-selection-debug-texta-scene.js b/packages/demo/text-selection-debug-texta-scene.js new file mode 100644 index 0000000..e78aca8 --- /dev/null +++ b/packages/demo/text-selection-debug-texta-scene.js @@ -0,0 +1,351 @@ +import { group, rectangle, text } from 'vitrine'; +import { + applyStyle, + detectRgStorageMode, + getRgCodePointBoundaryUtf16, + getRgGraphemeBoundaryUtf16, + texta as textaBlock +} from 'texta/browser'; + +export const DEBUG_TEXTA_BLOCK_IDS = [ + 'ta1', + 'ta2', + 'ta3', + 'ta4', + 'ta5', + 'ta6', + 'ta7', + 'ta8', + 'ta9', + 'ta10' +]; + +function getUnitCount(strText, rgStorageMode) { + if (rgStorageMode === 'fastCodeUnit') { + return strText.length; + } + if (rgStorageMode === 'fastCodePoint') { + return getRgCodePointBoundaryUtf16(strText).length - 1; + } + return getRgGraphemeBoundaryUtf16(strText).length - 1; +} + +function getRgSegGraphemeToUtf16(strText, rgStorageMode) { + if (rgStorageMode !== 'segmentedGrapheme') { + return []; + } + return getRgGraphemeBoundaryUtf16(strText).slice(1); +} + +function createAttributedText(strText, styleDefault) { + const rgStorageMode = detectRgStorageMode(strText); + const iUnits = getUnitCount(strText, rgStorageMode); + return { + iVersion: 1, + rgUnits: 'grapheme', + rgStorageMode, + strText, + rgSegGraphemeToUtf16: getRgSegGraphemeToUtf16(strText, rgStorageMode), + rgIdStyleRef: new Array(iUnits).fill(1), + mpId_StyleEntry: { 1: styleDefault }, + idStyleDefault: 1 + }; +} + +function applyRanges(value, ranges) { + let next = value; + const unitCount = next.rgIdStyleRef.length; + for (const range of ranges) { + const start = Math.max(0, Math.min(unitCount, range.start)); + const end = Math.max(start, Math.min(unitCount, range.end)); + if (end > start) { + next = applyStyle(next, start, end, range.style, 'merge'); + } + } + return next; +} + +function valueHeadline() { + const base = createAttributedText( + 'Texta Selection Debug: mixed runs and styles', + { fontFamily: 'ui-sans-serif', fontSize: 22, fill: '#0f172a', lineHeight: 30 } + ); + + return applyRanges(base, [ + { start: 0, end: 5, style: { fontWeight: '700', fill: '#2563eb' } }, + { start: 6, end: 15, style: { fontStyle: 'italic', fill: '#7c3aed' } }, + { start: 23, end: 28, style: { fontWeight: '700', background: '#fef3c7', fill: '#92400e' } }, + { start: 34, end: 40, style: { fontWeight: '700', fill: '#0f766e' } } + ]); +} + +function valueParagraph() { + const str = 'Wrapped paragraph with highlighted tokens, italic spans, and varying line height to stress hit testing and caret alignment in texta selection.'; + const base = createAttributedText( + str, + { fontFamily: 'Georgia', fontSize: 16, fill: '#334155', lineHeight: 24 } + ); + + return applyRanges(base, [ + { start: 0, end: 7, style: { fontWeight: '700', fill: '#1d4ed8' } }, + { start: 28, end: 44, style: { fontStyle: 'italic', fill: '#7c3aed' } }, + { start: 65, end: 89, style: { lineHeight: 30, fontSize: 18, fill: '#0f766e' } }, + { start: 96, end: 112, style: { background: '#dcfce7', fill: '#166534' } } + ]); +} + +function valueMultilineRuns() { + const str = 'Line one: regular\nLine two: bold + orange\nLine three: small mono + cyan bg'; + const base = createAttributedText( + str, + { fontFamily: 'ui-sans-serif', fontSize: 17, fill: '#1f2937', lineHeight: 27 } + ); + + return applyRanges(base, [ + { start: 20, end: 39, style: { fontWeight: '700', fill: '#c2410c' } }, + { start: 52, end: 74, style: { fontFamily: 'monospace', fontSize: 14, background: '#cffafe', fill: '#0f172a' } } + ]); +} + +function valueMixedScript() { + const str = 'Unicode mix: precomposed e\u0301 vs decomposed e\u0301, flag \ud83c\uddeb\ud83c\uddf7, tone \ud83d\udc4d\ud83c\udffd, ZWJ \ud83d\udc69\u200d\ud83d\udcbb, family \ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66, VS16 \u2764\ufe0f, Indic \u0915\u094d\u0937, CJK \u6587\u5b57\u30ec\u30f3\u30c0\u30ea\u30f3\u30b0'; + const base = createAttributedText( + str, + { fontFamily: 'ui-sans-serif', fontSize: 15, fill: '#0f172a', lineHeight: 24 } + ); + + const iFlag = str.indexOf('\ud83c\uddeb\ud83c\uddf7'); + const iTone = str.indexOf('\ud83d\udc4d\ud83c\udffd'); + const iZwj = str.indexOf('\ud83d\udc69\u200d\ud83d\udcbb'); + const iFamily = str.indexOf('\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d\udc66'); + const iVs16 = str.indexOf('\u2764\ufe0f'); + const iIndic = str.indexOf('\u0915\u094d\u0937'); + const iCjk = str.indexOf('\u6587\u5b57\u30ec\u30f3\u30c0\u30ea\u30f3\u30b0'); + + return applyRanges(base, [ + { start: 0, end: 11, style: { fontWeight: '700', fill: '#2563eb' } }, + { start: Math.max(0, iFlag - 5), end: iFlag + 8, style: { fill: '#7c3aed', fontStyle: 'italic' } }, + { start: Math.max(0, iTone - 5), end: iTone + 8, style: { background: '#fef3c7', fill: '#92400e' } }, + { start: Math.max(0, iZwj - 4), end: iZwj + 10, style: { fill: '#be123c', fontWeight: '700' } }, + { start: Math.max(0, iFamily - 7), end: iFamily + 14, style: { fill: '#0f766e' } }, + { start: Math.max(0, iVs16 - 4), end: iVs16 + 6, style: { background: '#fee2e2', fill: '#b91c1c' } }, + { start: Math.max(0, iIndic - 6), end: iIndic + 7, style: { fill: '#1d4ed8', fontWeight: '700' } }, + { start: Math.max(0, iCjk - 4), end: str.length, style: { fill: '#065f46' } } + ]); +} + +function valueAlignment(tag) { + const str = `${tag}: center and right aligned runs`; + const base = createAttributedText( + str, + { fontFamily: 'ui-sans-serif', fontSize: 15, fill: '#334155', lineHeight: 22 } + ); + return applyRanges(base, [ + { start: 0, end: tag.length, style: { fontWeight: '700', fill: '#1d4ed8' } }, + { start: 20, end: str.length, style: { fontStyle: 'italic', fill: '#7c3aed' } } + ]); +} + +function valueClipped() { + const str = 'This texta block should visibly clip. Long token: SUPERCALIFRAGILISTICEXPIALIDOCIOUS_TOKEN_1234567890 and then more wrapped rows to force vertical overflow beyond the frame.'; + const base = createAttributedText( + str, + { fontFamily: 'Georgia', fontSize: 15, fill: '#334155', lineHeight: 22 } + ); + + const stPrefix = 'This texta block'; + const stLabel = 'Long token:'; + const stTail = 'vertical overflow beyond the frame'; + const iPrefix = str.indexOf(stPrefix); + const iLabel = str.indexOf(stLabel); + const iTail = str.indexOf(stTail); + + return applyRanges(base, [ + { start: iPrefix, end: iPrefix + stPrefix.length, style: { fontWeight: '700', fill: '#1d4ed8' } }, + { start: iLabel, end: iLabel + stLabel.length, style: { background: '#ede9fe', fill: '#6d28d9' } }, + { start: iTail, end: iTail + stTail.length, style: { fontStyle: 'italic', fill: '#0f766e' } } + ]); +} + +function textaCard({ title, id, x, y, dx, dy, align, baseline, value, fill = '#ffffff', clipText = false }) { + const dxContent = dx - 24; + const dyContent = dy - 46; + let xTexta = 12; + if (align === 'center') { + xTexta = 12 + dxContent / 2; + } else if (align === 'right' || align === 'end') { + xTexta = 12 + dxContent; + } + + const textaContent = clipText + ? group({ x: 12, y: 34, clip: true, dx: dxContent, dy: dyContent }, [ + textaBlock({ + id, + x: xTexta - 12, + y: 0, + dx: dxContent, + dy: dyContent, + baseline: baseline ?? 'top', + align: align ?? 'left', + texta: value + }) + ]) + : textaBlock({ + id, + x: xTexta, + y: 34, + dx: dxContent, + dy: dyContent, + baseline: baseline ?? 'top', + align: align ?? 'left', + texta: value + }); + + return group({ x, y }, [ + rectangle({ dx, dy, fill, stroke: '#dbe3ef', strokeWidth: 1, cornerRadius: 10 }), + text({ x: 12, y: 12, text: title, fontSize: 12, fill: '#64748b', baseline: 'top', font: '600 12px ui-sans-serif' }), + textaContent + ]); +} + +export function buildDebugTextaScene() { + return group({}, [ + rectangle({ dx: 1000, dy: 920, fill: '#ffffff' }), + + textaCard({ + title: 'ta1: headline with mixed run styles', + id: 'ta1', + x: 26, + y: 20, + dx: 470, + dy: 110, + value: valueHeadline() + }), + + textaCard({ + title: 'ta2: wrapped paragraph with dynamic line-height runs', + id: 'ta2', + x: 26, + y: 142, + dx: 470, + dy: 210, + value: valueParagraph() + }), + + textaCard({ + title: 'ta3: explicit multiline with per-line run styles', + id: 'ta3', + x: 26, + y: 364, + dx: 470, + dy: 176, + value: valueMultilineRuns() + }), + + textaCard({ + title: 'ta4: unicode and emoji runs', + id: 'ta4', + x: 26, + y: 552, + dx: 470, + dy: 176, + value: valueMixedScript() + }), + + textaCard({ + title: 'ta5: clipped rows (dy constrained)', + id: 'ta5', + x: 26, + y: 740, + dx: 470, + dy: 124, + value: valueClipped(), + fill: '#fffdfa', + clipText: true + }), + + textaCard({ + title: 'ta6: centered layout', + id: 'ta6', + x: 508, + y: 20, + dx: 466, + dy: 120, + align: 'center', + value: valueAlignment('Center') + }), + + textaCard({ + title: 'ta7: right aligned layout', + id: 'ta7', + x: 508, + y: 152, + dx: 466, + dy: 120, + align: 'right', + value: valueAlignment('Right') + }), + + textaCard({ + title: 'ta8: top baseline + dense wrap', + id: 'ta8', + x: 508, + y: 284, + dx: 466, + dy: 150, + baseline: 'top', + value: applyRanges( + createAttributedText( + 'Dense wrapping at narrow width with bold and italic markers for caret edge tests.', + { fontFamily: 'ui-sans-serif', fontSize: 14, fill: '#334155', lineHeight: 18 } + ), + [ + { start: 0, end: 5, style: { fontWeight: '700', fill: '#1d4ed8' } }, + { start: 33, end: 49, style: { fontStyle: 'italic', fill: '#7c3aed' } }, + { start: 59, end: 73, style: { background: '#dcfce7', fill: '#166534' } } + ] + ) + }), + + textaCard({ + title: 'ta9: run opacity and background mix', + id: 'ta9', + x: 508, + y: 446, + dx: 466, + dy: 170, + value: applyRanges( + createAttributedText( + 'Opacity fades, highlighted chips, and medium-large glyphs in one wrapped block.', + { fontFamily: 'ui-sans-serif', fontSize: 16, fill: '#0f172a', lineHeight: 24 } + ), + [ + { start: 0, end: 13, style: { opacity: 0.45, fill: '#1d4ed8' } }, + { start: 15, end: 32, style: { background: '#fef3c7', fill: '#92400e', fontWeight: '700' } }, + { start: 38, end: 56, style: { fontSize: 19, fill: '#7c3aed' } } + ] + ) + }), + + textaCard({ + title: 'ta10: tiny run + giant run extremes', + id: 'ta10', + x: 508, + y: 628, + dx: 466, + dy: 264, + value: applyRanges( + createAttributedText( + 'size extremes tiny then huge then normal for insertion geometry checks', + { fontFamily: 'ui-sans-serif', fontSize: 15, fill: '#334155', lineHeight: 23 } + ), + [ + { start: 0, end: 12, style: { fontSize: 11, fill: '#475569' } }, + { start: 18, end: 27, style: { fontSize: 32, fontWeight: '700', fill: '#be123c', lineHeight: 38 } }, + { start: 33, end: 42, style: { fontFamily: 'monospace', background: '#e0f2fe', fill: '#0c4a6e' } } + ] + ), + fill: '#f8fafc' + }) + ]); +} diff --git a/packages/demo/text-selection-debug-texta-setup.js b/packages/demo/text-selection-debug-texta-setup.js new file mode 100644 index 0000000..e307d9f --- /dev/null +++ b/packages/demo/text-selection-debug-texta-setup.js @@ -0,0 +1,87 @@ +import { getTextLayoutCacheStats } from 'vitrine'; +import { VitrineComponent } from 'vitrine-gui'; +import { registerTextaBlockType } from 'texta/browser'; +import { buildDebugTextaScene, DEBUG_TEXTA_BLOCK_IDS } from './text-selection-debug-texta-scene.js'; + +registerTextaBlockType(); + +let selectionManager = null; +const debugInfo = document.getElementById('debugInfo'); +const cacheFontsEl = document.getElementById('cacheFonts'); +const cacheEntriesEl = document.getElementById('cacheEntries'); + +function updateCacheStats() { + const stats = getTextLayoutCacheStats(); + cacheFontsEl.textContent = `${stats.fontsInGlyphCache.toLocaleString()} / ${stats.fontsInPrefixCache.toLocaleString()} / ${stats.fontsInMeasureCache.toLocaleString()}`; + cacheEntriesEl.textContent = `${stats.glyphEntries.toLocaleString()} / ${stats.prefixEntries.toLocaleString()} / ${stats.measureEntries.toLocaleString()}`; +} + +function log(msg) { + debugInfo.textContent += `${msg}\n`; + debugInfo.scrollTop = debugInfo.scrollHeight; +} + +function clearDebug() { + debugInfo.textContent = ''; +} + +const component = VitrineComponent.block(buildDebugTextaScene, { + width: 1000, + height: 920, + renderMode: 'auto', + selectionConfig: { + enabled: true, + caretColor: '#dc2626', + selectionColor: 'rgba(59, 130, 246, 0.2)', + caretWidth: 2, + } +}); + +const canvas = document.getElementById('canvas'); +component.mount(canvas); +selectionManager = component.getSelectionManager(); +updateCacheStats(); + +function refreshStatsLoop() { + updateCacheStats(); + requestAnimationFrame(refreshStatsLoop); +} +requestAnimationFrame(refreshStatsLoop); + +const canvasElem = document.getElementById('canvas').querySelector('canvas'); +if (canvasElem) { + canvasElem.addEventListener('click', (e) => { + clearDebug(); + + const rect = canvasElem.getBoundingClientRect(); + log(`Canvas size: ${canvasElem.width} x ${canvasElem.height}`); + log(`Display size: ${rect.width} x ${rect.height}`); + log(`Canvas scale: ${canvasElem.width / rect.width} x ${canvasElem.height / rect.height}`); + + const clientX = e.clientX; + const clientY = e.clientY; + log(`\nClient coords: (${clientX}, ${clientY})`); + + const screenX = clientX - rect.left; + const screenY = clientY - rect.top; + log(`Screen coords (relative to canvas element): (${screenX.toFixed(1)}, ${screenY.toFixed(1)})`); + log(`Scene coords (for hit-testing): (${screenX.toFixed(1)}, ${screenY.toFixed(1)})`); + + if (selectionManager) { + log(`\nHit-testing against blocks: ${DEBUG_TEXTA_BLOCK_IDS.join(', ')}`); + + for (const blockId of DEBUG_TEXTA_BLOCK_IDS) { + const charIndex = selectionManager.hitTestBlockCharacter(blockId, screenX, screenY); + if (charIndex !== null) { + log(`Hit in ${blockId}: insertion index ${charIndex}`); + const sel = selectionManager.getSelection(); + if (sel) { + log(`Selection state: block=${sel.blockId}, anchor=${sel.anchor}, focus=${sel.focus}`); + } + } + } + } + + updateCacheStats(); + }); +} diff --git a/packages/demo/text-selection-debug-texta.css b/packages/demo/text-selection-debug-texta.css new file mode 100644 index 0000000..d91c30a --- /dev/null +++ b/packages/demo/text-selection-debug-texta.css @@ -0,0 +1,152 @@ +:root { + color-scheme: light; + font-family: "Avenir Next", "Segoe UI", sans-serif; + background: linear-gradient(180deg, #fefce8 0%, #f8fafc 100%); + color: #0f172a; +} + +body { + margin: 0; + min-height: 100vh; + padding: 24px 20px 84px; + background: + radial-gradient(circle at top left, rgba(251, 191, 36, 0.14), transparent 30%), + radial-gradient(circle at right 20%, rgba(14, 165, 233, 0.1), transparent 32%), + linear-gradient(180deg, #fefce8 0%, #f8fafc 100%); +} + +.page { + max-width: 1080px; + margin: 0 auto; +} + +h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 3rem); + letter-spacing: -0.03em; + text-align: center; +} + +.intro { + max-width: 760px; + margin: 14px auto 24px; + text-align: center; + font-size: 15px; + line-height: 1.5; + color: #475569; +} + +.canvas-shell { + padding: 16px; + border: 1px solid rgba(148, 163, 184, 0.45); + border-radius: 24px; + background: rgba(255, 255, 255, 0.84); + box-shadow: + 0 0 1px rgba(0, 0, 0, 0.04), + 0 0 2px rgba(0, 0, 0, 0.08); + margin-bottom: 24px; +} + +#canvas { + display: block; + border-radius: 16px; + background: #ffffff; + width: 100%; + height: auto; + min-height: 920px; +} + +.caption { + margin: 8px 0 0; + padding: 0; + font-size: 13px; + color: #64748b; + font-style: italic; +} + +.cache-stats { + margin-top: 20px; + padding: 14px 16px; + background: rgba(15, 23, 42, 0.92); + border-radius: 12px; + border: 1px solid rgba(30, 41, 59, 0.9); + color: #e2e8f0; +} + +.cache-stats h2 { + margin: 0 0 10px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #fde68a; +} + +.cache-stats-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.cache-stat { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px; + border-radius: 8px; + background: rgba(30, 41, 59, 0.55); +} + +.cache-stat-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #fcd34d; +} + +.cache-stat-value { + font-size: 16px; + font-weight: 700; + color: #f8fafc; +} + +.debug-info { + margin-top: 24px; + padding: 16px; + background: #f1f5f9; + border-radius: 12px; + border: 1px solid #cbd5e1; + font-family: 'Courier New', monospace; + font-size: 12px; + line-height: 1.6; + color: #334155; + white-space: pre-wrap; + word-break: break-all; + max-height: 320px; + overflow-y: auto; +} + +.page-nav { + position: fixed; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: space-between; + gap: 12px; + padding: 12px 20px; + border-top: 1px solid rgba(148, 163, 184, 0.35); + background: rgba(248, 250, 252, 0.92); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + font-size: 14px; +} + +.page-nav a { + color: #2563eb; + text-decoration: none; + font-weight: 600; +} + +.page-nav a:hover { + text-decoration: underline; +} diff --git a/packages/demo/text-selection-debug-texta.html b/packages/demo/text-selection-debug-texta.html new file mode 100644 index 0000000..8d9ce70 --- /dev/null +++ b/packages/demo/text-selection-debug-texta.html @@ -0,0 +1,48 @@ + + + + + + + Vitrine - Texta Selection Debug + + + +
+

Vitrine Texta Selection Debug

+

+ Testing text selection on texta() blocks with mixed runs: font family, size, weight, style, + color, background, opacity, wrapping, and alignment variants. +

+
+
+

Canvas with multiple texta blocks for selection and hit-testing diagnostics.

+
+ +
+

Text Cache Counters

+
+
+ Fonts (G/P/M) + 0 / 0 / 0 +
+
+ Entries (G/P/M) + 0 / 0 / 0 +
+
+
+ +
+ Click inside texta blocks and review block hit, insertion index, and selection updates. +
+
+ + + + + + diff --git a/packages/demo/text-selection-debug.html b/packages/demo/text-selection-debug.html index e036d8d..86c845b 100644 --- a/packages/demo/text-selection-debug.html +++ b/packages/demo/text-selection-debug.html @@ -40,7 +40,7 @@

Text Cache Counters

diff --git a/packages/demo/vite.config.ts b/packages/demo/vite.config.ts index 72de084..29f4a1e 100644 --- a/packages/demo/vite.config.ts +++ b/packages/demo/vite.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ tables: resolve(__dirname, 'tables.html'), textSelection: resolve(__dirname, 'text-selection.html'), textSelectionDebug: resolve(__dirname, 'text-selection-debug.html'), + textSelectionDebugTexta: resolve(__dirname, 'text-selection-debug-texta.html'), textSelectionMinimal: resolve(__dirname, 'text-selection-minimal.html') } } diff --git a/packages/texta/src/vitrine.ts b/packages/texta/src/vitrine.ts index 2cbecf6..229c31f 100644 --- a/packages/texta/src/vitrine.ts +++ b/packages/texta/src/vitrine.ts @@ -320,7 +320,7 @@ function computeLineMetrics( if (line.length === 0) { return props.fontSize ?? styleDefault.fontSize ?? 16; } - return Math.max(...line.map((segment) => segment.ascent), lineHeights[i]! * 0.7); + return Math.max(...line.map((segment) => segment.ascent)); }); return { lineMetrics, lineWidths, lineHeights, lineAscents, styleDefault }; @@ -396,7 +396,6 @@ function buildTextaLayout(props: TextaBlockProps, context?: { measureText?: (tex const rgBoundaryUtf16 = getRgRenderBridgeBoundaryUtf16(props.texta); const lines: TextaLayoutLine[] = []; - let yLineTop = yTop; let yLineBaseline = yBaseline; for (let i = 0; i < lineMetrics.length; i++) { @@ -404,6 +403,7 @@ function buildTextaLayout(props: TextaBlockProps, context?: { measureText?: (tex const lineWidth = lineWidths[i] ?? 0; const lineHeight = lineHeights[i] ?? 0; const lineAscent = lineAscents[i] ?? firstAscent; + const yLineTop = yLineBaseline - lineAscent; let xRun = getLineStartX(lineWidth); const segments: PositionedSegmentMetrics[] = []; @@ -447,7 +447,6 @@ function buildTextaLayout(props: TextaBlockProps, context?: { measureText?: (tex yBaseline: yLineBaseline }); - yLineTop += lineHeight; yLineBaseline += lineHeight; } From 3de6a5e52f86628de8169d8eff8975ad294a1949 Mon Sep 17 00:00:00 2001 From: frouaix Date: Sat, 18 Jul 2026 19:23:40 -0700 Subject: [PATCH 5/8] Optimize texta selection layout reuse Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/core/src/core/block-registry.ts | 3 +- .../demo/text-selection-debug-texta-setup.js | 68 ++++- packages/demo/text-selection-debug-texta.css | 11 + packages/demo/text-selection-debug-texta.html | 53 +++- packages/gui/src/component.ts | 9 + .../src/selection/character-bounds-adapter.ts | 10 +- packages/texta/src/browser.ts | 4 + packages/texta/src/index.ts | 4 + packages/texta/src/vitrine.ts | 234 ++++++++++++++---- packages/texta/test/vitrine-selection.test.ts | 201 ++++++++++++++- 10 files changed, 545 insertions(+), 52 deletions(-) diff --git a/packages/core/src/core/block-registry.ts b/packages/core/src/core/block-registry.ts index d7da983..c3172fa 100644 --- a/packages/core/src/core/block-registry.ts +++ b/packages/core/src/core/block-registry.ts @@ -34,7 +34,8 @@ export interface CustomBlockSelectionApi { export interface CustomBlockSelectionGeometry { blockId: string; layoutSignature: string; - rgrclCharacterBounds: Rc[]; + rgrclCharacterBounds?: Rc[]; + resolveCharacterBounds?: () => Rc[]; } export interface CustomBlockHandlers { diff --git a/packages/demo/text-selection-debug-texta-setup.js b/packages/demo/text-selection-debug-texta-setup.js index e307d9f..ff36d37 100644 --- a/packages/demo/text-selection-debug-texta-setup.js +++ b/packages/demo/text-selection-debug-texta-setup.js @@ -1,4 +1,3 @@ -import { getTextLayoutCacheStats } from 'vitrine'; import { VitrineComponent } from 'vitrine-gui'; import { registerTextaBlockType } from 'texta/browser'; import { buildDebugTextaScene, DEBUG_TEXTA_BLOCK_IDS } from './text-selection-debug-texta-scene.js'; @@ -7,13 +6,74 @@ registerTextaBlockType(); let selectionManager = null; const debugInfo = document.getElementById('debugInfo'); +const fpsEl = document.getElementById('fps'); +const avgFpsEl = document.getElementById('avgFps'); +const renderTimeEl = document.getElementById('renderTime'); +const blocksRenderedEl = document.getElementById('blocksRendered'); +const textaCacheEntriesEl = document.getElementById('textaCacheEntries'); +const textaCacheHitRateEl = document.getElementById('textaCacheHitRate'); +const textaCacheHitsEl = document.getElementById('textaCacheHits'); +const textaCacheMissesEl = document.getElementById('textaCacheMisses'); +const textaLayoutBuildsEl = document.getElementById('textaLayoutBuilds'); +const textaCharacterBoundsBuildsEl = document.getElementById('textaCharacterBoundsBuilds'); +const textaSelectionResolvesEl = document.getElementById('textaSelectionResolves'); const cacheFontsEl = document.getElementById('cacheFonts'); const cacheEntriesEl = document.getElementById('cacheEntries'); +function formatInteger(value) { + return typeof value === 'number' && Number.isFinite(value) + ? value.toLocaleString() + : '0'; +} + +function formatDecimal(value, digits = 1) { + return typeof value === 'number' && Number.isFinite(value) + ? value.toFixed(digits) + : '0.0'; +} + +function getHookMetric(hooks, hookName, metricName) { + const hook = hooks?.[hookName]; + const value = hook?.[metricName]; + return typeof value === 'number' ? value : 0; +} + function updateCacheStats() { - const stats = getTextLayoutCacheStats(); - cacheFontsEl.textContent = `${stats.fontsInGlyphCache.toLocaleString()} / ${stats.fontsInPrefixCache.toLocaleString()} / ${stats.fontsInMeasureCache.toLocaleString()}`; - cacheEntriesEl.textContent = `${stats.glyphEntries.toLocaleString()} / ${stats.prefixEntries.toLocaleString()} / ${stats.measureEntries.toLocaleString()}`; + const stats = component.getPerformanceStats(); + if (!stats) { + return; + } + + fpsEl.textContent = formatInteger(stats.fps); + avgFpsEl.textContent = formatInteger(stats.averageFPS); + renderTimeEl.textContent = formatDecimal(stats.renderTime, 2); + blocksRenderedEl.textContent = formatInteger(stats.blocksRendered); + + const textaEntries = getHookMetric(stats.hooks, 'textaLayoutCache', 'cacheEntries'); + const textaHitRate = getHookMetric(stats.hooks, 'textaLayoutCache', 'hitRatePercent'); + const textaHits = getHookMetric(stats.hooks, 'textaLayoutCache', 'cacheHits'); + const textaMisses = getHookMetric(stats.hooks, 'textaLayoutCache', 'cacheMisses'); + const textaLayoutBuilds = getHookMetric(stats.hooks, 'textaLayoutCache', 'layoutBuilds'); + const textaCharacterBoundsBuilds = getHookMetric(stats.hooks, 'textaLayoutCache', 'characterBoundsBuilds'); + const textaSelectionResolves = getHookMetric(stats.hooks, 'textaLayoutCache', 'selectionGeometryResolveCalls'); + + textaCacheEntriesEl.textContent = formatInteger(textaEntries); + textaCacheHitRateEl.textContent = `${formatDecimal(textaHitRate, 1)}%`; + textaCacheHitsEl.textContent = formatInteger(textaHits); + textaCacheMissesEl.textContent = formatInteger(textaMisses); + textaLayoutBuildsEl.textContent = formatInteger(textaLayoutBuilds); + textaCharacterBoundsBuildsEl.textContent = formatInteger(textaCharacterBoundsBuilds); + textaSelectionResolvesEl.textContent = formatInteger(textaSelectionResolves); + + const glyphFonts = getHookMetric(stats.hooks, 'textLayoutCache', 'fontsInGlyphCache'); + const prefixFonts = getHookMetric(stats.hooks, 'textLayoutCache', 'fontsInPrefixCache'); + const measureFonts = getHookMetric(stats.hooks, 'textLayoutCache', 'fontsInMeasureCache'); + const glyphEntries = getHookMetric(stats.hooks, 'textLayoutCache', 'glyphEntries'); + const prefixEntries = getHookMetric(stats.hooks, 'textLayoutCache', 'prefixEntries'); + const measureEntries = getHookMetric(stats.hooks, 'textLayoutCache', 'measureEntries'); + + cacheFontsEl.textContent = `${formatInteger(glyphFonts)} / ${formatInteger(prefixFonts)} / ${formatInteger(measureFonts)}`; + cacheEntriesEl.textContent = `${formatInteger(glyphEntries)} / ${formatInteger(prefixEntries)} / ${formatInteger(measureEntries)}`; } function log(msg) { diff --git a/packages/demo/text-selection-debug-texta.css b/packages/demo/text-selection-debug-texta.css index d91c30a..1173774 100644 --- a/packages/demo/text-selection-debug-texta.css +++ b/packages/demo/text-selection-debug-texta.css @@ -109,6 +109,17 @@ h1 { color: #f8fafc; } +.cache-stats-note { + margin: 12px 0 0; + font-size: 12px; + line-height: 1.5; + color: #cbd5e1; +} + +.cache-stats-note strong { + color: #fef3c7; +} + .debug-info { margin-top: 24px; padding: 16px; diff --git a/packages/demo/text-selection-debug-texta.html b/packages/demo/text-selection-debug-texta.html index 8d9ce70..725c191 100644 --- a/packages/demo/text-selection-debug-texta.html +++ b/packages/demo/text-selection-debug-texta.html @@ -20,8 +20,52 @@

Vitrine Texta Selection Debug

-

Text Cache Counters

+

Performance and Cache Counters

+
+ FPS + 0 +
+
+ Average FPS + 0 +
+
+ Render Time (ms) + 0.00 +
+
+ Blocks Rendered + 0 +
+
+ Texta Cache Entries + 0 +
+
+ Texta Cache Hit Rate + 0.0% +
+
+ Texta Cache Hits + 0 +
+
+ Texta Cache Misses + 0 +
+
+ Texta Layout Builds + 0 +
+
+ Char-Bounds Builds + 0 +
+
+ Selection Geometry Resolves + 0 +
Fonts (G/P/M) 0 / 0 / 0 @@ -31,10 +75,15 @@

Text Cache Counters

0 / 0 / 0
+

+ On a stable drag-selection, FPS should stay high while Texta Layout Builds, + Char-Bounds Builds, and Selection Geometry Resolves flatten + after warmup; Texta Cache Hits should keep climbing. +

- Click inside texta blocks and review block hit, insertion index, and selection updates. + Click or drag inside texta blocks, then compare hit-testing logs with the counters above.
diff --git a/packages/gui/src/component.ts b/packages/gui/src/component.ts index f03cc6d..efa58b6 100644 --- a/packages/gui/src/component.ts +++ b/packages/gui/src/component.ts @@ -7,6 +7,7 @@ import type { Block } from 'vitrine'; import type { GUIControl, TransformContext, ThemeDefinition } from './GUI/types.ts'; import { ImmediateRenderer, group, Canvas2DContext, clearTextLayoutCaches } from 'vitrine'; import type { RendererConfig, RenderContext } from 'vitrine'; +import type { PerformanceStatsSnapshot } from 'vitrine'; import { transformGUIControl, rsControl } from './GUI/transform.ts'; import { lightTheme } from './GUI/themes.ts'; import { TextSelectionManager } from './selection/TextSelectionManager.ts'; @@ -235,6 +236,14 @@ export class VitrineComponent { return this.selectionManager; } + /** Returns the latest renderer performance snapshot, or null if not mounted. */ + getPerformanceStats(): PerformanceStatsSnapshot | null { + if (!this.renderer) { + return null; + } + return this.renderer.getPerformanceStats(); + } + /** * Signals that an animation has started. * In auto mode, this enables continuous RAF until endAnimation() balances it. diff --git a/packages/gui/src/selection/character-bounds-adapter.ts b/packages/gui/src/selection/character-bounds-adapter.ts index 36db9a9..d2aece9 100644 --- a/packages/gui/src/selection/character-bounds-adapter.ts +++ b/packages/gui/src/selection/character-bounds-adapter.ts @@ -127,11 +127,19 @@ function toCustomSelectionDescriptor( return null; } + const resolveCharacterBounds = geometry.resolveCharacterBounds + ?? (geometry.rgrclCharacterBounds + ? (): Rc[] => geometry.rgrclCharacterBounds ?? [] + : null); + if (!resolveCharacterBounds) { + return null; + } + return { blockId: geometry.blockId, transformWorld: xfCur, layoutSignature: geometry.layoutSignature, - resolveLocalBounds: () => geometry.rgrclCharacterBounds, + resolveLocalBounds: resolveCharacterBounds, worldSignature: buildWorldTransformSignature(xfCur) }; } diff --git a/packages/texta/src/browser.ts b/packages/texta/src/browser.ts index c80206c..fa3230f 100644 --- a/packages/texta/src/browser.ts +++ b/packages/texta/src/browser.ts @@ -82,5 +82,9 @@ export { registerTextaBlockType, texta, stBlockTypeTexta, + clearTextaLayoutCache, + getTextaLayoutCacheStats, + resetTextaLayoutCacheStats, + type TextaLayoutCacheStats, type TextaBlockProps } from "./vitrine.ts"; diff --git a/packages/texta/src/index.ts b/packages/texta/src/index.ts index ce43ac4..54c83eb 100644 --- a/packages/texta/src/index.ts +++ b/packages/texta/src/index.ts @@ -84,6 +84,10 @@ export { registerTextaBlockType, texta, stBlockTypeTexta, + clearTextaLayoutCache, + getTextaLayoutCacheStats, + resetTextaLayoutCacheStats, + type TextaLayoutCacheStats, type TextaBlockProps } from "./vitrine.ts"; diff --git a/packages/texta/src/vitrine.ts b/packages/texta/src/vitrine.ts index 229c31f..9f69403 100644 --- a/packages/texta/src/vitrine.ts +++ b/packages/texta/src/vitrine.ts @@ -2,9 +2,8 @@ import type { AttributedTextValue } from './types.ts'; import { getRgRenderBridgeBoundaryUtf16, getRgRenderBridgeRun } from './render-bridges.ts'; import { customBlock, + PerformanceMonitor, registerBlockType, - measureText, - calculateTextOffset, SF_TEXT_ADVANCE_APPROX_DEFAULT } from 'vitrine'; import type { @@ -69,6 +68,38 @@ type TextaLayout = { styleDefault: StyleEntryLike; }; +type TextaLayoutOptions = { + fIncludeCharacterBounds?: boolean; +}; + +interface TextaLayoutCacheEntry { + layout: TextaLayout; + fIncludesCharacterBounds: boolean; +} + +export interface TextaLayoutCacheStats { + [stMetric: string]: number; + cacheEntries: number; + layoutRequests: number; + cacheHits: number; + cacheMisses: number; + layoutBuilds: number; + characterBoundsBuilds: number; + selectionGeometryResolveCalls: number; + hitRatePercent: number; +} + +const mpstLayoutCache = new Map(); +const C_LAYOUT_CACHE_MAX = 256; +const textaLayoutStats = { + layoutRequests: 0, + cacheHits: 0, + cacheMisses: 0, + layoutBuilds: 0, + characterBoundsBuilds: 0, + selectionGeometryResolveCalls: 0 +}; + export interface TextaBlockProps extends BaseBlockProps { texta: AttributedTextValue; align?: 'left' | 'center' | 'right' | 'start' | 'end'; @@ -158,10 +189,42 @@ function getUnitText(value: AttributedTextValue, rgBoundaryUtf16: number[], iUni return value.strText.slice(iUtf16Start, iUtf16End); } +function pruneOldestMapEntry(map: Map): void { + const firstKey = map.keys().next().value; + if (firstKey !== undefined) { + map.delete(firstKey); + } +} + +function setWithLruTouch(map: Map, key: K, value: V, maxEntries: number): void { + if (map.has(key)) { + map.delete(key); + } + map.set(key, value); + while (map.size > maxEntries) { + pruneOldestMapEntry(map); + } +} + +function touchMapKey(map: Map, key: K): void { + const value = map.get(key); + if (value === undefined) { + return; + } + map.delete(key); + map.set(key, value); +} + function buildTextaLayoutSignature(props: TextaBlockProps): string { return [ props.texta.iVersion, + props.texta.rgUnits, props.texta.rgStorageMode, + props.texta.strText, + props.texta.idStyleDefault, + JSON.stringify(props.texta.rgSegGraphemeToUtf16), + JSON.stringify(props.texta.rgIdStyleRef), + JSON.stringify(props.texta.mpId_StyleEntry), props.align, props.baseline, props.dx, @@ -171,6 +234,19 @@ function buildTextaLayoutSignature(props: TextaBlockProps): string { ].join('|'); } +function getTextaMeasureSignature( + context?: { measureText?: (text: string, props: { font?: string; fontSize?: number }) => TextMeasure } +): string { + return context?.measureText ? 'measured' : 'approx'; +} + +function getTextaLayoutCacheKey( + props: TextaBlockProps, + context?: { measureText?: (text: string, props: { font?: string; fontSize?: number }) => TextMeasure } +): string { + return `${buildTextaLayoutSignature(props)}|${getTextaMeasureSignature(context)}`; +} + function splitRunLines(props: TextaBlockProps): Segment[][] { const runs = getRgRenderBridgeRun(props.texta); const rgBoundaryUtf16 = getRgRenderBridgeBoundaryUtf16(props.texta); @@ -326,14 +402,24 @@ function computeLineMetrics( return { lineMetrics, lineWidths, lineHeights, lineAscents, styleDefault }; } -function buildTextaLayout(props: TextaBlockProps, context?: { measureText?: (text: string, props: { font?: string; fontSize?: number }) => TextMeasure }): TextaLayout { +function buildTextaLayoutUncached( + props: TextaBlockProps, + context?: { measureText?: (text: string, props: { font?: string; fontSize?: number }) => TextMeasure }, + options?: TextaLayoutOptions +): TextaLayout { + textaLayoutStats.layoutBuilds += 1; + if (options?.fIncludeCharacterBounds === true) { + textaLayoutStats.characterBoundsBuilds += 1; + } const contextMeasure = createMeasureTextFn(props, context); const { lineMetrics, lineWidths, lineHeights, lineAscents, styleDefault } = computeLineMetrics( props, contextMeasure ); - const iUnitCount = props.texta.rgIdStyleRef.length; - const rgrclCharacterBounds: Array = new Array(iUnitCount).fill(null); + const fIncludeCharacterBounds = options?.fIncludeCharacterBounds ?? false; + const rgrclCharacterBounds: Array = fIncludeCharacterBounds + ? new Array(props.texta.rgIdStyleRef.length).fill(null) + : []; if (lineMetrics.length === 0) { return { @@ -413,26 +499,28 @@ function buildTextaLayout(props: TextaBlockProps, context?: { measureText?: (tex x: xRun }); - let widthTotalUnits = 0; - const rgdxUnit: number[] = []; - for (let iUnit = segment.iStart; iUnit < segment.iEnd; iUnit++) { - const textUnit = getUnitText(props.texta, rgBoundaryUtf16, iUnit); - const metrics = contextMeasure(textUnit, segment.font ? { font: segment.font } : { fontSize: segment.fontSize }); - rgdxUnit.push(metrics.width); - widthTotalUnits += metrics.width; - } + if (fIncludeCharacterBounds) { + let widthTotalUnits = 0; + const rgdxUnit: number[] = []; + for (let iUnit = segment.iStart; iUnit < segment.iEnd; iUnit++) { + const textUnit = getUnitText(props.texta, rgBoundaryUtf16, iUnit); + const metrics = contextMeasure(textUnit, segment.font ? { font: segment.font } : { fontSize: segment.fontSize }); + rgdxUnit.push(metrics.width); + widthTotalUnits += metrics.width; + } - const scale = widthTotalUnits > 0 ? segment.width / widthTotalUnits : 1; - let widthBefore = 0; - for (let iUnitOffset = 0; iUnitOffset < rgdxUnit.length; iUnitOffset++) { - const widthAfter = widthBefore + rgdxUnit[iUnitOffset]!; - rgrclCharacterBounds[segment.iStart + iUnitOffset] = { - x: xRun + widthBefore * scale, - y: yLineTop, - width: Math.max(0, (widthAfter - widthBefore) * scale), - height: lineHeight - }; - widthBefore = widthAfter; + const scale = widthTotalUnits > 0 ? segment.width / widthTotalUnits : 1; + let widthBefore = 0; + for (let iUnitOffset = 0; iUnitOffset < rgdxUnit.length; iUnitOffset++) { + const widthAfter = widthBefore + rgdxUnit[iUnitOffset]!; + rgrclCharacterBounds[segment.iStart + iUnitOffset] = { + x: xRun + widthBefore * scale, + y: yLineTop, + width: Math.max(0, (widthAfter - widthBefore) * scale), + height: lineHeight + }; + widthBefore = widthAfter; + } } xRun += segment.width; @@ -450,23 +538,25 @@ function buildTextaLayout(props: TextaBlockProps, context?: { measureText?: (tex yLineBaseline += lineHeight; } - let xFallback = bounds.x + bounds.width; - let yFallback = bounds.y; - let heightFallback = lineHeights[0] ?? 0; - for (let i = 0; i < rgrclCharacterBounds.length; i++) { - const rc = rgrclCharacterBounds[i]; - if (rc) { - xFallback = rc.x + rc.width; - yFallback = rc.y; - heightFallback = rc.height; - continue; + if (fIncludeCharacterBounds) { + let xFallback = bounds.x + bounds.width; + let yFallback = bounds.y; + let heightFallback = lineHeights[0] ?? 0; + for (let i = 0; i < rgrclCharacterBounds.length; i++) { + const rc = rgrclCharacterBounds[i]; + if (rc) { + xFallback = rc.x + rc.width; + yFallback = rc.y; + heightFallback = rc.height; + continue; + } + rgrclCharacterBounds[i] = { + x: xFallback, + y: yFallback, + width: 0, + height: heightFallback + }; } - rgrclCharacterBounds[i] = { - x: xFallback, - y: yFallback, - width: 0, - height: heightFallback - }; } return { @@ -477,6 +567,59 @@ function buildTextaLayout(props: TextaBlockProps, context?: { measureText?: (tex }; } +function buildTextaLayout( + props: TextaBlockProps, + context?: { measureText?: (text: string, props: { font?: string; fontSize?: number }) => TextMeasure }, + options?: TextaLayoutOptions +): TextaLayout { + textaLayoutStats.layoutRequests += 1; + const fIncludeCharacterBounds = options?.fIncludeCharacterBounds ?? false; + const stCacheKey = getTextaLayoutCacheKey(props, context); + const cached = mpstLayoutCache.get(stCacheKey); + if (cached && (cached.fIncludesCharacterBounds || !fIncludeCharacterBounds)) { + textaLayoutStats.cacheHits += 1; + touchMapKey(mpstLayoutCache, stCacheKey); + return cached.layout; + } + + textaLayoutStats.cacheMisses += 1; + const layout = buildTextaLayoutUncached(props, context, options); + setWithLruTouch(mpstLayoutCache, stCacheKey, { + layout, + fIncludesCharacterBounds: fIncludeCharacterBounds + }, C_LAYOUT_CACHE_MAX); + return layout; +} + +export function clearTextaLayoutCache(): void { + mpstLayoutCache.clear(); +} + +export function resetTextaLayoutCacheStats(): void { + textaLayoutStats.layoutRequests = 0; + textaLayoutStats.cacheHits = 0; + textaLayoutStats.cacheMisses = 0; + textaLayoutStats.layoutBuilds = 0; + textaLayoutStats.characterBoundsBuilds = 0; + textaLayoutStats.selectionGeometryResolveCalls = 0; +} + +export function getTextaLayoutCacheStats(): TextaLayoutCacheStats { + const hitRatePercent = textaLayoutStats.layoutRequests > 0 + ? (textaLayoutStats.cacheHits / textaLayoutStats.layoutRequests) * 100 + : 0; + return { + cacheEntries: mpstLayoutCache.size, + layoutRequests: textaLayoutStats.layoutRequests, + cacheHits: textaLayoutStats.cacheHits, + cacheMisses: textaLayoutStats.cacheMisses, + layoutBuilds: textaLayoutStats.layoutBuilds, + characterBoundsBuilds: textaLayoutStats.characterBoundsBuilds, + selectionGeometryResolveCalls: textaLayoutStats.selectionGeometryResolveCalls, + hitRatePercent + }; +} + function createTextaHandlers(): CustomBlockHandlers { return { render: (block, api): void => { @@ -539,29 +682,34 @@ function createTextaHandlers(): CustomBlockHandlers { && yl <= cachedBounds.y + cachedBounds.height; } const bounds = buildTextaLayout(block.props as unknown as TextaBlockProps).bounds; + layoutCache?.mpbl_rc.set(block as unknown as Block, bounds); return xl >= bounds.x && xl <= bounds.x + bounds.width && yl >= bounds.y && yl <= bounds.y + bounds.height; }, rcl: (block): Rc => buildTextaLayout(block.props as unknown as TextaBlockProps).bounds, - getDebugOutlineBounds: (block): Rc => buildTextaLayout(block.props as unknown as TextaBlockProps).bounds, + getDebugOutlineBounds: (block, api): Rc => buildTextaLayout(block.props as unknown as TextaBlockProps, api.context).bounds, getSelectionGeometry: (block, api) => { const props = block.props as unknown as TextaBlockProps; if (typeof props.id !== 'string' || props.id.length === 0) { return null; } - const layout = buildTextaLayout(props, api.context); return { blockId: props.id, layoutSignature: buildTextaLayoutSignature(props), - rgrclCharacterBounds: layout.rgrclCharacterBounds + resolveCharacterBounds: (): Rc[] => { + textaLayoutStats.selectionGeometryResolveCalls += 1; + return buildTextaLayout(props, api.context, { fIncludeCharacterBounds: true }).rgrclCharacterBounds; + } }; } }; } +PerformanceMonitor.registerStatsHook('textaLayoutCache', () => getTextaLayoutCacheStats()); + let fRegistered = false; export function registerTextaBlockType(): void { diff --git a/packages/texta/test/vitrine-selection.test.ts b/packages/texta/test/vitrine-selection.test.ts index b6f59dd..24132bb 100644 --- a/packages/texta/test/vitrine-selection.test.ts +++ b/packages/texta/test/vitrine-selection.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; -import { group, text } from "vitrine"; +import { + Matrix2D, + TransformStack, + customBlock, + group, + registerBlockType, + text, + unregisterBlockType +} from "vitrine"; +import type { CustomBlockHandlers, Rc, RenderContext, TextMeasure } from "vitrine"; import { createCharacterBoundsProviderFromBlockTree } from "../../gui/src/index.ts"; +import { createCharacterBoundsAdapter } from "../../gui/src/selection/character-bounds-adapter.ts"; import { detectRgStorageMode, getRgCodePointBoundaryUtf16, getRgGraphemeBoundaryUtf16 } from "../src/index.ts"; import { registerTextaBlockType, texta } from "../src/vitrine.ts"; import type { AttributedTextValue, RgStorageMode, StyleEntry } from "../src/types.ts"; @@ -37,6 +47,50 @@ function createAttributedText(strText: string, styleDefault: StyleEntry): Attrib }; } +function createCountingRenderContext(): { context: RenderContext; getMeasureCount: () => number } { + let cMeasure = 0; + const context: RenderContext = { + transformStack: new TransformStack(), + opacity: 1, + fVisible: true, + save(): void {}, + restore(): void {}, + applyTransform(_xf: Matrix2D): void {}, + setOpacity(opacity: number): void { + this.opacity = opacity; + }, + setShadow(_shadow): void {}, + setFilter(_filter): void {}, + setBlendMode(_blendMode): void {}, + clipRect(_xl: number, _yl: number, _dxl: number, _dyl: number): void {}, + clear(): void {}, + drawRectangle(_xl: number, _yl: number, _dxl: number, _dyl: number): void {}, + drawCircle(_xl: number, _yl: number, _rl: number): void {}, + drawEllipse(_xl: number, _yl: number, _rxl: number, _ryl: number): void {}, + drawPath(_pathData: string): void {}, + drawLine(_xl1: number, _yl1: number, _xl2: number, _yl2: number): void {}, + drawText(_text: string, _xl: number, _yl: number): void {}, + drawImage(_image: HTMLImageElement, _xl: number, _yl: number, _dxl: number, _dyl: number): void {}, + drawArc(_xl: number, _yl: number, _rl: number, _startAngle: number, _endAngle: number): void {}, + measureText(text: string, props: { font?: string; fontSize?: number }): TextMeasure { + cMeasure += 1; + const fontSize = props.fontSize ?? 16; + const width = Array.from(text).length * fontSize * 0.6; + return { + width, + height: fontSize, + ascent: fontSize * 0.8, + descent: fontSize * 0.2 + }; + } + }; + + return { + context, + getMeasureCount: (): number => cMeasure + }; +} + describe("texta selection geometry", () => { it("keeps regular text selection working while adding texta support", () => { registerTextaBlockType(); @@ -89,4 +143,149 @@ describe("texta selection geometry", () => { expect(rcRich2!.x).toBeGreaterThanOrEqual(rcRich1!.x); expect(rcRich3!.y).toBeGreaterThan(rcRich0!.y); }); + + it("keeps custom selection geometry lazy across unchanged frames", () => { + const stBlockType = "test-selection-lazy"; + let cResolve = 0; + const handlers: CustomBlockHandlers = { + getSelectionGeometry: (block) => { + const { + id, + stLayoutSignature + } = block.props as { id: string; stLayoutSignature: string }; + return { + blockId: id, + layoutSignature: stLayoutSignature, + resolveCharacterBounds: (): Rc[] => { + cResolve += 1; + return [{ + x: 0, + y: 0, + width: 12, + height: 18 + }]; + } + }; + } + }; + + registerBlockType(stBlockType, handlers); + + try { + const adapter = createCharacterBoundsAdapter(); + const provider = adapter.getProvider(); + + adapter.updateFromBlockTree(group({}, [ + customBlock(stBlockType, { + id: "lazy", + stLayoutSignature: "sig-a" + }) + ])); + expect(cResolve).toBe(0); + + expect(provider("lazy", 0)).toEqual({ + x: 0, + y: 0, + width: 12, + height: 18 + }); + expect(cResolve).toBe(1); + + adapter.updateFromBlockTree(group({}, [ + customBlock(stBlockType, { + id: "lazy", + stLayoutSignature: "sig-a" + }) + ])); + expect(provider("lazy", 0)).toEqual({ + x: 0, + y: 0, + width: 12, + height: 18 + }); + expect(cResolve).toBe(1); + + adapter.updateFromBlockTree(group({}, [ + customBlock(stBlockType, { + id: "lazy", + stLayoutSignature: "sig-a", + x: 15 + }) + ])); + expect(provider("lazy", 0)).toEqual({ + x: 15, + y: 0, + width: 12, + height: 18 + }); + expect(cResolve).toBe(1); + + adapter.updateFromBlockTree(group({}, [ + customBlock(stBlockType, { + id: "lazy", + stLayoutSignature: "sig-b" + }) + ])); + expect(provider("lazy", 0)).toEqual({ + x: 0, + y: 0, + width: 12, + height: 18 + }); + expect(cResolve).toBe(2); + } finally { + unregisterBlockType(stBlockType); + } + }); + + it("reuses texta layout across unchanged frames and transform-only updates", () => { + registerTextaBlockType(); + + const { context, getMeasureCount } = createCountingRenderContext(); + const adapter = createCharacterBoundsAdapter({ context }); + const provider = adapter.getProvider(); + const styleDefault: StyleEntry = { + fontFamily: "ui-sans-serif", + fontSize: 18, + lineHeight: 24, + fill: "#111827" + }; + const textValue = createAttributedText("Reuse 😀 cache", styleDefault); + const createRoot = (props: { x?: number; dx?: number; texta?: AttributedTextValue } = {}) => ( + group({}, [ + texta({ + id: "rich-cached", + x: props.x ?? 0, + dx: props.dx ?? 90, + baseline: "top", + texta: props.texta ?? textValue + }) + ]) + ); + + adapter.updateFromBlockTree(createRoot()); + expect(getMeasureCount()).toBe(0); + + const rcInitial = provider("rich-cached", 0); + expect(rcInitial).not.toBeNull(); + const cMeasureAfterFirstRead = getMeasureCount(); + expect(cMeasureAfterFirstRead).toBeGreaterThan(0); + + adapter.updateFromBlockTree(createRoot()); + const rcStable = provider("rich-cached", 0); + expect(rcStable).toEqual(rcInitial); + expect(getMeasureCount()).toBe(cMeasureAfterFirstRead); + + adapter.updateFromBlockTree(createRoot({ x: 30 })); + const rcMoved = provider("rich-cached", 0); + expect(rcMoved).not.toBeNull(); + expect(rcMoved!.x).toBe((rcInitial?.x ?? 0) + 30); + expect(getMeasureCount()).toBe(cMeasureAfterFirstRead); + + const textChanged = createAttributedText("Reuse 😀 cache again", styleDefault); + adapter.updateFromBlockTree(createRoot({ texta: textChanged })); + const rcChanged = provider("rich-cached", 0); + expect(rcChanged).not.toBeNull(); + expect(getMeasureCount()).toBeGreaterThan(cMeasureAfterFirstRead); + }); }); \ No newline at end of file From 2c657adac27019843b913dc72a8ad5628494ea14 Mon Sep 17 00:00:00 2001 From: frouaix Date: Sat, 18 Jul 2026 19:25:05 -0700 Subject: [PATCH 6/8] Tighten texta fallback bounds Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/texta/src/vitrine.ts | 141 +++++++++++++++++++++++++++------- 1 file changed, 112 insertions(+), 29 deletions(-) diff --git a/packages/texta/src/vitrine.ts b/packages/texta/src/vitrine.ts index 9f69403..1af2e1b 100644 --- a/packages/texta/src/vitrine.ts +++ b/packages/texta/src/vitrine.ts @@ -1,9 +1,11 @@ import type { AttributedTextValue } from './types.ts'; import { getRgRenderBridgeBoundaryUtf16, getRgRenderBridgeRun } from './render-bridges.ts'; import { + calculateTextOffset, customBlock, PerformanceMonitor, registerBlockType, + measureText, SF_TEXT_ADVANCE_APPROX_DEFAULT } from 'vitrine'; import type { @@ -402,36 +404,23 @@ function computeLineMetrics( return { lineMetrics, lineWidths, lineHeights, lineAscents, styleDefault }; } -function buildTextaLayoutUncached( +function calculateTextaBounds( props: TextaBlockProps, - context?: { measureText?: (text: string, props: { font?: string; fontSize?: number }) => TextMeasure }, - options?: TextaLayoutOptions -): TextaLayout { - textaLayoutStats.layoutBuilds += 1; - if (options?.fIncludeCharacterBounds === true) { - textaLayoutStats.characterBoundsBuilds += 1; - } - const contextMeasure = createMeasureTextFn(props, context); - const { lineMetrics, lineWidths, lineHeights, lineAscents, styleDefault } = computeLineMetrics( - props, - contextMeasure - ); - const fIncludeCharacterBounds = options?.fIncludeCharacterBounds ?? false; - const rgrclCharacterBounds: Array = fIncludeCharacterBounds - ? new Array(props.texta.rgIdStyleRef.length).fill(null) - : []; - - if (lineMetrics.length === 0) { + lineWidths: number[], + lineHeights: number[], + lineAscents: number[], + styleDefault: StyleEntryLike +): { bounds: Rc; yBaseline: number; firstAscent: number } { + if (lineWidths.length === 0 || lineHeights.length === 0) { return { - lines: [], bounds: { x: 0, y: 0, width: 0, height: 0 }, - rgrclCharacterBounds: [], - styleDefault + yBaseline: 0, + firstAscent: props.fontSize ?? styleDefault.fontSize ?? 16 }; } @@ -473,13 +462,107 @@ function buildTextaLayoutUncached( } } - const bounds: Rc = { - x: xMin, - y: yTop, - width: Math.max(0, xMax - xMin), - height: Math.max(0, totalHeight) + return { + bounds: { + x: xMin, + y: yTop, + width: Math.max(0, xMax - xMin), + height: Math.max(0, totalHeight) + }, + yBaseline, + firstAscent + }; +} + +function estimateTextaBounds(props: TextaBlockProps): Rc { + const lineHeight = props.lineHeight ?? props.fontSize ?? 16; + if (props.texta.strText.length === 0) { + return { + x: 0, + y: 0, + width: 0, + height: 0 + }; + } + + const baseMetrics = measureText(props.texta.strText, { font: props.font, fontSize: props.fontSize }); + const lines = props.texta.strText.split('\n'); + const lineWidths = lines.map((line) => measureText(line, { font: props.font, fontSize: props.fontSize }).width); + const widthMax = lineWidths.length > 0 + ? Math.max(...lineWidths) + : baseMetrics.width; + const explicitLineCount = Math.max(1, lines.length); + const wrappedLineCount = props.dx !== undefined + ? lineWidths.reduce((count, width) => count + Math.max(1, Math.ceil(Math.max(1, width) / Math.max(1, props.dx!))), 0) + : explicitLineCount; + const width = props.dx !== undefined + ? Math.min(widthMax, props.dx) + : widthMax; + const height = Math.max(lineHeight, wrappedLineCount * lineHeight); + const { xOffset, yOffset } = calculateTextOffset( + width, + height, + baseMetrics.ascent, + props.align, + props.baseline + ); + return { + x: xOffset, + y: yOffset, + width, + height + }; +} + +function buildTextaLayoutUncached( + props: TextaBlockProps, + context?: { measureText?: (text: string, props: { font?: string; fontSize?: number }) => TextMeasure }, + options?: TextaLayoutOptions +): TextaLayout { + textaLayoutStats.layoutBuilds += 1; + if (options?.fIncludeCharacterBounds === true) { + textaLayoutStats.characterBoundsBuilds += 1; + } + const contextMeasure = createMeasureTextFn(props, context); + const { lineMetrics, lineWidths, lineHeights, lineAscents, styleDefault } = computeLineMetrics( + props, + contextMeasure + ); + const fIncludeCharacterBounds = options?.fIncludeCharacterBounds ?? false; + const rgrclCharacterBounds: Array = fIncludeCharacterBounds + ? new Array(props.texta.rgIdStyleRef.length).fill(null) + : []; + + if (lineMetrics.length === 0) { + return { + lines: [], + bounds: { + x: 0, + y: 0, + width: 0, + height: 0 + }, + rgrclCharacterBounds: [], + styleDefault + }; + } + + const getLineStartX = (lineWidth: number): number => { + if (props.align === 'center') { + return -lineWidth / 2; + } + if (props.align === 'right' || props.align === 'end') { + return -lineWidth; + } + return 0; }; + const { + bounds, + yBaseline, + firstAscent + } = calculateTextaBounds(props, lineWidths, lineHeights, lineAscents, styleDefault); + const rgBoundaryUtf16 = getRgRenderBridgeBoundaryUtf16(props.texta); const lines: TextaLayoutLine[] = []; let yLineBaseline = yBaseline; @@ -681,14 +764,14 @@ function createTextaHandlers(): CustomBlockHandlers { && yl >= cachedBounds.y && yl <= cachedBounds.y + cachedBounds.height; } - const bounds = buildTextaLayout(block.props as unknown as TextaBlockProps).bounds; + const bounds = estimateTextaBounds(block.props as unknown as TextaBlockProps); layoutCache?.mpbl_rc.set(block as unknown as Block, bounds); return xl >= bounds.x && xl <= bounds.x + bounds.width && yl >= bounds.y && yl <= bounds.y + bounds.height; }, - rcl: (block): Rc => buildTextaLayout(block.props as unknown as TextaBlockProps).bounds, + rcl: (block): Rc => estimateTextaBounds(block.props as unknown as TextaBlockProps), getDebugOutlineBounds: (block, api): Rc => buildTextaLayout(block.props as unknown as TextaBlockProps, api.context).bounds, getSelectionGeometry: (block, api) => { const props = block.props as unknown as TextaBlockProps; From 290e11748f0f1f17365fa9433a1cc6bfebece3c0 Mon Sep 17 00:00:00 2001 From: frouaix Date: Sat, 18 Jul 2026 19:45:52 -0700 Subject: [PATCH 7/8] Optimize texta layout hot path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/texta/src/vitrine.ts | 20 +++++++++---------- packages/texta/test/vitrine-selection.test.ts | 5 ++++- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/texta/src/vitrine.ts b/packages/texta/src/vitrine.ts index 1af2e1b..b9ef7bc 100644 --- a/packages/texta/src/vitrine.ts +++ b/packages/texta/src/vitrine.ts @@ -222,11 +222,6 @@ function buildTextaLayoutSignature(props: TextaBlockProps): string { props.texta.iVersion, props.texta.rgUnits, props.texta.rgStorageMode, - props.texta.strText, - props.texta.idStyleDefault, - JSON.stringify(props.texta.rgSegGraphemeToUtf16), - JSON.stringify(props.texta.rgIdStyleRef), - JSON.stringify(props.texta.mpId_StyleEntry), props.align, props.baseline, props.dx, @@ -249,10 +244,12 @@ function getTextaLayoutCacheKey( return `${buildTextaLayoutSignature(props)}|${getTextaMeasureSignature(context)}`; } -function splitRunLines(props: TextaBlockProps): Segment[][] { +function splitRunLines( + props: TextaBlockProps, + rgBoundaryUtf16: number[], + styleDefault: StyleEntryLike +): Segment[][] { const runs = getRgRenderBridgeRun(props.texta); - const rgBoundaryUtf16 = getRgRenderBridgeBoundaryUtf16(props.texta); - const styleDefault = getDefaultStyle(props); const mpStyleById = props.texta.mpId_StyleEntry as Record; const lineSegments: Segment[][] = [[]]; @@ -347,11 +344,11 @@ function splitSegmentWrapAtoms(segment: Segment, value: AttributedTextValue, rgB function computeLineMetrics( props: TextaBlockProps, + rgBoundaryUtf16: number[], contextMeasure: (text: string, props: { font?: string; fontSize?: number }) => TextMeasure ): { lineMetrics: SegmentMetrics[][]; lineWidths: number[]; lineHeights: number[]; lineAscents: number[]; styleDefault: StyleEntryLike } { const styleDefault = getDefaultStyle(props); - const lineSegments = splitRunLines(props); - const rgBoundaryUtf16 = getRgRenderBridgeBoundaryUtf16(props.texta); + const lineSegments = splitRunLines(props, rgBoundaryUtf16, styleDefault); let lineMetrics: SegmentMetrics[][]; if (props.dx !== undefined) { @@ -524,8 +521,10 @@ function buildTextaLayoutUncached( textaLayoutStats.characterBoundsBuilds += 1; } const contextMeasure = createMeasureTextFn(props, context); + const rgBoundaryUtf16 = getRgRenderBridgeBoundaryUtf16(props.texta); const { lineMetrics, lineWidths, lineHeights, lineAscents, styleDefault } = computeLineMetrics( props, + rgBoundaryUtf16, contextMeasure ); const fIncludeCharacterBounds = options?.fIncludeCharacterBounds ?? false; @@ -563,7 +562,6 @@ function buildTextaLayoutUncached( firstAscent } = calculateTextaBounds(props, lineWidths, lineHeights, lineAscents, styleDefault); - const rgBoundaryUtf16 = getRgRenderBridgeBoundaryUtf16(props.texta); const lines: TextaLayoutLine[] = []; let yLineBaseline = yBaseline; diff --git a/packages/texta/test/vitrine-selection.test.ts b/packages/texta/test/vitrine-selection.test.ts index 24132bb..ba58823 100644 --- a/packages/texta/test/vitrine-selection.test.ts +++ b/packages/texta/test/vitrine-selection.test.ts @@ -282,7 +282,10 @@ describe("texta selection geometry", () => { expect(rcMoved!.x).toBe((rcInitial?.x ?? 0) + 30); expect(getMeasureCount()).toBe(cMeasureAfterFirstRead); - const textChanged = createAttributedText("Reuse 😀 cache again", styleDefault); + const textChanged = { + ...createAttributedText("Reuse 😀 cache again", styleDefault), + iVersion: textValue.iVersion + 1 + }; adapter.updateFromBlockTree(createRoot({ texta: textChanged })); const rcChanged = provider("rich-cached", 0); expect(rcChanged).not.toBeNull(); From 9b872a3a8a2006966597fdaefca378c7d8f3109c Mon Sep 17 00:00:00 2001 From: frouaix Date: Sat, 18 Jul 2026 19:53:14 -0700 Subject: [PATCH 8/8] Fix final review issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/demo/performance.html | 3 ++- packages/texta/src/vitrine.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/demo/performance.html b/packages/demo/performance.html index f1935d1..21a8d50 100644 --- a/packages/demo/performance.html +++ b/packages/demo/performance.html @@ -305,10 +305,11 @@

Vitrine Performance Test

}, blocks) , - // Text workload to exercise text measurement caches + // Keep the text workload invisible so the cache exercise does not clutter the scene. group({ x: 470 + Math.cos(config.time * 0.35) * 120, y: 320 + Math.sin(config.time * 0.45) * 70, + opacity: 0, }, textBlocks) ]); diff --git a/packages/texta/src/vitrine.ts b/packages/texta/src/vitrine.ts index b9ef7bc..4d96c28 100644 --- a/packages/texta/src/vitrine.ts +++ b/packages/texta/src/vitrine.ts @@ -585,7 +585,10 @@ function buildTextaLayoutUncached( const rgdxUnit: number[] = []; for (let iUnit = segment.iStart; iUnit < segment.iEnd; iUnit++) { const textUnit = getUnitText(props.texta, rgBoundaryUtf16, iUnit); - const metrics = contextMeasure(textUnit, segment.font ? { font: segment.font } : { fontSize: segment.fontSize }); + const metrics = contextMeasure(textUnit, { + font: segment.font, + fontSize: segment.fontSize + }); rgdxUnit.push(metrics.width); widthTotalUnits += metrics.width; }