diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 983cf71..be10722 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Build vitrine (texta dependency) + run: pnpm --filter vitrine build + - name: Run texta tests run: pnpm --filter texta test diff --git a/docs/COORDINATES.md b/docs/COORDINATES.md index 60277a7..788d356 100644 --- a/docs/COORDINATES.md +++ b/docs/COORDINATES.md @@ -53,6 +53,8 @@ immediately visible: | `dx`, `dy` | A size / dimension (delta) | `dxRect`, `dyClip` | | `du` | A generic distance (dimensionless units) | `duRadius` | | `pt` | A point object `{ xc, yc }` | `ptcDragStart` | +| `xf` | A `Matrix2D` transform | `xfBlock`, `xfCur`, `xfWorld` | +| `bl` | A `Block` | `blHit`, `blChild` | Coordinate-space suffixes are appended to the prefix: diff --git a/docs/TEXT_SELECTION_REGULAR_TEXT.md b/docs/TEXT_SELECTION_REGULAR_TEXT.md new file mode 100644 index 0000000..bc5f346 --- /dev/null +++ b/docs/TEXT_SELECTION_REGULAR_TEXT.md @@ -0,0 +1,83 @@ +# Text Selection for Regular `text()` Blocks + +This guide explains how to enable text selection when using regular Vitrine `text()` blocks (not `texta()`). + +## 1. Enable selection on the component + +Create your component with `selectionConfig.enabled: true`: + +```ts +import { VitrineComponent } from 'vitrine-gui'; + +const component = VitrineComponent.block(buildScene, { + width: 800, + height: 400, + renderMode: 'auto', + selectionConfig: { + enabled: true, + caretColor: '#dc2626', + selectionColor: 'rgba(59, 130, 246, 0.2)', + caretWidth: 2 + } +}); +``` + +## 2. Give selectable `text()` blocks stable `id` values + +Selection hit-testing and overlays are keyed by block `id`. + +```ts +import { group, text } from 'vitrine'; + +function buildScene() { + return group({}, [ + text({ + id: 'paragraph-1', + x: 40, + y: 60, + text: 'Click and drag to select this text.', + fontSize: 18, + baseline: 'top' + }) + ]); +} +``` + +If a `text()` block has no `id`, it is not selectable. + +## 3. Pointer + keyboard behavior + +- Pointer down/move/up drives caret placement and drag selection. +- Keyboard navigation (`ArrowLeft/Right/Up/Down`, `Shift+Arrow`, `Home`, `End`, `Ctrl/Cmd+A`) is handled by `TextSelectionManager`. +- The canvas is focusable; clicking the canvas gives it focus for keyboard navigation. + +## 4. Coordinate space contract + +Character bounds are handled in **scene/CSS coordinates** (the same coordinate system as your block positions), not raw canvas buffer pixels. + +## 5. Automatic bounds provider (default path) + +For regular `text()` blocks, `VitrineComponent` automatically builds and updates a character bounds provider from the block tree each frame. + +It uses renderer-compatible layout rules (font, wrapping, alignment, baseline, transforms), so typical usage does not require manual bounds wiring. + +## 6. Custom bounds provider (optional) + +You can override the automatic provider when needed: + +```ts +const selectionManager = component.getSelectionManager(); + +selectionManager?.setCharacterBoundsProvider((blockId, charIndex) => { + // Return { x, y, width, height } in scene/CSS coordinates, or null. + return null; +}); +``` + +Use this only for custom rendering paths where automatic `text()` extraction is insufficient. + +## Current scope + +- Automatic adapter support is for regular `text()` blocks with `id`. +- Cross-block selections are not supported. +- `texta()` requires custom handling if you need selection behavior equivalent to `text()`. diff --git a/packages/core/package.json b/packages/core/package.json index bc42e7b..1ea6ae8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -15,9 +15,7 @@ "files": [ "dist" ], - "dependencies": { - "texta": "workspace:*" - }, + "dependencies": {}, "scripts": { "build": "tsc", "lint": "tsc --noEmit", diff --git a/packages/core/src/core/block-registry.ts b/packages/core/src/core/block-registry.ts new file mode 100644 index 0000000..294561a --- /dev/null +++ b/packages/core/src/core/block-registry.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 François Rouaix + +import type { RenderContext } from './context.ts'; +import type { Block, Rc } from './types.ts'; + +export interface CustomBlockDescriptor { + type: string; + props: Record; + rgblChildren?: Block[]; +} + +export interface BlockLayoutCache { + mpbl_rc: WeakMap; +} + +export interface CustomBlockRenderApi { + context: RenderContext; + layoutCache: BlockLayoutCache; + setLayoutBounds: (bounds: Rc) => void; +} + +export interface CustomBlockHitTestApi { + layoutCache?: BlockLayoutCache; +} + +export interface CustomBlockDebugApi { + context: RenderContext; +} + +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; +} + +const mpCustomBlockHandlers = new Map(); + +export function registerBlockType(type: string, handlers: CustomBlockHandlers): void { + mpCustomBlockHandlers.set(type, handlers); +} + +export function unregisterBlockType(type: string): void { + mpCustomBlockHandlers.delete(type); +} + +export function getBlockTypeHandlers(type: string): CustomBlockHandlers | undefined { + return mpCustomBlockHandlers.get(type); +} diff --git a/packages/core/src/core/blocks.ts b/packages/core/src/core/blocks.ts index 3e9a8ed..a441d8c 100644 --- a/packages/core/src/core/blocks.ts +++ b/packages/core/src/core/blocks.ts @@ -6,6 +6,7 @@ import type { BlockForType, BlockPropsByType, BlockOfType, + BaseBlockProps } from './types.ts'; import { BlockType } from './types.ts'; @@ -15,79 +16,87 @@ type LeafBlockType = Exclude; export function block( type: T, props: BlockPropsByType[T], - children: Block[] + rgblChildren: Block[] ): BlockForType; export function block( type: T, props: BlockPropsByType[T], - children?: Block[] + rgblChildren?: Block[] ): BlockForType; export function block( type: T, props: BlockPropsByType[T], - children?: Block[] + rgblChildren?: Block[] ): BlockForType { return { type, props, - children + rgblChildren }; } -export function rectangle(props: BlockPropsByType[BlockType.Rectangle], children?: Block[]): BlockOfType { - return block(BlockType.Rectangle, props, children); +export function rectangle(props: BlockPropsByType[BlockType.Rectangle], rgblChildren?: Block[]): BlockOfType { + return block(BlockType.Rectangle, props, rgblChildren); } -export function circle(props: BlockPropsByType[BlockType.Circle], children?: Block[]): BlockOfType { - return block(BlockType.Circle, props, children); +export function circle(props: BlockPropsByType[BlockType.Circle], rgblChildren?: Block[]): BlockOfType { + return block(BlockType.Circle, props, rgblChildren); } -export function ellipse(props: BlockPropsByType[BlockType.Ellipse], children?: Block[]): BlockOfType { - return block(BlockType.Ellipse, props, children); +export function ellipse(props: BlockPropsByType[BlockType.Ellipse], rgblChildren?: Block[]): BlockOfType { + return block(BlockType.Ellipse, props, rgblChildren); } -export function line(props: BlockPropsByType[BlockType.Line], children?: Block[]): BlockOfType { - return block(BlockType.Line, props, children); +export function line(props: BlockPropsByType[BlockType.Line], rgblChildren?: Block[]): BlockOfType { + return block(BlockType.Line, props, rgblChildren); } -export function text(props: BlockPropsByType[BlockType.Text], children?: Block[]): BlockOfType { - return block(BlockType.Text, props, children); +export function text(props: BlockPropsByType[BlockType.Text], rgblChildren?: Block[]): BlockOfType { + return block(BlockType.Text, props, rgblChildren); } -export function texta(props: BlockPropsByType[BlockType.Texta], children?: Block[]): BlockOfType { - return block(BlockType.Texta, props, children); +export function path(props: BlockPropsByType[BlockType.Path], rgblChildren?: Block[]): BlockOfType { + return block(BlockType.Path, props, rgblChildren); } -export function path(props: BlockPropsByType[BlockType.Path], children?: Block[]): BlockOfType { - return block(BlockType.Path, props, children); +export function arc(props: BlockPropsByType[BlockType.Arc], rgblChildren?: Block[]): BlockOfType { + return block(BlockType.Arc, props, rgblChildren); } -export function arc(props: BlockPropsByType[BlockType.Arc], children?: Block[]): BlockOfType { - return block(BlockType.Arc, props, children); +export function image(props: BlockPropsByType[BlockType.Image], rgblChildren?: Block[]): BlockOfType { + return block(BlockType.Image, props, rgblChildren); } -export function image(props: BlockPropsByType[BlockType.Image], children?: Block[]): BlockOfType { - return block(BlockType.Image, props, children); +export function group(props: BlockPropsByType[BlockType.Group], rgblChildren: Block[]): BlockOfType { + return block(BlockType.Group, props, rgblChildren); } -export function group(props: BlockPropsByType[BlockType.Group], children: Block[]): BlockOfType { - return block(BlockType.Group, props, children); +export function layer(props: BlockPropsByType[BlockType.Layer], rgblChildren: Block[]): BlockOfType { + return block(BlockType.Layer, props, rgblChildren); } -export function layer(props: BlockPropsByType[BlockType.Layer], children: Block[]): BlockOfType { - return block(BlockType.Layer, props, children); +export function portal(props: BlockPropsByType[BlockType.Portal], rgblChildren: Block[]): BlockOfType { + return block(BlockType.Portal, props, rgblChildren); } -export function portal(props: BlockPropsByType[BlockType.Portal], children: Block[]): BlockOfType { - return block(BlockType.Portal, props, children); +export function contentSized(props: BlockPropsByType[BlockType.ContentSized], rgblChildren: Block[]): BlockOfType { + return block(BlockType.ContentSized, props, rgblChildren); } -export function contentSized(props: BlockPropsByType[BlockType.ContentSized], children: Block[]): BlockOfType { - return block(BlockType.ContentSized, props, children); +export function customBlock( + type: string, + props: TProps, + rgblChildren?: Block[] +): Block { + return { + type, + props, + rgblChildren + } as unknown as Block; } /** Convenience factory: wraps children in a group that opens `href` in a new tab on click. */ -export function link(props: { href: string } & BlockPropsByType[BlockType.Group], children: Block[]): BlockOfType { +export function link(props: { href: string } & BlockPropsByType[BlockType.Group], rgblChildren: Block[]): BlockOfType { const { href, onClick: userOnClick, tooltip: userTooltip, ...rest } = props; return group( { @@ -98,6 +107,6 @@ export function link(props: { href: string } & BlockPropsByType[BlockType.Group] if (userOnClick) userOnClick(event); } }, - children + rgblChildren ); } diff --git a/packages/core/src/core/bounds.ts b/packages/core/src/core/bounds.ts new file mode 100644 index 0000000..2796f33 --- /dev/null +++ b/packages/core/src/core/bounds.ts @@ -0,0 +1,105 @@ +// Copyright (c) 2026 François Rouaix + +import type { Block, Rc, Transform } from './types.ts'; +import { BlockType } from './types.ts'; +import { Matrix2D, transformRc } from '../transform.ts'; +import { getBlockTypeHandlers } from './block-registry.ts'; +import type { CustomBlockDescriptor } from './block-registry.ts'; +import { getTextBlockRc } from './text-layout.ts'; + +export function getBlockTransform(props: Transform): Matrix2D { + let xf = Matrix2D.identity(); + const { x, y, rotation, scaleX, scaleY, skewX, skewY } = props; + + if (x !== undefined || y !== undefined) { + xf = xf.translate(x ?? 0, y ?? 0); + } + if (rotation !== undefined) { + xf = xf.rotate(rotation); + } + if (scaleX !== undefined || scaleY !== undefined) { + xf = xf.scaleXY(scaleX ?? 1, scaleY ?? 1); + } + if (skewX !== undefined || skewY !== undefined) { + xf = xf.skewXY(skewX ?? 0, skewY ?? 0); + } + + return xf; +} + +export function rcl(bl: Block): Rc | null { + switch (bl.type) { + case BlockType.Rectangle: { + const { dx, dy } = bl.props; + return { x: 0, y: 0, width: dx, height: dy }; + } + + case BlockType.Circle: { + const { radius } = bl.props; + return { + x: -radius, + y: -radius, + width: radius * 2, + height: radius * 2 + }; + } + + case BlockType.Ellipse: { + const { radiusX, radiusY } = bl.props; + return { + x: -radiusX, + y: -radiusY, + width: radiusX * 2, + height: radiusY * 2 + }; + } + + case BlockType.Line: { + const { x1, x2, y1, y2 } = bl.props; + const xlMin = Math.min(x1, x2); + const xlMax = Math.max(x1, x2); + const ylMin = Math.min(y1, y2); + const ylMax = Math.max(y1, y2); + return { x: xlMin, y: ylMin, width: xlMax - xlMin, height: ylMax - ylMin }; + } + + case BlockType.Text: { + return getTextBlockRc(bl.props.text, bl.props); + } + + case BlockType.Arc: { + const { radius } = bl.props; + return { x: -radius, y: -radius, width: radius * 2, height: radius * 2 }; + } + + case BlockType.Image: { + const { dx, dy } = bl.props; + return { x: 0, y: 0, width: dx, height: dy }; + } + + case BlockType.Path: + case BlockType.Group: + case BlockType.Layer: + case BlockType.Portal: + case BlockType.ContentSized: + // No intrinsic bounds — depends on children or path data + return null; + + default: { + const blockCustom = bl as CustomBlockDescriptor; + const handlers = getBlockTypeHandlers(blockCustom.type); + return handlers?.rcl?.(blockCustom) ?? null; + } + } +} + +// Get bounding box for a block in world coordinates. +export function getBlockBounds(bl: Block, xfParent: Matrix2D = Matrix2D.identity()): Rc | null { + const xfBl = getBlockTransform(bl.props); + const xfCur = xfParent.multiply(xfBl); + + const rclT = rcl(bl); + if (!rclT) return null; + + return transformRc(rclT, xfCur); +} diff --git a/packages/core/src/core/context.ts b/packages/core/src/core/context.ts index 71007a0..ef49f92 100644 --- a/packages/core/src/core/context.ts +++ b/packages/core/src/core/context.ts @@ -2,38 +2,56 @@ // Rendering context abstraction import { Matrix2D, TransformStack } from '../transform.ts'; -import type { Block, BaseBlockProps, Rc, FillStyle } from './types.ts'; +import type { + DrawArcStyleProps, + DrawCircleStyleProps, + DrawImageStyleProps, + DrawLineStyleProps, + DrawPathStyleProps, + DrawRectStyleProps, + DrawTextStyleProps, + FillStyle, + LineStyleProps, + TextMeasure, + TextMeasureProps, +} from './types.ts'; + +export const DU_FONTSIZE_DEFAULT = 16; +export const SF_TEXT_ASCENT_DEFAULT = 0.8; +export const SF_TEXT_DESCENT_DEFAULT = 0.2; +export const SF_TEXT_ADVANCE_APPROX_DEFAULT = 0.6; +export const SF_TEXT_LINE_HEIGHT_DEFAULT = 1.4; export interface RenderContext { transformStack: TransformStack; opacity: number; - visible: boolean; + fVisible: boolean; save(): void; restore(): void; - applyTransform(matrix: Matrix2D): void; + applyTransform(xf: Matrix2D): void; setOpacity(opacity: number): void; // Drawing primitives - to be implemented by concrete renderers clear(): void; - drawRectangle(xl: number, yl: number, dxl: number, dyl: number, props: any): void; - drawCircle(xl: number, yl: number, rl: number, props: any): void; - drawEllipse(xl: number, yl: number, rxl: number, ryl: number, props: any): void; - drawPath(pathData: string, props: any): void; - drawLine(xl1: number, yl1: number, xl2: number, yl2: number, props: any): void; - drawText(text: string, xl: number, yl: number, props: any): void; - drawImage(image: HTMLImageElement, xl: number, yl: number, dxl: number, dyl: number, props: any): void; - drawArc(xl: number, yl: number, rl: number, startAngle: number, endAngle: number, props: any): void; - measureText?(text: string, props: any): { width: number; height: number; ascent: number; descent: number }; + drawRectangle(xl: number, yl: number, dxl: number, dyl: number, props: DrawRectStyleProps): void; + drawCircle(xl: number, yl: number, rl: number, props: DrawCircleStyleProps): void; + drawEllipse(xl: number, yl: number, rxl: number, ryl: number, props: DrawCircleStyleProps): void; + drawPath(pathData: string, props: DrawPathStyleProps): void; + drawLine(xl1: number, yl1: number, xl2: number, yl2: number, props: DrawLineStyleProps): void; + drawText(text: string, xl: number, yl: number, props: DrawTextStyleProps): void; + drawImage(image: HTMLImageElement, xl: number, yl: number, dxl: number, dyl: number, props: DrawImageStyleProps): void; + drawArc(xl: number, yl: number, rl: number, startAngle: number, endAngle: number, props: DrawArcStyleProps): void; + measureText?(text: string, props: TextMeasureProps): TextMeasure; } export class Canvas2DContext implements RenderContext { transformStack: TransformStack; opacity: number = 1; - visible: boolean = true; + fVisible: boolean = true; private ctx: CanvasRenderingContext2D; - private opacityStack: number[] = []; + private rgOpacity: number[] = []; constructor(ctx: CanvasRenderingContext2D) { this.ctx = ctx; @@ -41,7 +59,7 @@ export class Canvas2DContext implements RenderContext { } /** Apply optional line-style props (lineCap, lineJoin, lineDash, lineDashOffset) to the context. */ - private applyLineStyle(props: any): void { + private applyLineStyle(props: LineStyleProps): void { if (props.lineCap) this.ctx.lineCap = props.lineCap; if (props.lineJoin) this.ctx.lineJoin = props.lineJoin; if (props.lineDash) this.ctx.setLineDash(props.lineDash); @@ -78,13 +96,13 @@ export class Canvas2DContext implements RenderContext { save(): void { this.ctx.save(); this.transformStack.save(); - this.opacityStack.push(this.opacity); + this.rgOpacity.push(this.opacity); } restore(): void { this.ctx.restore(); this.transformStack.restore(); - const prevOpacity = this.opacityStack.pop(); + const prevOpacity = this.rgOpacity.pop(); if (prevOpacity !== undefined) this.opacity = prevOpacity; } @@ -104,7 +122,7 @@ export class Canvas2DContext implements RenderContext { this.ctx.restore(); } - drawRectangle(xl: number, yl: number, dxl: number, dyl: number, props: any): void { + drawRectangle(xl: number, yl: number, dxl: number, dyl: number, props: DrawRectStyleProps): void { const { cornerRadius: duCornerRadius, fill, stroke, strokeWidth } = props; if (duCornerRadius) { this.roundRect(xl, yl, dxl, dyl, duCornerRadius, props); @@ -122,7 +140,7 @@ export class Canvas2DContext implements RenderContext { } } - private roundRect(xl: number, yl: number, dxl: number, dyl: number, rl: number, props: any): void { + private roundRect(xl: number, yl: number, dxl: number, dyl: number, rl: number, props: DrawRectStyleProps): void { const { fill, stroke, strokeWidth } = props; this.ctx.beginPath(); this.ctx.moveTo(xl + rl, yl); @@ -148,7 +166,7 @@ export class Canvas2DContext implements RenderContext { } } - drawCircle(xl: number, yl: number, rl: number, props: any): void { + drawCircle(xl: number, yl: number, rl: number, props: DrawCircleStyleProps): void { const { fill, stroke, strokeWidth, fillRule } = props; this.ctx.beginPath(); this.ctx.arc(xl, yl, rl, 0, Math.PI * 2); @@ -164,7 +182,7 @@ export class Canvas2DContext implements RenderContext { } } - drawEllipse(xl: number, yl: number, rxl: number, ryl: number, props: any): void { + drawEllipse(xl: number, yl: number, rxl: number, ryl: number, props: DrawCircleStyleProps): void { const { fill, stroke, strokeWidth, fillRule } = props; this.ctx.beginPath(); this.ctx.ellipse(xl, yl, rxl, ryl, 0, 0, Math.PI * 2); @@ -180,7 +198,7 @@ export class Canvas2DContext implements RenderContext { } } - drawPath(pathData: string, props: any): void { + drawPath(pathData: string, props: DrawPathStyleProps): void { const { fill, stroke, strokeWidth, fillRule } = props; const path = new Path2D(pathData); if (fill) { @@ -195,7 +213,7 @@ export class Canvas2DContext implements RenderContext { } } - drawLine(xl1: number, yl1: number, xl2: number, yl2: number, props: any): void { + drawLine(xl1: number, yl1: number, xl2: number, yl2: number, props: DrawLineStyleProps): void { const { stroke, strokeWidth } = props; this.ctx.beginPath(); this.ctx.moveTo(xl1, yl1); @@ -206,51 +224,74 @@ export class Canvas2DContext implements RenderContext { this.ctx.stroke(); } - /** Word-wrap text into lines that fit within maxWidth pixels. */ - private wrapText(text: string, dxMax: number): string[] { - const words = text.split(/\s+/); - if (words.length === 0) return ['']; - const lines: string[] = []; - let currentLine = words[0]; - for (let i = 1; i < words.length; i++) { - const testLine = currentLine + ' ' + words[i]; - if (this.ctx.measureText(testLine).width > dxMax) { - lines.push(currentLine); - currentLine = words[i]; + /** Word-wrap text into lines that fit within dxMax pixels. */ + // TODO: this is a very naive implementation that only breaks on whitespace and doesn't consider breaking long words. + // It also doesn't cache results, which could be expensive for large blocks of text or frequent re-rendering. + private rgtextWrapped(text: string, dxMax: number): string[] { + const rgwords = text.split(/\s+/); + if (rgwords.length === 0) return ['']; + const rglines: string[] = []; + let lineCur = rgwords[0]; + for (let i = 1; i < rgwords.length; i++) { + const lineCandidate = lineCur + ' ' + rgwords[i]; + if (this.ctx.measureText(lineCandidate).width > dxMax) { + rglines.push(lineCur); + lineCur = rgwords[i]; } else { - currentLine = testLine; + lineCur = lineCandidate; } } - lines.push(currentLine); - return lines; + rglines.push(lineCur); + return rglines; } - measureText(text: string, props: any): { width: number; height: number; ascent: number; descent: number } { - const { font, fontSize, dx: dxMax, lineHeight: lineHeightProp } = props; - // Apply font settings + measureText(text: string, props: TextMeasureProps): TextMeasure { + const { font, fontSize = DU_FONTSIZE_DEFAULT, dx: dxMax, dyLineHeight } = props; + if (text.length === 0) { + return { + width: 0, + height: fontSize, + ascent: fontSize * SF_TEXT_ASCENT_DEFAULT, + descent: fontSize * SF_TEXT_DESCENT_DEFAULT + }; + } + + // Apply font settings for ctx.measureText if (font) this.ctx.font = font; else if (fontSize) this.ctx.font = `${fontSize}px sans-serif`; - - const metrics = this.ctx.measureText(text); - const ascent = metrics.actualBoundingBoxAscent ?? (fontSize ?? 16); - const descent = metrics.actualBoundingBoxDescent ?? 0; - - if (dxMax !== undefined) { - const lines = this.wrapText(text, dxMax); - const duLineHeight = lineHeightProp ?? (fontSize ?? 16) * 1.4; - const maxLineWidth = Math.min(dxMax, Math.max(...lines.map(l => this.ctx.measureText(l).width))); - const totalHeight = lines.length * duLineHeight; - return { width: maxLineWidth, height: totalHeight, ascent, descent }; + + let tm: TextMetrics; + let width: number, height: number; + + + if (dxMax === undefined) { + tm = this.ctx.measureText(text); + width = tm.width; + } else { + const rglines = this.rgtextWrapped(text, dxMax); + const duLineHeight = dyLineHeight ?? fontSize * SF_TEXT_LINE_HEIGHT_DEFAULT; + const rgtm = rglines.map(l => this.ctx.measureText(l)); + const dxLineWidthMax = Math.min(dxMax, Math.max(...rgtm.map(tm => tm.width))); + const dyTotal = rglines.length * duLineHeight; + // Extract ascent/descent from first line measurement + tm = rgtm[0] ?? { fontBoundingBoxAscent: 0, fontBoundingBoxDescent: 0, actualBoundingBoxAscent: 0, actualBoundingBoxDescent: 0 }; + width = dxLineWidthMax; + height = dyTotal; } - const width = metrics.width; - const height = ascent + descent; + const ascent = tm.fontBoundingBoxAscent + ?? tm.actualBoundingBoxAscent + ?? fontSize * SF_TEXT_ASCENT_DEFAULT; + const descent = tm.fontBoundingBoxDescent + ?? tm.actualBoundingBoxDescent + ?? fontSize * SF_TEXT_DESCENT_DEFAULT; + height ??= ascent + descent; return { width, height, ascent, descent }; } - drawText(text: string, xl: number, yl: number, props: any): void { - const { font, fontSize, align, baseline, fill, stroke, strokeWidth, - dx: dxMax, dy: dyMax, lineHeight: lineHeightProp } = props; + drawText(text: string, xl: number, yl: number, props: DrawTextStyleProps): void { + const { font, fontSize = DU_FONTSIZE_DEFAULT, align, baseline, fill, stroke, strokeWidth, + dx: dxMax, dy: dyMax, dyLineHeight } = props; if (font) this.ctx.font = font; else if (fontSize) this.ctx.font = `${fontSize}px sans-serif`; if (align) this.ctx.textAlign = align; @@ -272,47 +313,47 @@ export class Canvas2DContext implements RenderContext { } // Multi-line wrapping - const lines = this.wrapText(text, dxMax); - const duLineHeight = lineHeightProp ?? (fontSize ?? 16) * 1.4; + const rglines = this.rgtextWrapped(text, dxMax); + const duLineHeight = dyLineHeight ?? fontSize * SF_TEXT_LINE_HEIGHT_DEFAULT; // Clip vertically when dy is set - const shouldClip = dyMax !== undefined; - if (shouldClip) { + const fShouldClip = dyMax !== undefined; + if (fShouldClip) { this.ctx.save(); this.ctx.beginPath(); // Clip region depends on alignment - let clipX = xl; - if (align === 'center') clipX = xl - dxMax / 2; - else if (align === 'right' || align === 'end') clipX = xl - dxMax; + let xClip = xl; + if (align === 'center') xClip = xl - dxMax / 2; + else if (align === 'right' || align === 'end') xClip = xl - dxMax; // Clip region depends on baseline - let clipY = yl; - if (baseline === 'alphabetic' || !baseline) clipY = yl - (fontSize ?? 16); - else if (baseline === 'middle') clipY = yl - duLineHeight / 2; - else if (baseline === 'bottom') clipY = yl - dyMax; - this.ctx.rect(clipX, clipY, dxMax, dyMax); + let yClip = yl; + if (baseline === 'alphabetic' || !baseline) yClip = yl - fontSize; + else if (baseline === 'middle') yClip = yl - duLineHeight / 2; + else if (baseline === 'bottom') yClip = yl - dyMax; + this.ctx.rect(xClip, yClip, dxMax, dyMax); this.ctx.clip(); } - for (let i = 0; i < lines.length; i++) { - const lineY = yl + i * duLineHeight; + for (let i = 0; i < rglines.length; i++) { + const yLine = yl + i * duLineHeight; if (fill) { this.ctx.fillStyle = this.resolveFillStyle(fill); - this.ctx.fillText(lines[i], xl, lineY); + this.ctx.fillText(rglines[i], xl, yLine); } if (stroke) { this.applyLineStyle(props); this.ctx.strokeStyle = this.resolveFillStyle(stroke); this.ctx.lineWidth = strokeWidth ?? 1; - this.ctx.strokeText(lines[i], xl, lineY); + this.ctx.strokeText(rglines[i], xl, yLine); } } - if (shouldClip) { + if (fShouldClip) { this.ctx.restore(); } } - drawImage(image: HTMLImageElement, xl: number, yl: number, dxl: number, dyl: number, props: any): void { + drawImage(image: HTMLImageElement, xl: number, yl: number, dxl: number, dyl: number, props: DrawImageStyleProps): void { const { sx, sy, sw, sh } = props; if (sx !== undefined && sy !== undefined && sw !== undefined && sh !== undefined) { this.ctx.drawImage(image, sx, sy, sw, sh, xl, yl, dxl, dyl); @@ -321,7 +362,7 @@ export class Canvas2DContext implements RenderContext { } } - drawArc(xl: number, yl: number, rl: number, startAngle: number, endAngle: number, props: any): void { + drawArc(xl: number, yl: number, rl: number, startAngle: number, endAngle: number, props: DrawArcStyleProps): void { const { fill, stroke, strokeWidth, fillRule } = props; this.ctx.beginPath(); this.ctx.arc(xl, yl, rl, startAngle, endAngle); diff --git a/packages/core/src/core/renderer-immediate.ts b/packages/core/src/core/renderer-immediate.ts index a6b62b4..bdf48a9 100644 --- a/packages/core/src/core/renderer-immediate.ts +++ b/packages/core/src/core/renderer-immediate.ts @@ -1,16 +1,18 @@ // Copyright (c) 2026 François Rouaix // Immediate-mode rendering engine -import type { Block, BlockOfType } from './types.ts'; +import type { Block, BlockOfType, Rc } from './types.ts'; import { BlockType } from './types.ts'; import type { RenderContext } from './context.ts'; import { Canvas2DContext } from './context.ts'; -import { getRgRenderBridgeRun } from 'texta/browser'; import { EventManager } from '../events.ts'; import { PerformanceOptimizer, PerformanceMonitor, type Viewport } from '../performance.ts'; import { HitTester, type HitTestLayoutCache, type HitTestResult } from '../hit-test.ts'; -import { Matrix2D } from '../transform.ts'; +import { Matrix2D, transformRc } from '../transform.ts'; +import { getBlockBounds, getBlockTransform } from './bounds.ts'; +import { getTextBlockRc } from './text-layout.ts'; import { group, rectangle, text, portal } from './blocks.ts'; +import { getBlockTypeHandlers, type CustomBlockDescriptor } from './block-registry.ts'; const TOOLTIP_DEFAULTS = { colBg: '#1f2937', @@ -27,45 +29,52 @@ const TOOLTIP_DEFAULTS = { duMaxWidth: 300 } as const; +const PROPS_OUTLINE_DEBUG_HOVER = { + fill: 'transparent', + stroke: '#ff00ff', + strokeWidth: 2 +} as const; + export interface RendererConfig { canvas?: HTMLCanvasElement; - width?: number; - height?: number; + dx?: number; + dy?: number; pixelRatio?: number; - enableEvents?: boolean; - enableCulling?: boolean; - debugHoverOutline?: boolean; - enableCameraControls?: boolean; + fEnableEvents?: boolean; + fEnableCulling?: boolean; + fDebugHoverOutline?: boolean; + fEnableCameraControls?: boolean; } export class ImmediateRenderer { private canvas: HTMLCanvasElement; - private context: RenderContext; - private imageCache: Map = new Map(); private dxc: number; private dyc: number; private pixelRatio: number; + private fEnableCulling: boolean; + private fDebugHoverOutline: boolean; + private fEnableCameraControls: boolean; + + private context: RenderContext; + private imageCache: Map = new Map(); private eventManager: EventManager | null = null; - private enableCulling: boolean; - private debugHoverOutline: boolean; private debugHoveredBlock: Block | null = null; private viewport: Viewport; private perfMonitor: PerformanceMonitor; - private enableCameraControls: boolean; - private cameraX: number = 0; - private cameraY: number = 0; - private cameraZoom: number = 1; + private xsCamera: number = 0; + private ysCamera: number = 0; + private sfCamera: number = 1; private portalBlocks: Array<{ block: Block; transform: Matrix2D }> = []; - private hitTestLayoutCache: HitTestLayoutCache = { boundsByBlock: new WeakMap() }; + private hitTestLayoutCache: HitTestLayoutCache = { mpbl_rc: new WeakMap() }; constructor(config: RendererConfig = {}) { this.canvas = config.canvas || document.createElement('canvas'); - this.dxc = config.width || 800; - this.dyc = config.height || 600; + this.dxc = config.dx || 800; + this.dyc = config.dy || 600; this.pixelRatio = config.pixelRatio || window.devicePixelRatio || 1; - this.enableCulling = config.enableCulling ?? true; - this.debugHoverOutline = config.debugHoverOutline ?? false; - this.enableCameraControls = config.enableCameraControls ?? false; + this.fEnableCulling = config.fEnableCulling ?? true; + this.fDebugHoverOutline = config.fDebugHoverOutline ?? false; + this.fEnableCameraControls = config.fEnableCameraControls ?? false; this.viewport = { x: 0, @@ -76,17 +85,20 @@ export class ImmediateRenderer { this.setupCanvas(); - const ctx = this.canvas.getContext('2d'); + const ctx = this.canvas.getContext('2d', { + alpha: true, + willReadFrequently: false + }); if (!ctx) throw new Error('Failed to get 2D context'); this.context = new Canvas2DContext(ctx); // Enable event handling by default - if (config.enableEvents !== false) { + if (config.fEnableEvents !== false) { this.eventManager = new EventManager(this.canvas); this.eventManager.setPixelRatio(this.pixelRatio); // Setup camera controls if enabled - if (this.enableCameraControls) { + if (this.fEnableCameraControls) { this.setupCameraControls(); } } @@ -114,11 +126,11 @@ export class ImmediateRenderer { } setDebugHoverOutline(enabled: boolean): void { - this.debugHoverOutline = enabled; + this.fDebugHoverOutline = enabled; } getDebugHoverOutline(): boolean { - return this.debugHoverOutline; + return this.fDebugHoverOutline; } private setupCameraControls(): void { @@ -131,7 +143,7 @@ export class ImmediateRenderer { if (e.ctrlKey) { // Ctrl+MouseWheel: zoom in/out towards mouse pointer const zoomDelta = -e.deltaY * zoomSpeed; - const newZoom = Math.max(0.1, Math.min(10, this.cameraZoom + zoomDelta)); + const newZoom = Math.max(0.1, Math.min(10, this.sfCamera + zoomDelta)); // Convert mouse position from CSS pixels to logical canvas coordinates const rect = this.canvas.getBoundingClientRect(); @@ -145,21 +157,21 @@ export class ImmediateRenderer { // Camera group uses Translate × Scale convention: // screen = world * zoom + translate // world = (screen - translate) / zoom - const worldX = (logicalX - this.cameraX) / this.cameraZoom; - const worldY = (logicalY - this.cameraY) / this.cameraZoom; + const worldX = (logicalX - this.xsCamera) / this.sfCamera; + const worldY = (logicalY - this.ysCamera) / this.sfCamera; // Keep world point under mouse after zoom change: // logicalX = worldX * newZoom + newTranslateX // newTranslateX = logicalX - worldX * newZoom - this.cameraX = logicalX - worldX * newZoom; - this.cameraY = logicalY - worldY * newZoom; - this.cameraZoom = newZoom; + this.xsCamera = logicalX - worldX * newZoom; + this.ysCamera = logicalY - worldY * newZoom; + this.sfCamera = newZoom; } else if (e.shiftKey) { // Shift+MouseWheel: scroll horizontally (translate is in screen units) - this.cameraX -= e.deltaY * panSpeed; + this.xsCamera -= e.deltaY * panSpeed; } else { // MouseWheel: scroll vertically (translate is in screen units) - this.cameraY -= e.deltaY * panSpeed; + this.ysCamera -= e.deltaY * panSpeed; } }; @@ -168,16 +180,16 @@ export class ImmediateRenderer { getCameraTransform(): { x: number; y: number; zoom: number } { return { - x: this.cameraX, - y: this.cameraY, - zoom: this.cameraZoom + x: this.xsCamera, + y: this.ysCamera, + zoom: this.sfCamera }; } setCameraTransform(x: number, y: number, zoom: number): void { - this.cameraX = x; - this.cameraY = y; - this.cameraZoom = zoom; + this.xsCamera = x; + this.ysCamera = y; + this.sfCamera = zoom; } /** @@ -185,11 +197,11 @@ export class ImmediateRenderer { * transform to the EventManager. Wrap your scene children with this method * so that camera controls (pan/zoom) are applied as a regular group transform. */ - camera(children: Block[]): Block { + camera(rgbl: Block[]): Block { // Build the camera transform matrix (Translate × Scale) and sync to EventManager const cameraTransform = Matrix2D.identity() - .translate(this.cameraX, this.cameraY) - .scaleXY(this.cameraZoom, this.cameraZoom); + .translate(this.xsCamera, this.ysCamera) + .scaleXY(this.sfCamera, this.sfCamera); const fullTransform = Matrix2D.identity() .scaleXY(this.pixelRatio, this.pixelRatio) .multiply(cameraTransform); @@ -198,24 +210,24 @@ export class ImmediateRenderer { } return group({ - x: this.cameraX, - y: this.cameraY, - scaleX: this.cameraZoom, - scaleY: this.cameraZoom - }, children); + x: this.xsCamera, + y: this.ysCamera, + scaleX: this.sfCamera, + scaleY: this.sfCamera + }, rgbl); } - render(block: Block): void { + render(bl: Block): void { const startTime = performance.now(); // Layout cache is valid for exactly one rendered frame. - this.hitTestLayoutCache = { boundsByBlock: new WeakMap() }; + this.hitTestLayoutCache = { mpbl_rc: new WeakMap() }; // Clear portal collection this.portalBlocks = []; this.debugHoveredBlock = null; - if (this.debugHoverOutline && this.eventManager) { + if (this.fDebugHoverOutline && this.eventManager) { const ptcLastPointer = this.eventManager.getLastPointerCanvasPosition(); if (ptcLastPointer) { // Convert canvas buffer coordinates to logical coordinates (remove pixelRatio). @@ -233,7 +245,7 @@ export class ImmediateRenderer { // Fall back to main scene if (!hit) { - hit = HitTester.hitTest(block, logicalX, logicalY, Matrix2D.identity(), [], this.hitTestLayoutCache); + hit = HitTester.hitTest(bl, logicalX, logicalY, Matrix2D.identity(), [], this.hitTestLayoutCache); } this.debugHoveredBlock = hit?.block || null; @@ -247,7 +259,7 @@ export class ImmediateRenderer { // Camera transform is now applied via the camera group block in the scene tree. // No special camera transform application here. - this.renderBlock(block); + this.renderBlock(bl); this.context.restore(); // Render portals on top @@ -258,7 +270,7 @@ export class ImmediateRenderer { // Update event system with current scene if (this.eventManager) { - this.eventManager.setScene(block); + this.eventManager.setScene(bl); // Pass portal blocks to event manager for layer-aware hit testing const portalContainers = this.portalBlocks.map(p => p.block); @@ -267,7 +279,7 @@ export class ImmediateRenderer { // Camera transform is synced to EventManager via camera() method. // For non-camera scenes, still account for pixelRatio. - if (!this.enableCameraControls && this.pixelRatio !== 1) { + if (!this.fEnableCameraControls && this.pixelRatio !== 1) { const pixelRatioTransform = Matrix2D.identity() .scaleXY(this.pixelRatio, this.pixelRatio); this.eventManager.setCameraTransform(pixelRatioTransform); @@ -291,15 +303,15 @@ export class ImmediateRenderer { this.imageCache.clear(); } - private renderBlock(block: Block): void { - const { props, children } = block; - const { visible, opacity: fOpacity = 1, shadow } = props; - if (visible === false) return; + private renderBlock(bl: Block): void { + const { props, rgblChildren } = bl; + const { fVisible, opacity = 1, shadow } = props; + if (fVisible === false) return; // Frustum culling - if (this.enableCulling) { + if (this.fEnableCulling) { const inView = PerformanceOptimizer.cullBlocks( - block, + bl, this.viewport, this.context.transformStack.getCurrent() ); @@ -316,19 +328,19 @@ export class ImmediateRenderer { // Apply transform this.context.transformStack.save(); this.context.transformStack.apply(props); - const worldTransform = this.context.transformStack.getCurrent(); + const xfWorld = this.context.transformStack.getCurrent(); if (this.pixelRatio !== 1) { - const renderTransform = Matrix2D.identity() + const xfRender = Matrix2D.identity() .scaleXY(this.pixelRatio, this.pixelRatio) - .multiply(worldTransform); - this.context.applyTransform(renderTransform); + .multiply(xfWorld); + this.context.applyTransform(xfRender); } else { - this.context.applyTransform(worldTransform); + this.context.applyTransform(xfWorld); } // Apply opacity - const parentOpacity = this.context.opacity; - this.context.setOpacity(parentOpacity * fOpacity); + const opacityParent = this.context.opacity; + this.context.setOpacity(opacityParent * opacity); // Apply shadow if present if (shadow) { @@ -351,54 +363,52 @@ export class ImmediateRenderer { } } + // Render based on block type - switch (block.type) { + switch (bl.type) { case BlockType.Rectangle: - this.renderRectangle(block); + this.renderRectangle(bl); break; case BlockType.Circle: - this.renderCircle(block); + this.renderCircle(bl); break; case BlockType.Ellipse: - this.renderEllipse(block); + this.renderEllipse(bl); break; case BlockType.Path: - this.renderPath(block); + this.renderPath(bl); break; case BlockType.Line: - this.renderLine(block); + this.renderLine(bl); break; case BlockType.Text: - this.renderText(block); - break; - case BlockType.Texta: - this.renderTexta(block); + this.renderText(bl); break; case BlockType.Image: - this.renderImage(block); + this.renderImage(bl); break; case BlockType.Arc: - this.renderArc(block); + this.renderArc(bl); break; case BlockType.Portal: // Collect portal instead of rendering inline this.portalBlocks.push({ - block, - transform: worldTransform.clone() + block: bl, + transform: xfWorld.clone() }); // Don't render children here - they'll be rendered in portal pass this.context.transformStack.restore(); this.context.restore(); return; case BlockType.ContentSized: - this.renderContentSized(block as BlockOfType); + this.renderContentSized(bl as BlockOfType); this.context.transformStack.restore(); this.context.restore(); return; case BlockType.Group: case BlockType.Layer: { // Apply blend mode for Layer blocks - if (block.type === BlockType.Layer) { + if (bl.type === BlockType.Layer) { const { blendMode } = props as any; if (blendMode) { const ctx = (this.context as any).ctx as CanvasRenderingContext2D; @@ -420,574 +430,188 @@ export class ImmediateRenderer { } break; } + default: { + const blockCustom = bl as unknown as CustomBlockDescriptor; + const handlers = getBlockTypeHandlers(blockCustom.type); + handlers?.render?.(blockCustom, { + context: this.context, + layoutCache: this.hitTestLayoutCache, + setLayoutBounds: (bounds) => { + this.hitTestLayoutCache.mpbl_rc.set(bl, bounds); + } + }); + break; + } } // Render children if any - if (children) { - for (const child of children) { - this.renderBlock(child); + if (rgblChildren) { + for (const blChild of rgblChildren) { + this.renderBlock(blChild); } } - if (this.debugHoverOutline && block === this.debugHoveredBlock) { - this.renderDebugHoverOutline(block); + if (this.fDebugHoverOutline && bl === this.debugHoveredBlock) { + this.renderDebugHoverOutline(bl); } this.context.transformStack.restore(); this.context.restore(); } - private getLocalTransformFromProps(props: any): Matrix2D { - let transform = Matrix2D.identity(); - const { x, y, rotation, scaleX, scaleY, skewX, skewY } = props; - - if (x !== undefined || y !== undefined) { - transform = transform.translate(x ?? 0, y ?? 0); - } - if (rotation !== undefined) { - transform = transform.rotate(rotation); - } - if (scaleX !== undefined || scaleY !== undefined) { - transform = transform.scaleXY(scaleX ?? 1, scaleY ?? 1); - } - if (skewX !== undefined || skewY !== undefined) { - transform = transform.skewXY(skewX ?? 0, skewY ?? 0); - } - - return transform; - } - - private transformBounds(bounds: { x: number; y: number; width: number; height: number }, transform: Matrix2D): { x: number; y: number; width: number; height: number } { - const corners = [ - transform.transformPoint(bounds.x, bounds.y), - transform.transformPoint(bounds.x + bounds.width, bounds.y), - transform.transformPoint(bounds.x, bounds.y + bounds.height), - transform.transformPoint(bounds.x + bounds.width, bounds.y + bounds.height) - ]; - - const xMin = Math.min(...corners.map((c) => c.x)); - const xMax = Math.max(...corners.map((c) => c.x)); - const yMin = Math.min(...corners.map((c) => c.y)); - const yMax = Math.max(...corners.map((c) => c.y)); - - return { - x: xMin, - y: yMin, - width: xMax - xMin, - height: yMax - yMin - }; - } - - private renderContentSized(block: BlockOfType): void { - const { props, children } = block; - if (!children || children.length === 0) { + private renderContentSized(bl: BlockOfType): void { + const { props, rgblChildren } = bl; + if (!rgblChildren || rgblChildren.length === 0) { return; } - for (const child of children) { - this.renderBlock(child); + for (const blChild of rgblChildren) { + this.renderBlock(blChild); } - const localBoundsChildren = children - .map((child) => { - const childBoundsLocal = this.hitTestLayoutCache.boundsByBlock.get(child); - if (!childBoundsLocal) { + const rgrclChildren = rgblChildren + .map((blChild) => { + const rclChild = this.hitTestLayoutCache.mpbl_rc.get(blChild); + if (!rclChild) { return null; } - const childTransform = this.getLocalTransformFromProps(child.props); - return this.transformBounds(childBoundsLocal, childTransform); + const xfChild = getBlockTransform(blChild.props); + return transformRc(rclChild, xfChild); }) - .filter((bounds): bounds is { x: number; y: number; width: number; height: number } => bounds !== null); + .filter((rc): rc is Rc => rc !== null); - if (localBoundsChildren.length === 0) { + if (rgrclChildren.length === 0) { return; } - const xMin = Math.min(...localBoundsChildren.map((b) => b.x)); - const yMin = Math.min(...localBoundsChildren.map((b) => b.y)); - const xMax = Math.max(...localBoundsChildren.map((b) => b.x + b.width)); - const yMax = Math.max(...localBoundsChildren.map((b) => b.y + b.height)); + const xMin = Math.min(...rgrclChildren.map((b) => b.x)); + const yMin = Math.min(...rgrclChildren.map((b) => b.y)); + const xMax = Math.max(...rgrclChildren.map((b) => b.x + b.width)); + const yMax = Math.max(...rgrclChildren.map((b) => b.y + b.height)); const padding = props.padding ?? 0; const paddingX = props.paddingX ?? padding; const paddingY = props.paddingY ?? padding; - const bounds = { + const rc = { x: xMin - paddingX, y: yMin - paddingY, width: xMax - xMin + paddingX * 2, height: yMax - yMin + paddingY * 2 }; - this.hitTestLayoutCache.boundsByBlock.set(block, bounds); + this.hitTestLayoutCache.mpbl_rc.set(bl, rc); if (props.fill || props.stroke) { - this.context.drawRectangle(bounds.x, bounds.y, bounds.width, bounds.height, { - fill: props.fill, - stroke: props.stroke, - strokeWidth: props.strokeWidth, - lineCap: props.lineCap, - lineJoin: props.lineJoin, - lineDash: props.lineDash, - lineDashOffset: props.lineDashOffset, - cornerRadius: props.cornerRadius - }); + this.context.drawRectangle(rc.x, rc.y, rc.width, rc.height, props); } } - - private renderDebugHoverOutline(block: Block): void { - const propsOutline = { - fill: 'transparent', - stroke: '#ff00ff', - strokeWidth: 2 - }; - - switch (block.type) { + + private renderDebugHoverOutline(bl: Block): void { + switch (bl.type) { case BlockType.Rectangle: { - const { dx, dy } = block.props; - this.context.drawRectangle(0, 0, dx, dy, propsOutline); + const { dx, dy } = bl.props; + this.context.drawRectangle(0, 0, dx, dy, PROPS_OUTLINE_DEBUG_HOVER); return; } case BlockType.Circle: { - const { radius } = block.props; - this.context.drawCircle(0, 0, radius, propsOutline); + const { radius } = bl.props; + this.context.drawCircle(0, 0, radius, PROPS_OUTLINE_DEBUG_HOVER); return; } case BlockType.Ellipse: { - const { radiusX, radiusY } = block.props; - this.context.drawEllipse(0, 0, radiusX, radiusY, propsOutline); + const { radiusX, radiusY } = bl.props; + this.context.drawEllipse(0, 0, radiusX, radiusY, PROPS_OUTLINE_DEBUG_HOVER); return; } case BlockType.Line: { - const { x1, y1, x2, y2 } = block.props; - this.context.drawLine(x1, y1, x2, y2, { stroke: '#ff00ff', strokeWidth: 3 }); + const { x1, y1, x2, y2 } = bl.props; + this.context.drawLine(x1, y1, x2, y2, { stroke: PROPS_OUTLINE_DEBUG_HOVER.stroke, strokeWidth: PROPS_OUTLINE_DEBUG_HOVER.strokeWidth }); return; } case BlockType.Text: { - const { text, fontSize, align, baseline } = block.props; - - // Get actual text metrics - if (this.context.measureText) { - const metrics = this.context.measureText(text, block.props); - const { width, ascent, descent } = metrics; - const height = ascent + descent; - - // Calculate x offset based on alignment - let xOffset = 0; - if (align === 'center') { - xOffset = -width / 2; - } else if (align === 'right' || align === 'end') { - xOffset = -width; - } - - // Calculate y offset based on baseline - let yOffset = -ascent; // Default for 'alphabetic' baseline - if (baseline === 'top' || baseline === 'hanging') { - yOffset = 0; - } else if (baseline === 'middle') { - yOffset = -height / 2; - } else if (baseline === 'bottom') { - yOffset = -height; - } - - this.context.drawRectangle(xOffset, yOffset, width, height, propsOutline); - } else { - // Fallback to approximation if measureText not available - const duFont = fontSize ?? 16; - const textWidth = text.length * duFont * 0.6; - this.context.drawRectangle(0, 0, textWidth, duFont, propsOutline); - } - return; - } - case BlockType.Texta: { - const { texta: attributedText, fontSize, align, baseline } = block.props; - const textValue = attributedText.strText; - - if (this.context.measureText) { - const metrics = this.context.measureText(textValue, { fontSize }); - const { width, ascent, descent } = metrics; - const height = ascent + descent; - - let xOffset = 0; - if (align === 'center') { - xOffset = -width / 2; - } else if (align === 'right' || align === 'end') { - xOffset = -width; - } - - let yOffset = -ascent; - if (baseline === 'top' || baseline === 'hanging') { - yOffset = 0; - } else if (baseline === 'middle') { - yOffset = -height / 2; - } else if (baseline === 'bottom') { - yOffset = -height; - } - - this.context.drawRectangle(xOffset, yOffset, width, height, propsOutline); - } else { - const duFont = fontSize ?? 16; - const textWidth = textValue.length * duFont * 0.6; - this.context.drawRectangle(0, 0, textWidth, duFont, propsOutline); - } + const { text, fontSize, align, baseline } = bl.props; + const rc = getTextBlockRc(text, { ...bl.props, align, baseline, fontSize }, this.context); + this.context.drawRectangle(rc.x, rc.y, rc.width, rc.height, PROPS_OUTLINE_DEBUG_HOVER); return; } case BlockType.Image: { - const { dx, dy } = block.props; - this.context.drawRectangle(0, 0, dx, dy, propsOutline); + const { dx, dy } = bl.props; + this.context.drawRectangle(0, 0, dx, dy, PROPS_OUTLINE_DEBUG_HOVER); return; } case BlockType.Arc: { - const { radius, startAngle, endAngle } = block.props; - this.context.drawArc(0, 0, radius, startAngle, endAngle, { stroke: '#ff00ff', strokeWidth: 3 }); + const { radius, startAngle, endAngle } = bl.props; + this.context.drawArc(0, 0, radius, startAngle, endAngle, { stroke: PROPS_OUTLINE_DEBUG_HOVER.stroke, strokeWidth: PROPS_OUTLINE_DEBUG_HOVER.strokeWidth }); return; } case BlockType.Path: { - const { pathData } = block.props; - this.context.drawPath(pathData, propsOutline); + const { pathData } = bl.props; + this.context.drawPath(pathData, PROPS_OUTLINE_DEBUG_HOVER); return; } case BlockType.Group: case BlockType.Layer: case BlockType.Portal: - default: + case BlockType.ContentSized: { + const cachedRc = this.hitTestLayoutCache.mpbl_rc.get(bl); + if (cachedRc) { + this.context.drawRectangle(cachedRc.x, cachedRc.y, cachedRc.width, cachedRc.height, PROPS_OUTLINE_DEBUG_HOVER); + } return; + } + default: { + const blockCustom = bl as unknown as CustomBlockDescriptor; + const handlers = getBlockTypeHandlers(blockCustom.type); + const rc = handlers?.getDebugOutlineBounds?.(blockCustom, { context: this.context }); + if (rc) { + this.context.drawRectangle(rc.x, rc.y, rc.width, rc.height, PROPS_OUTLINE_DEBUG_HOVER); + } + } } } - private renderRectangle(block: BlockOfType): void { - const { props } = block; + private renderRectangle(bl: BlockOfType): void { + const { props } = bl; const { dx, dy } = props; this.context.drawRectangle(0, 0, dx, dy, props); } - private renderCircle(block: BlockOfType): void { - const { props } = block; + private renderCircle(bl: BlockOfType): void { + const { props } = bl; const { radius } = props; this.context.drawCircle(0, 0, radius, props); } - private renderEllipse(block: BlockOfType): void { - const { props } = block; + private renderEllipse(bl: BlockOfType): void { + const { props } = bl; const { radiusX, radiusY } = props; this.context.drawEllipse(0, 0, radiusX, radiusY, props); } - private renderPath(block: BlockOfType): void { - const { props } = block; + private renderPath(bl: BlockOfType): void { + const { props } = bl; const { pathData } = props; this.context.drawPath(pathData, props); } - private renderLine(block: BlockOfType): void { - const { props } = block; + private renderLine(bl: BlockOfType): void { + const { props } = bl; const { x1, y1, x2, y2 } = props; this.context.drawLine(x1, y1, x2, y2, props); } - private renderText(block: BlockOfType): void { - const { props } = block; - const { text, fontSize: duFont, align, baseline, dx: dxMax, lineHeight: lineHeightProp } = props; - - let textWidth: number; - let textHeight: number; - let ascent: number; - - if (this.context.measureText) { - const metrics = this.context.measureText(text, props); - textWidth = metrics.width; - textHeight = metrics.height; - ascent = metrics.ascent; - } else { - const fontSize = duFont ?? 16; - const duLineHeight = lineHeightProp ?? fontSize * 1.4; - if (dxMax !== undefined) { - const singleLineWidth = text.length * fontSize * 0.6; - const lineCount = Math.max(1, Math.ceil(singleLineWidth / dxMax)); - textWidth = Math.min(singleLineWidth, dxMax); - textHeight = lineCount * duLineHeight; - } else { - textWidth = text.length * fontSize * 0.6; - textHeight = fontSize; - } - ascent = fontSize; - } - - let xOffset = 0; - if (align === 'center') { - xOffset = -textWidth / 2; - } else if (align === 'right' || align === 'end') { - xOffset = -textWidth; - } - - let yOffset = -ascent; - if (baseline === 'top' || baseline === 'hanging') { - yOffset = 0; - } else if (baseline === 'middle') { - yOffset = -textHeight / 2; - } else if (baseline === 'bottom') { - yOffset = -textHeight; - } - - this.hitTestLayoutCache.boundsByBlock.set(block, { - x: xOffset, - y: yOffset, - width: textWidth, - height: textHeight - }); + private renderText(bl: BlockOfType): void { + const { props } = bl; + const { text } = props; + const rc = getTextBlockRc(text, props, this.context); + this.hitTestLayoutCache.mpbl_rc.set(bl, rc); this.context.drawText(text, 0, 0, props); } - private renderTexta(block: BlockOfType): void { - const { props } = block; - const { - texta: attributedText, - align, - baseline, - fill: fillDefault, - stroke: strokeDefault, - strokeWidth: duStrokeWidthDefault, - font: fontDefault, - fontSize: duFontSizeDefault, - lineHeight: duLineHeightDefault, - dx: duDx - } = props; - - const runs = getRgRenderBridgeRun(attributedText); - if (runs.length === 0) return; - - type StyleEntryLike = { - fontFamily?: string; - fontSize?: number; - fontWeight?: string; - fontStyle?: string; - lineHeight?: number; - fill?: string; - background?: string; - stroke?: string; - opacity?: number; - }; - - type Segment = { - text: string; - style: StyleEntryLike; - }; - - type SegmentMetrics = { - text: string; - style: StyleEntryLike; - font: string | undefined; - width: number; - ascent: number; - descent: number; - fontSize: number; - lineHeight: number; - }; - - const mpStyleById = attributedText.mpId_StyleEntry as Record; - const styleDefault = mpStyleById[attributedText.idStyleDefault] ?? {}; - - const getFontSize = (style: StyleEntryLike): number => { - return style.fontSize ?? duFontSizeDefault ?? styleDefault.fontSize ?? 16; - }; - - const getLineHeight = (style: StyleEntryLike): number => { - const duFont = getFontSize(style); - return style.lineHeight ?? duLineHeightDefault ?? styleDefault.lineHeight ?? duFont * 1.4; - }; - - const getFont = (style: StyleEntryLike): string | undefined => { - if (fontDefault && !style.fontFamily && !style.fontWeight && !style.fontStyle && style.fontSize === undefined) { - return fontDefault; - } - - const fontFamily = style.fontFamily ?? styleDefault.fontFamily; - if (!fontFamily) return undefined; - - const fontStyle = style.fontStyle ?? styleDefault.fontStyle ?? 'normal'; - const fontWeight = style.fontWeight ?? styleDefault.fontWeight ?? 'normal'; - const fontSize = getFontSize(style); - return `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`; - }; - - const measureSegment = (segment: Segment): SegmentMetrics => { - const fontSize = getFontSize(segment.style); - const font = getFont(segment.style); - const textMeasureProps = font ? { font } : { fontSize }; - const metrics = this.context.measureText - ? this.context.measureText(segment.text, textMeasureProps) - : { width: segment.text.length * fontSize * 0.6, height: fontSize, ascent: fontSize, descent: 0 }; - return { - text: segment.text, - style: segment.style, - font, - width: metrics.width, - ascent: metrics.ascent, - descent: metrics.descent, - fontSize, - lineHeight: getLineHeight(segment.style) - }; - }; - - // Split runs by explicit newlines while preserving style ids. - 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) { - lineSegments.push([]); - } - } - } - - // Word-wrap logical lines if dx is set. - let lineMetrics: SegmentMetrics[][]; - if (duDx !== undefined) { - lineMetrics = []; - for (const segs of lineSegments) { - // Tokenize each segment into space-delimited atoms (word + trailing space) - const atoms: SegmentMetrics[] = []; - for (const seg of segs) { - const parts = seg.text.split(' '); - for (let pi = 0; pi < parts.length; pi++) { - const t = pi < parts.length - 1 ? parts[pi] + ' ' : parts[pi]; - if (t.length > 0) atoms.push(measureSegment({ text: t, style: seg.style })); - } - } - // Greedy pack atoms into visual lines - const vLines: SegmentMetrics[][] = [[]]; - let xCur = 0; - for (const atom of atoms) { - if (xCur + atom.width > duDx && vLines[vLines.length - 1].length > 0) { - vLines.push([]); - xCur = 0; - } - vLines[vLines.length - 1].push(atom); - xCur += atom.width; - } - for (const vl of vLines) lineMetrics.push(vl); - } - } else { - lineMetrics = lineSegments.map((segments) => segments.map(measureSegment)); - } - - const lineWidths = lineMetrics.map((line) => line.reduce((sum, seg) => sum + seg.width, 0)); - const lineHeights = lineMetrics.map((line) => { - if (line.length === 0) { - return duLineHeightDefault ?? duFontSizeDefault ?? styleDefault.fontSize ?? 16; - } - return Math.max(...line.map((seg) => seg.lineHeight)); - }); - - const lineAscents = lineMetrics.map((line, i) => { - if (line.length === 0) { - const fontSize = duFontSizeDefault ?? styleDefault.fontSize ?? 16; - return fontSize; - } - return Math.max(...line.map((seg) => seg.ascent), lineHeights[i] * 0.7); - }); - - const totalHeight = lineHeights.reduce((sum, h) => sum + h, 0); - const firstAscent = lineAscents[0] ?? (duFontSizeDefault ?? styleDefault.fontSize ?? 16); - - let yLineBaseline = 0; - if (baseline === 'top') { - yLineBaseline = firstAscent; - } else if (baseline === 'middle') { - yLineBaseline = -totalHeight / 2 + firstAscent; - } else if (baseline === 'bottom') { - yLineBaseline = -totalHeight + firstAscent; - } else if (baseline === 'hanging') { - yLineBaseline = firstAscent * 0.8; - } - - const getLineStartX = (lineWidth: number): number => { - if (align === 'center') { - return -lineWidth / 2; - } - if (align === 'right' || align === 'end') { - return -lineWidth; - } - return 0; - }; - - const yTop = yLineBaseline - 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); - } - } - - this.hitTestLayoutCache.boundsByBlock.set(block, { - 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]; - const lineWidth = lineWidths[i] ?? 0; - - let xRun = getLineStartX(lineWidth); - - for (const seg of line) { - const { style } = seg; - const fill = style.fill - ?? (typeof fillDefault === 'string' ? fillDefault : undefined) - ?? styleDefault.fill; - const background = style.background; - const stroke = style.stroke - ?? (typeof strokeDefault === 'string' ? strokeDefault : undefined) - ?? styleDefault.stroke; - const opacity = style.opacity ?? 1; - - if (!fill && !stroke && !background) { - xRun += seg.width; - continue; - } - - this.context.save(); - this.context.setOpacity(this.context.opacity * opacity); - - if (background) { - const bgHeight = seg.ascent + seg.descent; - this.context.drawRectangle(xRun, yLineBaseline - seg.ascent, seg.width, bgHeight, { fill: background }); - } - - this.context.drawText(seg.text, xRun, yLineBaseline, { - font: seg.font, - fontSize: seg.fontSize, - fill, - stroke, - strokeWidth: duStrokeWidthDefault, - align: 'left', - baseline: 'alphabetic' - }); - this.context.restore(); - - xRun += seg.width; - } - - yLineBaseline += lineHeights[i] ?? 0; - } - } - - private renderImage(block: BlockOfType): void { - const { props } = block; + private renderImage(bl: BlockOfType): void { + const { props } = bl; const { src, dx, dy } = props; const img = typeof src === 'string' ? this.getCachedImage(src) : src; @@ -1010,8 +634,8 @@ export class ImmediateRenderer { return img; } - private renderArc(block: BlockOfType): void { - const { props } = block; + private renderArc(bl: BlockOfType): void { + const { props } = bl; const { radius, startAngle, endAngle } = props; this.context.drawArc(0, 0, radius, startAngle, endAngle, props); } @@ -1021,7 +645,7 @@ export class ImmediateRenderer { // Render portals in collection order (first collected = bottom, last = top) for (const { block, transform } of this.portalBlocks) { - if (!block.children) continue; + if (!block.rgblChildren) continue; this.context.save(); this.context.transformStack.save(); @@ -1037,17 +661,17 @@ export class ImmediateRenderer { // Apply to canvas context (with pixelRatio) if (this.pixelRatio !== 1) { - const renderTransform = Matrix2D.identity() + const xfRender = Matrix2D.identity() .scaleXY(this.pixelRatio, this.pixelRatio) .multiply(transform); - this.context.applyTransform(renderTransform); + this.context.applyTransform(xfRender); } else { this.context.applyTransform(transform); } // Now render portal children - they will apply their own transforms relative to this - for (const child of block.children) { - this.renderBlock(child); + for (const blChild of block.rgblChildren) { + this.renderBlock(blChild); } this.context.transformStack.restore(); @@ -1067,44 +691,45 @@ export class ImmediateRenderer { const { duPadding, duPaddingX, fontSize, fontFamily, colBg, colBorder, colText, borderWidth, borderRadius, duOffsetY, duMaxDistance, duMaxWidth } = TOOLTIP_DEFAULTS; - // Normalize: convert string content to a text block - const lineHeight = fontSize * 1.4; let contentBlock: Block; let dxContent: number; let dyContent: number; - + if (typeof content === 'string') { - const lines = content.split('\n'); - const textProps = { fontSize, font: `${fontSize}px ${fontFamily}` }; - - // Measure each line using the canvas context for exact widths - const lineWidths = lines.map(line => - this.context.measureText - ? this.context.measureText(line, textProps).width - : line.length * fontSize * 0.6 - ); + // Normalize: convert string content to a text block + const rgtextLines = content.split('\n'); + const textProps = { + fill: colText, + fontSize, + font: `${fontSize}px ${fontFamily}`, + baseline: 'top' as const + }; + const rgrcLines = rgtextLines.map((line) => getTextBlockRc(line, textProps, this.context)); + let yCursor = 0; + const rgblLines = rgtextLines.map((line, i) => { + const rcLine = rgrcLines[i]!; + const blLine = text({ + text: line, + y: yCursor, + ...textProps + }); + yCursor += rcLine.height; + return blLine; + }); + dxContent = Math.min( duMaxWidth - duPaddingX * 2, - Math.max(...lineWidths) + Math.max(...rgrcLines.map((rcLine) => rcLine.width)) ); - dyContent = lines.length * lineHeight; + dyContent = yCursor; - contentBlock = group({}, lines.map((line, i) => - text({ - text: line, - y: i * lineHeight, - fill: colText, - fontSize, - font: `${fontSize}px ${fontFamily}`, - baseline: 'top' as const - }) - )); + contentBlock = group({}, rgblLines); } else { contentBlock = content; // Estimate block content size via bounding box or fallback - const bounds = HitTester.getBounds(content); - dxContent = bounds ? bounds.width : duMaxWidth - duPaddingX * 2; - dyContent = bounds ? bounds.height : 40; + const rc = getBlockBounds(content); + dxContent = rc ? rc.width : duMaxWidth - duPaddingX * 2; + dyContent = rc ? rc.height : 40; } // Compute tooltip frame dimensions diff --git a/packages/core/src/core/renderer-webgl.ts b/packages/core/src/core/renderer-webgl.ts index f39b9de..ac3b18e 100644 --- a/packages/core/src/core/renderer-webgl.ts +++ b/packages/core/src/core/renderer-webgl.ts @@ -573,7 +573,7 @@ export class WebGLRenderer extends Renderer { fill, stroke, dx: dxMax, - lineHeight: lineHeightProp + dyLineHeight: lineHeightProp } = props; const font = fontProp || `${duFont ?? 16}px sans-serif`; const align = alignProp || 'left'; @@ -719,8 +719,8 @@ export class WebGLRenderer extends Renderer { } private renderBlock(block: Block, parentTransform: Matrix2D, parentOpacity: number): void { - const { props, type, children } = block; - const { visible, opacity = 1 } = props; + const { props, type, rgblChildren: children } = block; + const { fVisible: visible, opacity = 1 } = props; if (visible === false) return; if (this.enableCulling) { diff --git a/packages/core/src/core/selection-types.ts b/packages/core/src/core/selection-types.ts new file mode 100644 index 0000000..d3ccb17 --- /dev/null +++ b/packages/core/src/core/selection-types.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 François Rouaix + +// Text selection and layout types + +import type { Rc } from './types.ts'; + +/** Represents a single text selection or caret position. */ +export interface Selection { + /** ID of the text/texta block this selection applies to. */ + blockId: string; + /** Character position for the anchor (start of selection). */ + anchor: number; + /** Character position for the focus (end of selection). For caret, anchor === focus. */ + focus: number; + /** Optional user ID for collaborative multi-user selections. */ + userId?: string; + /** Optional color for rendering this selection (useful for multi-user scenarios). */ + color?: string; +} + +/** Layout information for a text block. */ +export interface TextLayout { + /** Character-indexed positions for hit testing. */ + rgrcCharacterBounds: Rc[]; + /** Overall bounding box. */ + rcCharacterBounds: Rc; + /** Line information (for multi-line text). */ + lines: TextLine[]; +} + +/** Information about a single line of text. */ +export interface TextLine { + /** Character index where this line starts. */ + startIndex: number; + /** Character index where this line ends (exclusive). */ + endIndex: number; + /** Width of the line in pixels. */ + width: number; + /** Height of the line in pixels. */ + height: number; + /** Ascent (baseline to top). */ + ascent: number; + /** Descent (baseline to bottom). */ + descent: number; +} diff --git a/packages/core/src/core/text-layout.ts b/packages/core/src/core/text-layout.ts new file mode 100644 index 0000000..c9333c4 --- /dev/null +++ b/packages/core/src/core/text-layout.ts @@ -0,0 +1,522 @@ +// Copyright (c) 2026 François Rouaix + +// Text layout utilities extracted from renderer for reuse in selection management. +// These functions are used by both the renderer (for display) and selection system (for hit-testing). + +import type { Rc, TextMeasure, TextProps } from './types.ts'; +import { DU_FONTSIZE_DEFAULT, SF_TEXT_ADVANCE_APPROX_DEFAULT, SF_TEXT_ASCENT_DEFAULT, SF_TEXT_DESCENT_DEFAULT, SF_TEXT_LINE_HEIGHT_DEFAULT, type RenderContext } from './context.ts'; + +const mpstFont_mpstGlyphWidth = new Map>(); +const mpstFont_mpstTextPrefixAdvance = new Map>(); +const mpstFont_mpstTextMeasure = new Map>(); + +const CACHES_MAX_FONTS = 64; +const CACHES_MAX_GLYPHS_PER_FONT = 512; +const CACHES_MAX_PREFIX_PER_FONT = 256; +const CACHES_MAX_MEASURE_PER_FONT = 512; + +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 getOrCreateGlyphCacheByFont(stFont: string): Map { + const existing = mpstFont_mpstGlyphWidth.get(stFont); + if (existing) { + touchMapKey(mpstFont_mpstGlyphWidth, stFont); + return existing; + } + const created = new Map(); + setWithLruTouch(mpstFont_mpstGlyphWidth, stFont, created, CACHES_MAX_FONTS); + return created; +} + +function getOrCreatePrefixCacheByFont(stFont: string): Map { + const existing = mpstFont_mpstTextPrefixAdvance.get(stFont); + if (existing) { + touchMapKey(mpstFont_mpstTextPrefixAdvance, stFont); + return existing; + } + const created = new Map(); + setWithLruTouch(mpstFont_mpstTextPrefixAdvance, stFont, created, CACHES_MAX_FONTS); + return created; +} + +function getOrCreateMeasureCacheByFont(stFont: string): Map { + const existing = mpstFont_mpstTextMeasure.get(stFont); + if (existing) { + touchMapKey(mpstFont_mpstTextMeasure, stFont); + return existing; + } + const created = new Map(); + setWithLruTouch(mpstFont_mpstTextMeasure, stFont, created, CACHES_MAX_FONTS); + return created; +} + +export function clearTextLayoutCaches(): void { + mpstFont_mpstGlyphWidth.clear(); + mpstFont_mpstTextPrefixAdvance.clear(); + mpstFont_mpstTextMeasure.clear(); +} + +export function getTextLayoutCacheStats(): { + fontsInGlyphCache: number; + fontsInPrefixCache: number; + fontsInMeasureCache: number; + glyphEntries: number; + prefixEntries: number; + measureEntries: number; +} { + let glyphEntries = 0; + for (const bucket of mpstFont_mpstGlyphWidth.values()) { + glyphEntries += bucket.size; + } + + let prefixEntries = 0; + for (const bucket of mpstFont_mpstTextPrefixAdvance.values()) { + prefixEntries += bucket.size; + } + + let measureEntries = 0; + for (const bucket of mpstFont_mpstTextMeasure.values()) { + measureEntries += bucket.size; + } + + return { + fontsInGlyphCache: mpstFont_mpstGlyphWidth.size, + fontsInPrefixCache: mpstFont_mpstTextPrefixAdvance.size, + fontsInMeasureCache: mpstFont_mpstTextMeasure.size, + glyphEntries, + prefixEntries, + measureEntries + }; +} + +function stFontCacheKey(props: Partial & { font?: string }): string { + return props.font ?? `${props.fontSize ?? DU_FONTSIZE_DEFAULT}px sans-serif`; +} + +function propsUnwrappedForMeasure(props: Partial & { font?: string }): Partial & { font?: string } { + return { + ...props, + dx: undefined, + dy: undefined + }; +} + +function getCachedGlyphWidth( + stGlyph: string, + props: Partial & { font?: string }, + context?: RenderContext +): number { + const stFont = stFontCacheKey(props); + const mpstGlyphWidth = getOrCreateGlyphCacheByFont(stFont); + + const widthCached = mpstGlyphWidth.get(stGlyph); + if (widthCached !== undefined) { + touchMapKey(mpstGlyphWidth, stGlyph); + return widthCached; + } + + const widthMeasured = measureText(stGlyph, propsUnwrappedForMeasure(props), context).width; + setWithLruTouch(mpstGlyphWidth, stGlyph, widthMeasured, CACHES_MAX_GLYPHS_PER_FONT); + return widthMeasured; +} + +function getCachedPrefixAdvance( + text: string, + props: Partial & { font?: string }, + context?: RenderContext +): number[] { + const stFont = stFontCacheKey(props); + const mpstTextPrefixAdvance = getOrCreatePrefixCacheByFont(stFont); + + const prefixCached = mpstTextPrefixAdvance.get(text); + if (prefixCached) { + touchMapKey(mpstTextPrefixAdvance, text); + return prefixCached; + } + + const rgdxPrefix = new Array(text.length + 1).fill(0); + for (let i = 0; i < text.length; i++) { + rgdxPrefix[i + 1] = rgdxPrefix[i] + getCachedGlyphWidth(text[i] ?? '', props, context); + } + + setWithLruTouch(mpstTextPrefixAdvance, text, rgdxPrefix, CACHES_MAX_PREFIX_PER_FONT); + return rgdxPrefix; +} + +function getAdvanceScaleFactor(measuredWidth: number, rgdxPrefix: number[]): number { + const dxAdvance = rgdxPrefix[rgdxPrefix.length - 1] ?? 0; + if (dxAdvance <= 0) { + return 1; + } + return measuredWidth / dxAdvance; +} + +function findCharIndexByAdvance(rgdxPrefix: number[], xAdvance: number): number { + let iMin = 0; + let iMax = Math.max(0, rgdxPrefix.length - 2); + while (iMin < iMax) { + const iMid = Math.floor((iMin + iMax) / 2); + const xMidEnd = rgdxPrefix[iMid + 1] ?? 0; + if (xMidEnd <= xAdvance) { + iMin = iMid + 1; + } else { + iMax = iMid; + } + } + return iMin; +} + +/** + * Measure text metrics using the provided render context. + * Falls back to approximation if context doesn't support measureText. + */ +export function measureText(text: string, props: Partial & { font?: string }, context?: RenderContext): TextMeasure { + if (context?.measureText && text.length > 0) { + const stFont = stFontCacheKey(props); + const mpstTextMeasure = getOrCreateMeasureCacheByFont(stFont); + const tmCached = mpstTextMeasure.get(text); + if (tmCached) { + touchMapKey(mpstTextMeasure, text); + return tmCached; + } + + const tmMeasured = context.measureText(text, props); + setWithLruTouch(mpstTextMeasure, text, tmMeasured, CACHES_MAX_MEASURE_PER_FONT); + return tmMeasured; + } + + const fontSize = props.fontSize ?? DU_FONTSIZE_DEFAULT; + return { + width: text.length * fontSize * SF_TEXT_ADVANCE_APPROX_DEFAULT, + height: fontSize, + ascent: fontSize * SF_TEXT_ASCENT_DEFAULT, + descent: fontSize * SF_TEXT_DESCENT_DEFAULT + }; +} + +/** + * Compute a text block's local bounding box for rendering and hit-test caches. + * The returned bounds are in block-local coordinates and include alignment/baseline offsets. + */ +export function getTextBlockRc( + text: string, + props: Partial & { font?: string }, + context?: RenderContext +): Rc { + const tm = measureText(text, props, context); + const duFontSize = props.fontSize ?? DU_FONTSIZE_DEFAULT; + const duLineHeight = props.dyLineHeight ?? duFontSize * SF_TEXT_LINE_HEIGHT_DEFAULT; + + let dxText = tm.width; + let dyText = tm.height; + if (props.dx !== undefined) { + const lineCount = Math.max(1, Math.ceil(dxText / props.dx)); + dxText = Math.min(dxText, props.dx); + dyText = lineCount * duLineHeight; + } + + const { xOffset, yOffset } = calculateTextOffset( + dxText, + dyText, + tm.ascent, + props.align, + props.baseline + ); + + return { + x: xOffset, + y: yOffset, + width: dxText, + height: dyText + }; +} + +/** + * Calculate text offset based on alignment and baseline. + */ +export function calculateTextOffset( + textWidth: number, + textHeight: number, + ascent: number, + align?: 'left' | 'center' | 'right' | 'start' | 'end', + baseline?: 'top' | 'middle' | 'bottom' | 'alphabetic' | 'hanging' +): { xOffset: number; yOffset: number } { + let xOffset = 0; + if (align === 'center') { + xOffset = -textWidth / 2; + } else if (align === 'right' || align === 'end') { + xOffset = -textWidth; + } + + let yOffset = -ascent; + if (baseline === 'top' || baseline === 'hanging') { + yOffset = 0; + } else if (baseline === 'middle') { + yOffset = -textHeight / 2; + } else if (baseline === 'bottom') { + yOffset = -textHeight; + } + + return { xOffset, yOffset }; +} + +/** + * Get character bounds for a single character in a text string. + * Returns the bounding box for the character at the given index. + * Uses cached per-glyph advances for the active font. + */ +export function getCharacterBounds( + text: string, + charIndex: number, + props: Partial & { font?: string }, + context?: RenderContext +): Rc | null { + if (text.length === 0) { + return null; + } + + if (charIndex < 0 || charIndex >= text.length) { + return null; + } + + const tm = measureText(text, props, context); + const { xOffset, yOffset } = calculateTextOffset( + tm.width, + tm.height, + tm.ascent, + props.align, + props.baseline + ); + + const rgdxPrefix = getCachedPrefixAdvance(text, props, context); + const sfAdvance = getAdvanceScaleFactor(tm.width, rgdxPrefix); + const xStart = (rgdxPrefix[charIndex] ?? 0) * sfAdvance; + const xEnd = (rgdxPrefix[charIndex + 1] ?? (rgdxPrefix[charIndex] ?? 0)) * sfAdvance; + + return { + x: xOffset + xStart, + y: yOffset, + width: Math.max(0, xEnd - xStart), + height: tm.height + }; +} + +/** + * Hit-test to find which character is at the given coordinates. + * Returns the character index, or null if no hit. + */ +export function hitTestCharacter( + text: string, + x: number, + y: number, + props: Partial & { font?: string }, + context?: RenderContext +): number | null { + if (text.length === 0) { + return null; + } + + const metrics = measureText(text, props, context); + const { xOffset, yOffset } = calculateTextOffset( + metrics.width, + metrics.height, + metrics.ascent, + props.align, + props.baseline + ); + + // Check if hit is within vertical bounds + if (y < yOffset || y > yOffset + metrics.height) { + return null; + } + + // Find character index based on horizontal position + const relativeX = x - xOffset; + if (relativeX < 0) { + return 0; + } + if (relativeX > metrics.width) { + return text.length; + } + + const rgdxPrefix = getCachedPrefixAdvance(text, props, context); + const sfAdvance = getAdvanceScaleFactor(metrics.width, rgdxPrefix); + const xAdvance = sfAdvance > 0 ? relativeX / sfAdvance : relativeX; + let charIndex = findCharIndexByAdvance(rgdxPrefix, xAdvance); + charIndex = Math.max(0, Math.min(charIndex, text.length - 1)); + + return charIndex; +} + +/** + * Generate per-character bounds for a text block using renderer-compatible wrapping/alignment rules. + * Bounds are returned in the text block's local coordinates (before block transforms). + */ +export function layoutTextCharacterBounds( + text: string, + props: Partial & { font?: string }, + context?: RenderContext +): Rc[] { + if (text.length === 0) { + return []; + } + + const fontSize = props.fontSize ?? DU_FONTSIZE_DEFAULT; + const metrics = measureText(text, props, context); + const lineHeight = props.dyLineHeight ?? fontSize * SF_TEXT_LINE_HEIGHT_DEFAULT; + const propsUnwrapped = propsUnwrappedForMeasure(props); + const measureAdvanceWidth = (value: string): number => { + const rgdxPrefix = getCachedPrefixAdvance(value, propsUnwrapped, context); + return rgdxPrefix[rgdxPrefix.length - 1] ?? 0; + }; + const bounds: Array = new Array(text.length).fill(null); + + const getLineStartX = (lineWidth: number): number => { + if (props.align === 'center') { + return -lineWidth / 2; + } + if (props.align === 'right' || props.align === 'end') { + return -lineWidth; + } + return 0; + }; + + if (props.dx === undefined) { + const rgdxPrefix = getCachedPrefixAdvance(text, propsUnwrapped, context); + const sfAdvance = getAdvanceScaleFactor(metrics.width, rgdxPrefix); + const { xOffset, yOffset } = calculateTextOffset( + metrics.width, + metrics.height, + metrics.ascent, + props.align, + props.baseline + ); + for (let i = 0; i < text.length; i++) { + const widthBefore = (rgdxPrefix[i] ?? 0) * sfAdvance; + const widthToEnd = (rgdxPrefix[i + 1] ?? (rgdxPrefix[i] ?? 0)) * sfAdvance; + bounds[i] = { + x: xOffset + widthBefore, + y: yOffset, + width: Math.max(0, widthToEnd - widthBefore), + height: metrics.height + }; + } + return bounds.map((entry) => entry ?? ({ + x: xOffset + metrics.width, + y: yOffset, + width: 0, + height: metrics.height + })); + } + + const dxMax = props.dx; + const words = text.split(/\s+/); + const lines: string[] = []; + if (words.length > 0) { + let currentLine = words[0] ?? ''; + for (let i = 1; i < words.length; i++) { + const candidate = `${currentLine} ${words[i]}`; + if (measureAdvanceWidth(candidate) > dxMax) { + lines.push(currentLine); + currentLine = words[i] ?? ''; + } else { + currentLine = candidate; + } + } + lines.push(currentLine); + } + + const { yOffset } = calculateTextOffset( + metrics.width, + metrics.height, + metrics.ascent, + props.align, + props.baseline + ); + + let normalizedOffset = 0; + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const lineText = lines[lineIndex] ?? ''; + const lineStart = normalizedOffset; + const lineEnd = lineStart + lineText.length; + const hasNextLine = lineIndex < lines.length - 1; + const rgdxPrefixLine = getCachedPrefixAdvance(lineText, propsUnwrapped, context); + const lineWidth = rgdxPrefixLine[rgdxPrefixLine.length - 1] ?? 0; + const xLineStart = getLineStartX(lineWidth); + const yLine = yOffset + lineIndex * lineHeight; + + for (let lineCharIndex = 0; lineCharIndex < lineText.length; lineCharIndex++) { + const charIndex = lineStart + lineCharIndex; + if (charIndex >= text.length) { + break; + } + const widthBefore = rgdxPrefixLine[lineCharIndex] ?? 0; + const widthToEnd = rgdxPrefixLine[lineCharIndex + 1] ?? (rgdxPrefixLine[lineCharIndex] ?? 0); + bounds[charIndex] = { + x: xLineStart + widthBefore, + y: yLine, + width: Math.max(0, widthToEnd - widthBefore), + height: lineHeight + }; + } + + if (hasNextLine && lineEnd < text.length) { + const xAtLineEnd = xLineStart + lineWidth; + const widthSpace = measureAdvanceWidth(' '); + bounds[lineEnd] = { + x: xAtLineEnd, + y: yLine, + width: widthSpace, + height: lineHeight + }; + } + + normalizedOffset = lineEnd + (hasNextLine ? 1 : 0); + } + + let fallbackX = getLineStartX(metrics.width) + metrics.width; + let fallbackY = yOffset; + let fallbackHeight = lineHeight; + + for (let i = 0; i < bounds.length; i++) { + const entry = bounds[i]; + if (entry) { + fallbackX = entry.x + entry.width; + fallbackY = entry.y; + fallbackHeight = entry.height; + continue; + } + bounds[i] = { + x: fallbackX, + y: fallbackY, + width: 0, + height: fallbackHeight + }; + } + + return bounds as Rc[]; +} diff --git a/packages/core/src/core/types.ts b/packages/core/src/core/types.ts index a8ebfe3..d8fba73 100644 --- a/packages/core/src/core/types.ts +++ b/packages/core/src/core/types.ts @@ -1,7 +1,6 @@ // Copyright (c) 2026 François Rouaix // Core type definitions for the block system -import type { AttributedTextValue } from 'texta/browser'; export type Color = string; // CSS color format export type BlendMode = 'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten'; @@ -92,8 +91,8 @@ export interface EventHandlers { export interface BaseBlockProps extends Transform, EventHandlers { opacity?: number; - visible?: boolean; - disableCulling?: boolean; + fVisible?: boolean; + fDisableCulling?: boolean; shadow?: ShadowProps; filter?: string; id?: string; @@ -129,7 +128,6 @@ export enum BlockType { Path = 'path', Line = 'line', Text = 'text', - Texta = 'texta', Image = 'image', Arc = 'arc', Group = 'group', @@ -138,6 +136,12 @@ export enum BlockType { ContentSized = 'content-sized' } +const mpBuiltInBlockTypes = new Set(Object.values(BlockType)); + +export function isBuiltInBlockType(type: string): type is BlockType { + return mpBuiltInBlockTypes.has(type); +} + export type BlockPropsByType = { [BlockType.Rectangle]: RectangleProps; [BlockType.Circle]: CircleProps; @@ -145,7 +149,6 @@ export type BlockPropsByType = { [BlockType.Path]: PathProps; [BlockType.Line]: LineProps; [BlockType.Text]: TextProps; - [BlockType.Texta]: TextaProps; [BlockType.Image]: ImageProps; [BlockType.Arc]: ArcProps; [BlockType.Group]: GroupProps; @@ -157,7 +160,7 @@ export type BlockPropsByType = { export type BlockForType = { type: T; props: BlockPropsByType[T]; - children?: Block[]; + rgblChildren?: Block[]; }; export type Block = { @@ -166,10 +169,16 @@ export type Block = { export type BlockOfType = Extract; +export type LineStyleProps = Pick; +export type StrokeStyleProps = Pick & LineStyleProps; +export type FillStrokeStyleProps = FillProps & StrokeStyleProps; + export interface RectangleProps extends BaseBlockProps, StrokeProps, FillProps, Rs { cornerRadius?: number; } +export type DrawRectStyleProps = Partial> & FillStrokeStyleProps; + export interface CircleProps extends BaseBlockProps, StrokeProps, FillProps { radius: number; } @@ -179,6 +188,17 @@ export interface EllipseProps extends BaseBlockProps, StrokeProps, FillProps { radiusY: number; } +export type FillRuleStyleProps = Partial>; +export type DrawCircleStyleProps = FillRuleStyleProps & FillStrokeStyleProps; + +export interface PathProps extends BaseBlockProps, StrokeProps, FillProps { + pathData: string; // SVG path format + closed?: boolean; + fillRule?: FillRule; +} + +export type DrawPathStyleProps = FillRuleStyleProps & FillStrokeStyleProps; + export interface LineProps extends BaseBlockProps, StrokeProps { x1: number; y1: number; @@ -187,6 +207,8 @@ export interface LineProps extends BaseBlockProps, StrokeProps { stroke: FillStyle; } +export type DrawLineStyleProps = Omit & Pick; + export interface TextProps extends BaseBlockProps, StrokeProps, FillProps { text: string; font?: string; @@ -198,41 +220,23 @@ export interface TextProps extends BaseBlockProps, StrokeProps, FillProps { /** When set (with dx), clip text vertically at this height. */ dy?: number; /** Line spacing in pixels. Defaults to fontSize * 1.4. */ - lineHeight?: number; -} - -export interface TextaProps extends BaseBlockProps { - texta: AttributedTextValue; - align?: 'left' | 'center' | 'right' | 'start' | 'end'; - baseline?: 'top' | 'middle' | 'bottom' | 'alphabetic' | 'hanging'; - /** Optional default fill CSS color string used when a style run does not define one. */ - fill?: Color; - /** Optional default stroke CSS color string used when a style run does not define one. */ - stroke?: Color; - /** Optional default stroke width used when a style run does not define one. */ - strokeWidth?: number; - /** Optional default font used when a style run does not define one. */ - font?: string; - /** Optional default font size used when a style run does not define one. */ - fontSize?: number; - /** Optional default line spacing in pixels. Defaults to fontSize * 1.4. */ - lineHeight?: number; - /** Optional max width in pixels for word-wrap. If omitted, no wrapping occurs. */ - dx?: number; + dyLineHeight?: number; } -export interface PathProps extends BaseBlockProps, StrokeProps, FillProps { - pathData: string; // SVG path format - closed?: boolean; - fillRule?: FillRule; +/** Text measurement metrics. */ +export interface TextMeasure { + /** Width of the text in pixels. */ + width: number; + /** Total height in pixels (ascent + descent). */ + height: number; + /** Ascent (distance from baseline to top) in pixels. */ + ascent: number; + /** Descent (distance from baseline to bottom) in pixels. */ + descent: number; } -export interface ArcProps extends BaseBlockProps, StrokeProps, FillProps { - radius: number; - startAngle: number; - endAngle: number; - fillRule?: FillRule; -} +export type TextMeasureProps = Partial>; +export type DrawTextStyleProps = Omit; export interface ImageProps extends BaseBlockProps, Rs { src: string | HTMLImageElement; @@ -243,6 +247,17 @@ export interface ImageProps extends BaseBlockProps, Rs { sh?: number; } +export type DrawImageStyleProps = Partial>; + +export interface ArcProps extends BaseBlockProps, StrokeProps, FillProps { + radius: number; + startAngle: number; + endAngle: number; + fillRule?: FillRule; +} + +export type DrawArcStyleProps = DrawPathStyleProps; + export interface GroupProps extends BaseBlockProps { clip?: boolean; } diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 4fb3b51..0556ce5 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -8,7 +8,7 @@ import { Matrix2D } from './transform.ts'; const EMPTY_SCENE: Block = { type: BlockType.Group, props: {}, - children: [] + rgblChildren: [] }; export interface PointerEventData { diff --git a/packages/core/src/hit-test.ts b/packages/core/src/hit-test.ts index 6ec632b..b241f93 100644 --- a/packages/core/src/hit-test.ts +++ b/packages/core/src/hit-test.ts @@ -4,6 +4,10 @@ import type { Block, Rc } from './core/types.ts'; import { BlockType } from './core/types.ts'; import { Matrix2D } from './transform.ts'; +import { getBlockTypeHandlers } from './core/block-registry.ts'; +import type { CustomBlockDescriptor } from './core/block-registry.ts'; +import { getTextBlockRc } from './core/text-layout.ts'; +import { getBlockTransform } from './core/bounds.ts'; export interface HitTestResult { block: Block; @@ -20,120 +24,96 @@ export interface HitTestResult { } export interface HitTestLayoutCache { - boundsByBlock: WeakMap; + mpbl_rc: WeakMap; } export class HitTester { // Test if a point hits a block, considering its transform static hitTest( - block: Block, - worldX: number, - worldY: number, - worldTransform: Matrix2D = Matrix2D.identity(), - ancestors: Block[] = [], + bl: Block, + xs: number, + ys: number, + xfWorld: Matrix2D = Matrix2D.identity(), + rgblAncestors: Block[] = [], layoutCache?: HitTestLayoutCache ): HitTestResult | null { - const { props, children } = block; - const { visible } = props; - if (visible === false) return null; + const { props, rgblChildren } = bl; + const { fVisible } = props; + if (fVisible === false) return null; // Calculate this block's world transform - const blockTransform = this.getBlockTransform(props); - const currentTransform = worldTransform.multiply(blockTransform); + const xfBlock = getBlockTransform(props); + const xfCur = xfWorld.multiply(xfBlock); // Transform world coordinates to local space - const inverse = currentTransform.invert(); + const inverse = xfCur.invert(); if (!inverse) return null; - const local = inverse.transformPoint(worldX, worldY); + const ptl = inverse.transformPoint(xs, ys); // Reject points outside clip region - const { clip, dx: dxClip, dy: dyClip } = props as any; + const { clip, dx: dxClip, dy: dyClip } = props as { clip?: boolean; dx?: number; dy?: number }; if (clip && dxClip !== undefined && dyClip !== undefined) { - if (local.x < 0 || local.x > dxClip || local.y < 0 || local.y > dyClip) { + if (ptl.x < 0 || ptl.x > dxClip || ptl.y < 0 || ptl.y > dyClip) { return null; } } // Test children first (reverse order for top-to-bottom) - if (children) { - const childAncestors = [...ancestors, block]; - for (let i = children.length - 1; i >= 0; i--) { - const childHit = this.hitTest( - children[i], - worldX, - worldY, - currentTransform, - childAncestors, + if (rgblChildren) { + const rgblAncestorsChild = [...rgblAncestors, bl]; + for (let i = rgblChildren.length - 1; i >= 0; i--) { + const blChildHit = this.hitTest( + rgblChildren[i], + xs, + ys, + xfCur, + rgblAncestorsChild, layoutCache ); - if (childHit) return childHit; + if (blChildHit) return blChildHit; } } // Test this block - if (this.hitTestShape(block, local.x, local.y, layoutCache)) { + if (this.hitTestShape(bl, ptl.x, ptl.y, layoutCache)) { return { - block, - ancestors, - xl: local.x, - yl: local.y, - xs: worldX, - ys: worldY + block: bl, + ancestors: rgblAncestors, + xl: ptl.x, + yl: ptl.y, + xs: xs, + ys: ys }; } return null; } - private static getBlockTransform(props: any): Matrix2D { - let transform = Matrix2D.identity(); - const { x, y, rotation, scaleX, scaleY, skewX, skewY } = props; - - if (x !== undefined || y !== undefined) { - transform = transform.translate(x ?? 0, y ?? 0); - } - if (rotation !== undefined) { - transform = transform.rotate(rotation); - } - if (scaleX !== undefined || scaleY !== undefined) { - transform = transform.scaleXY(scaleX ?? 1, scaleY ?? 1); - } - if (skewX !== undefined || skewY !== undefined) { - transform = transform.skewXY(skewX ?? 0, skewY ?? 0); - } - - return transform; - } - - private static hitTestShape(block: Block, x: number, y: number, layoutCache?: HitTestLayoutCache): boolean { - const xl = x; - const yl = y; - const { props } = block as { props: any }; - - switch (block.type) { + private static hitTestShape(bl: Block, xl: number, yl: number, layoutCache?: HitTestLayoutCache): boolean { + switch (bl.type) { case BlockType.Rectangle: { - const { dx, dy } = props; + const { dx, dy } = bl.props; return this.hitTestRectangle(xl, yl, dx, dy); } case BlockType.Circle: { - const { radius } = props; + const { radius } = bl.props; return this.hitTestCircle(xl, yl, radius); } case BlockType.Ellipse: { - const { radiusX, radiusY } = props; + const { radiusX, radiusY } = bl.props; return this.hitTestEllipse(xl, yl, radiusX, radiusY); } case BlockType.Line: { - const { x1, y1, x2, y2, strokeWidth } = props; + const { x1, y1, x2, y2, strokeWidth } = bl.props; return this.hitTestLine(xl, yl, x1, y1, x2, y2, strokeWidth ?? 1); } case BlockType.Text: { - const cachedBounds = layoutCache?.boundsByBlock.get(block); + const cachedBounds = layoutCache?.mpbl_rc.get(bl); if (cachedBounds) { return this.hitTestRectangle( xl - cachedBounds.x, @@ -143,103 +123,27 @@ export class HitTester { ); } - // Calculate text bounds accounting for baseline and alignment - const { fontSize: duFont, text: stText, align, baseline, dx: dxMax, lineHeight: lineHeightProp } = props; - const fontSize = duFont ?? 16; - const duLineHeight = lineHeightProp ?? fontSize * 1.4; - - let textWidth: number; - let height: number; - if (dxMax !== undefined) { - // Approximate wrapped line count - const singleLineWidth = stText.length * fontSize * 0.6; - const lineCount = Math.max(1, Math.ceil(singleLineWidth / dxMax)); - textWidth = Math.min(singleLineWidth, dxMax); - height = lineCount * duLineHeight; - } else { - textWidth = stText.length * fontSize * 0.6; // rough estimate - height = fontSize; - } - - const ascent = fontSize; // approximate - - // Calculate x offset based on alignment - let xOffset = 0; - if (align === 'center') { - xOffset = -textWidth / 2; - } else if (align === 'right' || align === 'end') { - xOffset = -textWidth; - } - - // Calculate y offset based on baseline - let yOffset = -ascent; // Default for 'alphabetic' baseline - if (baseline === 'top' || baseline === 'hanging') { - yOffset = 0; - } else if (baseline === 'middle') { - yOffset = -height / 2; - } else if (baseline === 'bottom') { - yOffset = -height; - } - - // Translate hit point to text box space - const testX = xl - xOffset; - const testY = yl - yOffset; - return this.hitTestRectangle(testX, testY, textWidth, height); - } - - case BlockType.Texta: { - const cachedBounds = layoutCache?.boundsByBlock.get(block); - if (cachedBounds) { - return this.hitTestRectangle( - xl - cachedBounds.x, - yl - cachedBounds.y, - cachedBounds.width, - cachedBounds.height - ); - } - - const { fontSize: duFont, texta: attributedText, align, baseline, lineHeight: lineHeightProp } = props; - const stText = attributedText.strText; - const fontSize = duFont ?? 16; - const duLineHeight = lineHeightProp ?? fontSize * 1.4; - - const lines = stText.split('\n'); - const textWidth = Math.max(...lines.map((line: string) => line.length * fontSize * 0.6), 0); - const height = Math.max(lines.length, 1) * duLineHeight; - const ascent = fontSize; - - let xOffset = 0; - if (align === 'center') { - xOffset = -textWidth / 2; - } else if (align === 'right' || align === 'end') { - xOffset = -textWidth; - } - - let yOffset = -ascent; - if (baseline === 'top' || baseline === 'hanging') { - yOffset = 0; - } else if (baseline === 'middle') { - yOffset = -height / 2; - } else if (baseline === 'bottom') { - yOffset = -height; - } - - const testX = xl - xOffset; - const testY = yl - yOffset; - return this.hitTestRectangle(testX, testY, textWidth, height); + const rc = getTextBlockRc(bl.props.text, bl.props); + return this.hitTestRectangle(xl - rc.x, yl - rc.y, rc.width, rc.height); } case BlockType.Arc: { - const { radius, startAngle, endAngle } = props; + const { radius, startAngle, endAngle } = bl.props; return this.hitTestArc(xl, yl, radius, startAngle, endAngle); } + case BlockType.Image: { + const { dx, dy } = bl.props; + return this.hitTestRectangle(xl, yl, dx, dy); + } + + case BlockType.Path: case BlockType.Group: case BlockType.Layer: case BlockType.Portal: case BlockType.ContentSized: { - const cachedBounds = layoutCache?.boundsByBlock.get(block); + const cachedBounds = layoutCache?.mpbl_rc.get(bl); if (cachedBounds) { return this.hitTestRectangle( xl - cachedBounds.x, @@ -253,8 +157,14 @@ export class HitTester { // Groups, layers, and portals don't have intrinsic shape, rely on children return false; - default: + default: { + const blCustom = bl as CustomBlockDescriptor; + const handlers = getBlockTypeHandlers(blCustom.type); + if (handlers?.hitTestShape) { + return handlers.hitTestShape(blCustom, xl, yl, { layoutCache }); + } return false; + } } } @@ -324,98 +234,4 @@ export class HitTester { return normalizedAngle >= normalizedStart && normalizedAngle <= normalizedEnd; } - // Get bounding box for a block in world coordinates - static getBounds(block: Block, worldTransform: Matrix2D = Matrix2D.identity()): Rc | null { - const blockTransform = this.getBlockTransform(block.props); - const currentTransform = worldTransform.multiply(blockTransform); - - const localBounds = this.getLocalBounds(block); - if (!localBounds) return null; - - // Transform all four corners to world space - const corners = [ - currentTransform.transformPoint(localBounds.x, localBounds.y), - currentTransform.transformPoint(localBounds.x + localBounds.width, localBounds.y), - currentTransform.transformPoint(localBounds.x, localBounds.y + localBounds.height), - currentTransform.transformPoint(localBounds.x + localBounds.width, localBounds.y + localBounds.height) - ]; - - const xcMin = Math.min(...corners.map(c => c.x)); - const xcMax = Math.max(...corners.map(c => c.x)); - const ycMin = Math.min(...corners.map(c => c.y)); - const ycMax = Math.max(...corners.map(c => c.y)); - - return { - x: xcMin, - y: ycMin, - width: xcMax - xcMin, - height: ycMax - ycMin - }; - } - - private static getLocalBounds(block: Block): Rc | null { - const { props } = block as { props: any }; - - switch (block.type) { - case BlockType.Rectangle: { - const { dx, dy } = props; - return { x: 0, y: 0, width: dx, height: dy }; - } - - case BlockType.Circle: { - const { radius } = props; - return { - x: -radius, - y: -radius, - width: radius * 2, - height: radius * 2 - }; - } - - case BlockType.Ellipse: { - const { radiusX, radiusY } = props; - return { - x: -radiusX, - y: -radiusY, - width: radiusX * 2, - height: radiusY * 2 - }; - } - - case BlockType.Line: { - const { x1, x2, y1, y2 } = props; - const xlMin = Math.min(x1, x2); - const xlMax = Math.max(x1, x2); - const ylMin = Math.min(y1, y2); - const ylMax = Math.max(y1, y2); - return { x: xlMin, y: ylMin, width: xlMax - xlMin, height: ylMax - ylMin }; - } - - case BlockType.Text: { - const { fontSize: duFont, text: stText, dx: dxMax, lineHeight: lineHeightProp } = props; - const fontSize = duFont ?? 16; - const duLineHeight = lineHeightProp ?? fontSize * 1.4; - const singleLineWidth = stText.length * fontSize * 0.6; - if (dxMax !== undefined) { - const lineCount = Math.max(1, Math.ceil(singleLineWidth / dxMax)); - return { x: 0, y: 0, width: Math.min(singleLineWidth, dxMax), height: lineCount * duLineHeight }; - } - return { x: 0, y: 0, width: singleLineWidth, height: fontSize }; - } - - case BlockType.Texta: { - const { fontSize: duFont, texta: attributedText, lineHeight: lineHeightProp } = props; - const stText = attributedText.strText; - const fontSize = duFont ?? 16; - const duLineHeight = lineHeightProp ?? fontSize * 1.4; - const lines = stText.split('\n'); - const width = Math.max(...lines.map((line: string) => line.length * fontSize * 0.6), 0); - const height = Math.max(lines.length, 1) * duLineHeight; - return { x: 0, y: 0, width, height }; - } - - default: - return null; - } - } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0361db1..4a6ebbc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,10 +4,17 @@ export * from './core/types.ts'; // Transform system -export { Matrix2D, TransformStack } from './transform.ts'; +export { Matrix2D, TransformStack, transformRc } from './transform.ts'; // Rendering context -export { Canvas2DContext } from './core/context.ts'; +export { + Canvas2DContext, + DU_FONTSIZE_DEFAULT, + SF_TEXT_ASCENT_DEFAULT, + SF_TEXT_DESCENT_DEFAULT, + SF_TEXT_ADVANCE_APPROX_DEFAULT, + SF_TEXT_LINE_HEIGHT_DEFAULT +} from './core/context.ts'; export type { RenderContext } from './core/context.ts'; // Immediate renderer @@ -16,7 +23,12 @@ export type { RendererConfig } from './core/renderer-immediate.ts'; // Performance export { PerformanceOptimizer, PerformanceMonitor } from './performance.ts'; -export type { Viewport } from './performance.ts'; +export type { + Viewport, + PerformanceHookMetrics, + PerformanceStatsHook, + PerformanceStatsSnapshot +} from './performance.ts'; // Event system export { EventManager } from './events.ts'; @@ -24,6 +36,32 @@ export type { PointerEventData, ActiveTooltip } from './events.ts'; export { HitTester } from './hit-test.ts'; export type { HitTestResult } from './hit-test.ts'; +// Selection system +export type { Selection, TextLayout, TextLine } from './core/selection-types.ts'; +export { + measureText, + calculateTextOffset, + getCharacterBounds, + hitTestCharacter, + layoutTextCharacterBounds, + clearTextLayoutCaches, + getTextLayoutCacheStats, + getTextBlockRc as getTextBlockBounds +} from './core/text-layout.ts'; +export { getBlockTransform, getBlockBounds } from './core/bounds.ts'; +export { + registerBlockType, + unregisterBlockType, + getBlockTypeHandlers +} from './core/block-registry.ts'; +export type { + BlockLayoutCache, + CustomBlockHandlers, + CustomBlockRenderApi, + CustomBlockHitTestApi, + CustomBlockDebugApi +} from './core/block-registry.ts'; + // Block factory functions export * from './core/blocks.ts'; diff --git a/packages/core/src/performance.ts b/packages/core/src/performance.ts index acbdf59..351e8e6 100644 --- a/packages/core/src/performance.ts +++ b/packages/core/src/performance.ts @@ -2,9 +2,22 @@ // Performance optimization utilities import type { Block, Rc } from './core/types.ts'; -import { HitTester } from './hit-test.ts'; +import { getBlockBounds } from './core/bounds.ts'; +import { getTextLayoutCacheStats } from './core/text-layout.ts'; import { Matrix2D } from './transform.ts'; +export type PerformanceHookMetrics = Record; +export type PerformanceStatsHook = () => PerformanceHookMetrics; + +export interface PerformanceStatsSnapshot { + blocksRendered: number; + blocksCulled: number; + renderTime: number; + fps: number; + averageFPS: number; + hooks: Record; +} + export interface Viewport { x: number; y: number; @@ -30,7 +43,7 @@ export class PerformanceOptimizer { worldTransform: Matrix2D = Matrix2D.identity() ): boolean { const { props } = block; - const { visible, disableCulling } = props; + const { fVisible: visible, fDisableCulling: disableCulling } = props; if (visible === false) return false; if (disableCulling) { @@ -38,7 +51,7 @@ export class PerformanceOptimizer { } // Get world bounds (getBounds will apply the block's transform) - const boundsWorld = HitTester.getBounds(block, worldTransform); + const boundsWorld = getBlockBounds(block, worldTransform); if (!boundsWorld) { // If we can't calculate bounds, assume visible return true; @@ -48,26 +61,6 @@ export class PerformanceOptimizer { return this.isInViewport(boundsWorld, viewport); } - private static getBlockTransform(props: any): Matrix2D { - let transform = Matrix2D.identity(); - const { x, y, rotation, scaleX, scaleY, skewX, skewY } = props; - - if (x !== undefined || y !== undefined) { - transform = transform.translate(x ?? 0, y ?? 0); - } - if (rotation !== undefined) { - transform = transform.rotate(rotation); - } - if (scaleX !== undefined || scaleY !== undefined) { - transform = transform.scaleXY(scaleX ?? 1, scaleY ?? 1); - } - if (skewX !== undefined || skewY !== undefined) { - transform = transform.skewXY(skewX ?? 0, skewY ?? 0); - } - - return transform; - } - // Object pooling for frequently allocated objects private static transformPool: Matrix2D[] = []; private static pointPool: { xc: number; yc: number }[] = []; @@ -107,10 +100,22 @@ export class PerformanceOptimizer { } export class PerformanceMonitor { + private static mpstHook_mpfnStats = new Map([ + ['textLayoutCache', () => getTextLayoutCacheStats()] + ]); + private frameCount = 0; private lastTime = performance.now(); private fpsHistory: number[] = []; + static registerStatsHook(stName: string, fnStats: PerformanceStatsHook): void { + this.mpstHook_mpfnStats.set(stName, fnStats); + } + + static unregisterStatsHook(stName: string): void { + this.mpstHook_mpfnStats.delete(stName); + } + update(): void { this.frameCount++; const currentTime = performance.now(); @@ -134,10 +139,22 @@ export class PerformanceMonitor { return Math.round(sum / this.fpsHistory.length); } - getStats() { + getStats(): PerformanceStatsSnapshot { + const mpstHookMetrics: Record = {}; + for (const [stName, fnStats] of PerformanceMonitor.mpstHook_mpfnStats.entries()) { + try { + mpstHookMetrics[stName] = fnStats(); + } catch { + mpstHookMetrics[stName] = { + fError: true + }; + } + } + return { ...PerformanceOptimizer.stats, - averageFPS: this.getAverageFPS() + averageFPS: this.getAverageFPS(), + hooks: mpstHookMetrics }; } } diff --git a/packages/core/src/transform.ts b/packages/core/src/transform.ts index 21a2e79..5931520 100644 --- a/packages/core/src/transform.ts +++ b/packages/core/src/transform.ts @@ -6,6 +6,8 @@ // | b d f | // | 0 0 1 | +import type { Rc } from './core/types.ts'; + export class Matrix2D { a: number; b: number; @@ -103,29 +105,29 @@ export class Matrix2D { } export class TransformStack { - private stack: Matrix2D[] = []; - private current: Matrix2D; + private rgxf: Matrix2D[] = []; + private xfCur: Matrix2D; constructor() { - this.current = Matrix2D.identity(); + this.xfCur = Matrix2D.identity(); } save(): void { - this.stack.push(this.current.clone()); + this.rgxf.push(this.xfCur.clone()); } restore(): void { - const m = this.stack.pop(); - if (m) this.current = m; + const m = this.rgxf.pop(); + if (m) this.xfCur = m; } reset(): void { - this.stack = []; - this.current = Matrix2D.identity(); + this.rgxf = []; + this.xfCur = Matrix2D.identity(); } getCurrent(): Matrix2D { - return this.current; + return this.xfCur; } apply(transform: { @@ -138,22 +140,43 @@ export class TransformStack { skewY?: number; }): void { if (transform.x !== undefined || transform.y !== undefined) { - this.current = this.current.translate(transform.x ?? 0, transform.y ?? 0); + this.xfCur = this.xfCur.translate(transform.x ?? 0, transform.y ?? 0); } if (transform.rotation !== undefined) { - this.current = this.current.rotate(transform.rotation); + this.xfCur = this.xfCur.rotate(transform.rotation); } if (transform.scaleX !== undefined || transform.scaleY !== undefined) { - this.current = this.current.scaleXY( + this.xfCur = this.xfCur.scaleXY( transform.scaleX ?? 1, transform.scaleY ?? 1 ); } if (transform.skewX !== undefined || transform.skewY !== undefined) { - this.current = this.current.skewXY( + this.xfCur = this.xfCur.skewXY( transform.skewX ?? 0, transform.skewY ?? 0 ); } } } + +export function transformRc(rc: Rc, xf: Matrix2D): Rc { + const corners = [ + xf.transformPoint(rc.x, rc.y), + xf.transformPoint(rc.x + rc.width, rc.y), + xf.transformPoint(rc.x, rc.y + rc.height), + xf.transformPoint(rc.x + rc.width, rc.y + rc.height) + ]; + + const xMin = Math.min(...corners.map((c) => c.x)); + const xMax = Math.max(...corners.map((c) => c.x)); + const yMin = Math.min(...corners.map((c) => c.y)); + const yMax = Math.max(...corners.map((c) => c.y)); + + return { + x: xMin, + y: yMin, + width: xMax - xMin, + height: yMax - yMin + }; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 6088614..f543bbd 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -4,11 +4,7 @@ "declaration": true, "outDir": "./dist", "rootDir": "./src", - "noEmit": false, - "baseUrl": ".", - "paths": { - "texta/browser": ["../texta/dist/browser.d.ts"] - } + "noEmit": false }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/packages/demo/basic.html b/packages/demo/basic.html index 426cd24..d5385e2 100644 --- a/packages/demo/basic.html +++ b/packages/demo/basic.html @@ -160,8 +160,8 @@

Vitrine Basic Blocks

const canvas = document.getElementById('canvas'); const renderer = new ImmediateRenderer({ canvas, - width: 980, - height: 860 + dx: 980, + dy: 860 }); const svgImage = new Image(); diff --git a/packages/demo/calendar.html b/packages/demo/calendar.html index fb87e3d..b189aa6 100644 --- a/packages/demo/calendar.html +++ b/packages/demo/calendar.html @@ -156,9 +156,9 @@

📅 Calendar Views Demo

// Initialize renderer const renderer = new ImmediateRenderer({ canvas, - width: 1360, - height: 800, - enableCulling: false + dx: 1360, + dy: 800, + fEnableCulling: false }); function render() { diff --git a/packages/demo/camera-controls-test.html b/packages/demo/camera-controls-test.html index 0b00f73..c97206c 100644 --- a/packages/demo/camera-controls-test.html +++ b/packages/demo/camera-controls-test.html @@ -103,10 +103,10 @@

🎯 Camera Controls Test - Click Detection

const renderer = new ImmediateRenderer({ canvas, - width: 800, - height: 600, - enableCameraControls: true, - debugHoverOutline: true + dx: 800, + dy: 600, + fEnableCameraControls: true, + fDebugHoverOutline: true }); let clickCounts = { diff --git a/packages/demo/camera-controls.html b/packages/demo/camera-controls.html index d41170c..4b43be0 100644 --- a/packages/demo/camera-controls.html +++ b/packages/demo/camera-controls.html @@ -113,9 +113,9 @@

Vitrine Canvas-Wide Camera Controls

const renderer = new ImmediateRenderer({ canvas, - width: 800, - height: 600, - enableCameraControls: true + dx: 800, + dy: 600, + fEnableCameraControls: true }); function updateStatus() { diff --git a/packages/demo/component-demo.html b/packages/demo/component-demo.html index 3f94eed..ab49e43 100644 --- a/packages/demo/component-demo.html +++ b/packages/demo/component-demo.html @@ -181,7 +181,7 @@

Raw Blocks

diff --git a/packages/demo/events.html b/packages/demo/events.html index 2a4505d..90e935b 100644 --- a/packages/demo/events.html +++ b/packages/demo/events.html @@ -79,8 +79,8 @@

Vitrine Interactive Events

const statusDiv = document.getElementById('status'); const renderer = new ImmediateRenderer({ canvas, - width: 800, - height: 600 + dx: 800, + dy: 600 }); // Application state diff --git a/packages/demo/gallery.html b/packages/demo/gallery.html index 4705fcf..33c9439 100644 --- a/packages/demo/gallery.html +++ b/packages/demo/gallery.html @@ -503,6 +503,14 @@

Performance

Render (ms) 0 +
+ Cache Fonts (G/P/M) + 0 / 0 / 0 +
+
+ Cache Entries (G/P/M) + 0 / 0 / 0 +
@@ -515,7 +523,7 @@

Performance

diff --git a/packages/demo/gallery.ts b/packages/demo/gallery.ts index 2279022..c3bdf33 100644 --- a/packages/demo/gallery.ts +++ b/packages/demo/gallery.ts @@ -206,11 +206,11 @@ function loadDemo(demo: GalleryDemo): void { const size = demo.size || { width: 800, height: 600 }; renderer = new ImmediateRenderer({ canvas, - width: size.width, - height: size.height, - enableCulling: demo.enableCulling !== false, - enableCameraControls: true, - debugHoverOutline + dx: size.width, + dy: size.height, + fEnableCulling: demo.enableCulling !== false, + fEnableCameraControls: true, + fDebugHoverOutline: debugHoverOutline }); syncDebugHoverButton(); @@ -252,6 +252,29 @@ function updateStats(): void { getRequiredElement('statBlocks').textContent = stats.blocksRendered.toLocaleString(); getRequiredElement('statCulled').textContent = stats.blocksCulled.toLocaleString(); getRequiredElement('statRenderTime').textContent = stats.renderTime.toFixed(2); + const textCache = stats.hooks && stats.hooks.textLayoutCache + ? stats.hooks.textLayoutCache + : null; + const glyphFonts = textCache && typeof textCache.fontsInGlyphCache === 'number' + ? textCache.fontsInGlyphCache + : 0; + const prefixFonts = textCache && typeof textCache.fontsInPrefixCache === 'number' + ? textCache.fontsInPrefixCache + : 0; + const measureFonts = textCache && typeof textCache.fontsInMeasureCache === 'number' + ? textCache.fontsInMeasureCache + : 0; + const glyphEntries = textCache && typeof textCache.glyphEntries === 'number' + ? textCache.glyphEntries + : 0; + const prefixEntries = textCache && typeof textCache.prefixEntries === 'number' + ? textCache.prefixEntries + : 0; + const measureEntries = textCache && typeof textCache.measureEntries === 'number' + ? textCache.measureEntries + : 0; + getRequiredElement('statCacheFonts').textContent = `${glyphFonts.toLocaleString()} / ${prefixFonts.toLocaleString()} / ${measureFonts.toLocaleString()}`; + getRequiredElement('statCacheEntries').textContent = `${glyphEntries.toLocaleString()} / ${prefixEntries.toLocaleString()} / ${measureEntries.toLocaleString()}`; } // UI Controls diff --git a/packages/demo/markdown.html b/packages/demo/markdown.html index 4753792..80dee4f 100644 --- a/packages/demo/markdown.html +++ b/packages/demo/markdown.html @@ -185,10 +185,10 @@ + + diff --git a/packages/demo/text-selection-minimal-scene.js b/packages/demo/text-selection-minimal-scene.js new file mode 100644 index 0000000..8570711 --- /dev/null +++ b/packages/demo/text-selection-minimal-scene.js @@ -0,0 +1,51 @@ +import { group, rectangle, text } from 'vitrine'; + +export const MINIMAL_TEST_TEXT = 'Click and drag to select'; +export const MINIMAL_TEXT_BLOCK_ID = 'testText'; + +export function buildMinimalScene() { + return group({}, [ + rectangle({ dx: 800, dy: 400, fill: '#ffffff' }), + text({ + id: MINIMAL_TEXT_BLOCK_ID, + x: 50, + y: 50, + text: MINIMAL_TEST_TEXT, + fontSize: 24, + fill: '#000000', + baseline: 'top' + }), + text({ + x: 50, + y: 150, + text: 'Expected character indices:', + fontSize: 14, + fill: '#333333', + baseline: 'top' + }), + text({ + x: 50, + y: 180, + text: '"Click" = 0-5', + fontSize: 12, + fill: '#666666', + baseline: 'top' + }), + text({ + x: 50, + y: 210, + text: '"and" = 6-8', + fontSize: 12, + fill: '#666666', + baseline: 'top' + }), + text({ + x: 50, + y: 240, + text: '"drag" = 10-14', + fontSize: 12, + fill: '#666666', + baseline: 'top' + }), + ]); +} diff --git a/packages/demo/text-selection-minimal-setup.js b/packages/demo/text-selection-minimal-setup.js new file mode 100644 index 0000000..ebff500 --- /dev/null +++ b/packages/demo/text-selection-minimal-setup.js @@ -0,0 +1,93 @@ +import { VitrineComponent } from 'vitrine-gui'; +import { + buildMinimalScene, + MINIMAL_TEST_TEXT, + MINIMAL_TEXT_BLOCK_ID, +} from './text-selection-minimal-scene.js'; + +const logEl = document.getElementById('log'); +let selectionManager = null; + +function log(msg) { + logEl.textContent += `${msg}\n`; + logEl.scrollTop = logEl.scrollHeight; +} + +function clearLog() { + logEl.textContent = ''; +} + +const component = VitrineComponent.block(buildMinimalScene, { + width: 800, + height: 400, + renderMode: 'auto', + selectionConfig: { + enabled: true, + caretColor: '#ff0000', + selectionColor: 'rgba(0, 0, 255, 0.3)', + caretWidth: 3, + } +}); + +const canvas = document.getElementById('canvas'); +component.mount(canvas); +selectionManager = component.getSelectionManager(); + +setTimeout(() => { + const actualCanvas = canvas.querySelector('canvas'); + if (actualCanvas) { + actualCanvas.addEventListener('click', (e) => { + clearLog(); + + const rect = actualCanvas.getBoundingClientRect(); + const dxcCanvas = actualCanvas.width; + const dycCanvas = actualCanvas.height; + const dxwCanvas = rect.width; + const dywCanvas = rect.height; + + log('=== CANVAS INFORMATION ==='); + log(`Buffer size: ${dxcCanvas} x ${dycCanvas}`); + log(`Display size: ${Math.round(dxwCanvas)} x ${Math.round(dywCanvas)}`); + log(`Scale factor: ${(dxcCanvas / dxwCanvas).toFixed(3)} x ${(dycCanvas / dywCanvas).toFixed(3)}`); + + log('\n=== CLICK EVENT ==='); + log(`clientX: ${e.clientX.toFixed(0)}`); + log(`clientY: ${e.clientY.toFixed(0)}`); + log(`rect.left: ${rect.left.toFixed(0)}`); + log(`rect.top: ${rect.top.toFixed(0)}`); + + const screenX = e.clientX - rect.left; + const screenY = e.clientY - rect.top; + log('\n=== SCREEN COORDINATES (CSS px) ==='); + log(`screenX: ${screenX.toFixed(1)}`); + log(`screenY: ${screenY.toFixed(1)}`); + + const scaleX = dxcCanvas / dxwCanvas; + const scaleY = dycCanvas / dywCanvas; + const bufferX = screenX * scaleX; + const bufferY = screenY * scaleY; + + log('\n=== CANVAS BUFFER COORDINATES ==='); + log(`bufferX: ${bufferX.toFixed(1)}`); + log(`bufferY: ${bufferY.toFixed(1)}`); + + if (selectionManager) { + log('\n=== HIT-TESTING ==='); + const charIndex = selectionManager.hitTestBlockCharacter(MINIMAL_TEXT_BLOCK_ID, screenX, screenY); + if (charIndex !== null) { + log(`✓ Hit! Character index: ${charIndex}`); + const sel = selectionManager.getSelection(); + if (sel) { + log(`Selection: anchor=${sel.anchor}, focus=${sel.focus}`); + log(`Block: ${sel.blockId}`); + log(`\nCharacter: "${MINIMAL_TEST_TEXT.charAt(charIndex)}"`); + log(`Substring [0..${charIndex}]: "${MINIMAL_TEST_TEXT.substring(0, charIndex)}"`); + log(`Substring [0..${charIndex + 1}]: "${MINIMAL_TEST_TEXT.substring(0, charIndex + 1)}"`); + } + } else { + log('✗ No hit on testText block'); + } + } + }); + } +}, 100); diff --git a/packages/demo/text-selection-minimal.css b/packages/demo/text-selection-minimal.css new file mode 100644 index 0000000..0a8d3ba --- /dev/null +++ b/packages/demo/text-selection-minimal.css @@ -0,0 +1,25 @@ +body { font-family: monospace; padding: 20px; background: #f0f0f0; } +.container { max-width: 1000px; margin: 0 auto; } +h1 { font-size: 24px; margin: 0 0 10px 0; } +.instructions { background: #e0e0e0; padding: 10px; border-radius: 5px; margin: 10px 0; } +#canvas { + border: 2px solid #333; + border-radius: 8px; + background: white; + margin: 20px 0; + min-height: 400px; +} +.debug { background: white; border: 1px solid #ccc; padding: 10px; margin: 10px 0; border-radius: 5px; } +.debug-label { font-weight: bold; color: #d32f2f; margin-top: 10px; } +#log { + height: 300px; + overflow-y: auto; + background: #fafafa; + padding: 10px; + border: 1px solid #ddd; + border-radius: 5px; + font-size: 11px; + line-height: 1.4; + white-space: pre-wrap; + word-wrap: break-word; +} diff --git a/packages/demo/text-selection-minimal.html b/packages/demo/text-selection-minimal.html new file mode 100644 index 0000000..682658b --- /dev/null +++ b/packages/demo/text-selection-minimal.html @@ -0,0 +1,30 @@ + + + + + + Vitrine - Text Selection Minimal Test + + + +
+

Vitrine Text Selection - Minimal Test

+
+

Test Instructions:

+

1. Click exactly at the beginning of the word "Click" → should place caret at character 0

+

2. Click on the space before "and" → should place caret around character 5-6

+

3. Click at end of "text" → should place caret around character 18-19

+

Check debug output below to see coordinates and character index

+
+ +
+ +
+
Debug Information:
+
Waiting for click...
+
+
+ + + + diff --git a/packages/demo/text-selection-scene.js b/packages/demo/text-selection-scene.js new file mode 100644 index 0000000..39ffb9b --- /dev/null +++ b/packages/demo/text-selection-scene.js @@ -0,0 +1,90 @@ +import { group, rectangle, text } from 'vitrine'; + +export const SCENE_WIDTH = 800; +export const SCENE_HEIGHT = 600; + +export const SELECTION_CONFIG = { + enabled: true, + caretColor: '#dc2626', + selectionColor: 'rgba(59, 130, 246, 0.05)', + caretWidth: 2, +}; + +export const COMPONENT_OPTIONS = { + width: SCENE_WIDTH, + height: SCENE_HEIGHT, + renderMode: 'auto', + selectionConfig: SELECTION_CONFIG, +}; + +export function buildScene() { + return group({}, [ + rectangle({ dx: SCENE_WIDTH, dy: SCENE_HEIGHT, fill: '#ffffff' }), + + text({ + id: 'text1', + x: 40, + y: 60, + text: 'Click and drag to select this text block', + fontSize: 18, + fill: '#0f172a', + baseline: 'top', + }), + + text({ + id: 'text2', + x: 40, + y: 140, + text: 'Multiple selections with different colors are supported for collaborative editing', + fontSize: 16, + fill: '#475569', + baseline: 'top', + }), + + text({ + x: 40, + y: 240, + text: 'Instructions:', + fontSize: 14, + fill: '#334155', + baseline: 'top', + fontWeight: 'bold', + }), + + text({ + x: 40, + y: 275, + text: '1. Click above to place a caret', + fontSize: 13, + fill: '#64748b', + baseline: 'top', + }), + + text({ + x: 40, + y: 305, + text: '2. Drag to create a selection range', + fontSize: 13, + fill: '#64748b', + baseline: 'top', + }), + + text({ + x: 40, + y: 335, + text: '3. Use controls below to add selections for other users', + fontSize: 13, + fill: '#64748b', + baseline: 'top', + }), + + text({ + x: 40, + y: 400, + text: 'Caret: █ | Selection: ▮▮▮', + fontSize: 14, + fill: '#2563eb', + baseline: 'top', + }), + ]); +} diff --git a/packages/demo/text-selection-setup.js b/packages/demo/text-selection-setup.js new file mode 100644 index 0000000..9b68614 --- /dev/null +++ b/packages/demo/text-selection-setup.js @@ -0,0 +1,56 @@ +import { VitrineComponent } from 'vitrine-gui'; + +import { buildScene, COMPONENT_OPTIONS } from './text-selection-scene.js'; + +const component = VitrineComponent.block(buildScene, COMPONENT_OPTIONS); +const canvas = document.getElementById('canvas'); + +component.mount(canvas); + +const selectionManager = component.getSelectionManager(); + +function updateStats() { + const selections = selectionManager.getAllSelections(); + document.getElementById('selectionCount').textContent = selections.length; + + const list = selections + .map( + (sel) => + `${sel.userId || 'default'}: [${sel.anchor}, ${sel.focus}) in "${sel.blockId}" ${sel.color ? `color: ${sel.color}` : ''}`, + ) + .join('\n'); + + document.getElementById('selectionList').textContent = list || '(no selections)'; +} + +window.addSelection = function addSelection() { + const userId = document.getElementById('userId').value || 'default'; + const startChar = parseInt(document.getElementById('startChar').value) || 0; + const endChar = parseInt(document.getElementById('endChar').value) || 0; + const hexColor = document.getElementById('selColor').value; + + selectionManager.setSelection('text1', startChar, endChar, userId); + + const selection = selectionManager.getSelection(userId); + if (selection) { + const hex = hexColor.replace('#', ''); + const red = parseInt(hex.substring(0, 2), 16); + const green = parseInt(hex.substring(2, 4), 16); + const blue = parseInt(hex.substring(4, 6), 16); + + if (startChar === endChar) { + selection.color = hexColor; + } else { + selection.color = `rgba(${red}, ${green}, ${blue}, 0.2)`; + } + } + + updateStats(); +}; + +window.clearAllSelections = function clearAllSelections() { + selectionManager.clearAllSelections(); + updateStats(); +}; + +updateStats(); diff --git a/packages/demo/text-selection.css b/packages/demo/text-selection.css new file mode 100644 index 0000000..3e74f76 --- /dev/null +++ b/packages/demo/text-selection.css @@ -0,0 +1,176 @@ +:root { + color-scheme: light; + font-family: "Avenir Next", "Segoe UI", sans-serif; + background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%); + color: #0f172a; +} + +body { + margin: 0; + min-height: 100vh; + padding: 24px 20px 84px; + background: + radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 28%), + linear-gradient(180deg, #f8fafc 0%, #e2e8f0 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 18px 40px rgba(15, 23, 42, 0.08); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + margin-bottom: 24px; +} + +#canvas { + border-radius: 16px; + background: #ffffff; +} + +canvas { + display: block; + width: 100%; + height: auto; + border-radius: 16px; + background: #ffffff; +} + +.caption { + margin: 14px 4px 0; + font-size: 13px; + color: #64748b; +} + +.controls { + padding: 16px; + border: 1px solid rgba(148, 163, 184, 0.45); + border-radius: 12px; + background: rgba(255, 255, 255, 0.5); + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: center; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.control-group label { + font-size: 12px; + font-weight: 600; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +input, +button { + padding: 8px 12px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 14px; + font-family: inherit; +} + +button { + background: #2563eb; + color: #ffffff; + cursor: pointer; + font-weight: 600; + border: none; + transition: background 0.2s; +} + +button:hover { + background: #1d4ed8; +} + +button:active { + background: #1e40af; +} + +.button-secondary { + background: #64748b; +} + +#userId { + width: 100px; +} + +#startChar, +#endChar { + width: 80px; +} + +#selColor { + width: 60px; +} + +.stats { + margin-top: 16px; + padding: 12px; + background: #f1f5f9; + border-radius: 8px; + font-size: 13px; + color: #334155; + font-family: "Monaco", "Menlo", monospace; +} + +#selectionList { + margin-top: 8px; + white-space: pre-wrap; + font-size: 12px; +} + +.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.html b/packages/demo/text-selection.html new file mode 100644 index 0000000..148d617 --- /dev/null +++ b/packages/demo/text-selection.html @@ -0,0 +1,56 @@ + + + + + + + Vitrine - Text Selection Demo + + + +
+

Vitrine Text Selection

+

+ Test multi-user text selection with configurable caret and highlight colors. + Click to place caret, drag to select. Try adding multiple user selections below. +

+
+
+

Selection overlays are rendered as Portal blocks above the content.

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+
Active Selections: 0
+
+
+
+ + + + + + diff --git a/packages/demo/texta.html b/packages/demo/texta.html index 8b11503..2286d66 100644 --- a/packages/demo/texta.html +++ b/packages/demo/texta.html @@ -70,15 +70,17 @@

Vitrine Texta Block

group, line, rectangle, - text, - texta as textaBlock + text } from 'vitrine'; import { applyStyle, detectRgStorageMode, getRgCodePointBoundaryUtf16, - getRgGraphemeBoundaryUtf16 + getRgGraphemeBoundaryUtf16, + registerTextaBlockType, + texta as textaBlock } from 'texta/browser'; + registerTextaBlockType(); function getUnitCount(strText, rgStorageMode) { if (rgStorageMode === 'fastCodeUnit') return strText.length; @@ -164,8 +166,8 @@

Vitrine Texta Block

const canvas = document.getElementById('canvas'); const renderer = new ImmediateRenderer({ canvas, - width: 1100, - height: 860 + dx: 1100, + dy: 860 }); let frame = 0; diff --git a/packages/demo/vite.config.ts b/packages/demo/vite.config.ts index ece8896..72de084 100644 --- a/packages/demo/vite.config.ts +++ b/packages/demo/vite.config.ts @@ -23,7 +23,10 @@ export default defineConfig({ componentDemo: resolve(__dirname, 'component-demo.html'), texta: resolve(__dirname, 'texta.html'), markdown: resolve(__dirname, 'markdown.html'), - tables: resolve(__dirname, 'tables.html') + tables: resolve(__dirname, 'tables.html'), + textSelection: resolve(__dirname, 'text-selection.html'), + textSelectionDebug: resolve(__dirname, 'text-selection-debug.html'), + textSelectionMinimal: resolve(__dirname, 'text-selection-minimal.html') } } }, diff --git a/packages/gui/src/GUI/button/transformButton.ts b/packages/gui/src/GUI/button/transformButton.ts index f92ece6..2b783ba 100644 --- a/packages/gui/src/GUI/button/transformButton.ts +++ b/packages/gui/src/GUI/button/transformButton.ts @@ -98,7 +98,7 @@ export function transformButton( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id, onClick: fEnabled !== false && onClick ? (_event: VitrinePointerEvent) => onClick() : undefined, onHover diff --git a/packages/gui/src/GUI/carousel/transformCarousel.ts b/packages/gui/src/GUI/carousel/transformCarousel.ts index 9d7bad2..ab7f6e4 100644 --- a/packages/gui/src/GUI/carousel/transformCarousel.ts +++ b/packages/gui/src/GUI/carousel/transformCarousel.ts @@ -47,7 +47,7 @@ export function transformCarousel( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id }, children diff --git a/packages/gui/src/GUI/checkbox/transformCheckBox.ts b/packages/gui/src/GUI/checkbox/transformCheckBox.ts index 2ef70ea..fb79716 100644 --- a/packages/gui/src/GUI/checkbox/transformCheckBox.ts +++ b/packages/gui/src/GUI/checkbox/transformCheckBox.ts @@ -89,7 +89,7 @@ export function transformCheckBox( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id }, children diff --git a/packages/gui/src/GUI/colorpicker/transformColorPicker.ts b/packages/gui/src/GUI/colorpicker/transformColorPicker.ts index cfa8347..76a0f45 100644 --- a/packages/gui/src/GUI/colorpicker/transformColorPicker.ts +++ b/packages/gui/src/GUI/colorpicker/transformColorPicker.ts @@ -642,7 +642,7 @@ export function transformColorPicker( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id, onHover }, diff --git a/packages/gui/src/GUI/dropdown/transformDropdown.ts b/packages/gui/src/GUI/dropdown/transformDropdown.ts index ec20c50..223f524 100644 --- a/packages/gui/src/GUI/dropdown/transformDropdown.ts +++ b/packages/gui/src/GUI/dropdown/transformDropdown.ts @@ -198,7 +198,7 @@ export function transformDropdown( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id, onClick: handleMainClick, onHover diff --git a/packages/gui/src/GUI/grid/transformGrid.ts b/packages/gui/src/GUI/grid/transformGrid.ts index 3cbd561..5b575e7 100644 --- a/packages/gui/src/GUI/grid/transformGrid.ts +++ b/packages/gui/src/GUI/grid/transformGrid.ts @@ -70,7 +70,7 @@ export function transformGrid( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id }, children diff --git a/packages/gui/src/GUI/hstack/transformHStack.ts b/packages/gui/src/GUI/hstack/transformHStack.ts index c806081..c6814d0 100644 --- a/packages/gui/src/GUI/hstack/transformHStack.ts +++ b/packages/gui/src/GUI/hstack/transformHStack.ts @@ -74,7 +74,7 @@ export function transformStack( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id }, children diff --git a/packages/gui/src/GUI/image/transformImage.ts b/packages/gui/src/GUI/image/transformImage.ts index 69109c6..a24aab5 100644 --- a/packages/gui/src/GUI/image/transformImage.ts +++ b/packages/gui/src/GUI/image/transformImage.ts @@ -21,7 +21,7 @@ export function transformGUIImage( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id }, [ diff --git a/packages/gui/src/GUI/label/transformLabel.ts b/packages/gui/src/GUI/label/transformLabel.ts index fd0979d..652b040 100644 --- a/packages/gui/src/GUI/label/transformLabel.ts +++ b/packages/gui/src/GUI/label/transformLabel.ts @@ -45,7 +45,7 @@ export function transformLabel( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id }, [ diff --git a/packages/gui/src/GUI/panel/transformPanel.ts b/packages/gui/src/GUI/panel/transformPanel.ts index b451eb9..ee0851d 100644 --- a/packages/gui/src/GUI/panel/transformPanel.ts +++ b/packages/gui/src/GUI/panel/transformPanel.ts @@ -78,7 +78,7 @@ export function transformPanel( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id }, children diff --git a/packages/gui/src/GUI/radiobutton/transformRadioButton.ts b/packages/gui/src/GUI/radiobutton/transformRadioButton.ts index 0691940..10115c1 100644 --- a/packages/gui/src/GUI/radiobutton/transformRadioButton.ts +++ b/packages/gui/src/GUI/radiobutton/transformRadioButton.ts @@ -77,7 +77,7 @@ export function transformRadioButton( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id }, children diff --git a/packages/gui/src/GUI/slider/transformSlider.ts b/packages/gui/src/GUI/slider/transformSlider.ts index 87a92dc..47627fb 100644 --- a/packages/gui/src/GUI/slider/transformSlider.ts +++ b/packages/gui/src/GUI/slider/transformSlider.ts @@ -186,7 +186,7 @@ export function transformSlider( x: fVertical ? duCrossCenter : duThumbAxis, y: fVertical ? duThumbAxis : duCrossCenter, radius: rlThumb, - disableCulling: true, + fDisableCulling: true, fill: colThumbFill, stroke: colThumbStroke, strokeWidth: SLIDER_DEFAULTS.duThumbStroke, @@ -201,7 +201,7 @@ export function transformSlider( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id, onHover }, diff --git a/packages/gui/src/GUI/textbox/transformTextBox.ts b/packages/gui/src/GUI/textbox/transformTextBox.ts index a5c2607..7a898fe 100644 --- a/packages/gui/src/GUI/textbox/transformTextBox.ts +++ b/packages/gui/src/GUI/textbox/transformTextBox.ts @@ -92,7 +92,7 @@ export function transformTextBox( { x: xp, y: yp, - visible: fVisible !== false, + fVisible: fVisible !== false, id }, children diff --git a/packages/gui/src/component.ts b/packages/gui/src/component.ts index ccc3335..f03cc6d 100644 --- a/packages/gui/src/component.ts +++ b/packages/gui/src/component.ts @@ -5,10 +5,14 @@ import type { Block } from 'vitrine'; import type { GUIControl, TransformContext, ThemeDefinition } from './GUI/types.ts'; -import { ImmediateRenderer } from 'vitrine'; -import type { RendererConfig } from 'vitrine'; +import { ImmediateRenderer, group, Canvas2DContext, clearTextLayoutCaches } from 'vitrine'; +import type { RendererConfig, RenderContext } from 'vitrine'; import { transformGUIControl, rsControl } from './GUI/transform.ts'; import { lightTheme } from './GUI/themes.ts'; +import { TextSelectionManager } from './selection/TextSelectionManager.ts'; +import type { SelectionRenderConfig } from './selection/TextSelectionManager.ts'; +import { createCharacterBoundsAdapter } from './selection/character-bounds-adapter.ts'; +import type { CharacterBoundsAdapter } from './selection/character-bounds-adapter.ts'; /** Function that returns a GUI control tree each frame. */ export type GUIControlBuilder = () => GUIControl; @@ -35,9 +39,12 @@ export interface VitrineComponentConfig { invalidateOnInteraction?: boolean; /** Additional renderer config overrides. */ rendererConfig?: Partial; + /** Text selection configuration. Defaults to disabled. */ + selectionConfig?: SelectionRenderConfig; } export class VitrineComponent { + private static cMountedComponents: number = 0; private renderer: ImmediateRenderer | null = null; private canvas: HTMLCanvasElement | null = null; private container: HTMLElement | null = null; @@ -52,13 +59,18 @@ export class VitrineComponent { private activeAnimationCount: number = 0; private hasExplicitAnimationControl: boolean = false; private fDirty: boolean = false; + private selectionManager: TextSelectionManager | null = null; + private selectionMeasureContext: RenderContext | undefined; + private selectionBoundsAdapter: CharacterBoundsAdapter | null = null; + private selectableTextBlockIds: string[] = []; private boundInteractionHandlers: { - pointerdown: () => void; - pointerup: () => void; - pointermove: () => void; - click: () => void; + pointerdown: (e: PointerEvent) => void; + pointerup: (e: PointerEvent) => void; + pointermove: (e: PointerEvent) => void; + click: (e: MouseEvent) => void; pointerleave: () => void; - wheel: () => void; + wheel: (e: WheelEvent) => void; + keydown: (e: KeyboardEvent) => void; }; constructor( @@ -73,12 +85,13 @@ export class VitrineComponent { this.renderMode = config.renderMode ?? 'continuous'; this.invalidateOnInteraction = config.invalidateOnInteraction ?? true; this.boundInteractionHandlers = { - pointerdown: this.handleInteractionInvalidate.bind(this), - pointerup: this.handleInteractionInvalidate.bind(this), - pointermove: this.handleInteractionInvalidate.bind(this), - click: this.handleInteractionInvalidate.bind(this), - pointerleave: this.handleInteractionInvalidate.bind(this), - wheel: this.handleInteractionInvalidate.bind(this) + pointerdown: this.handlePointerDown.bind(this), + pointerup: this.handlePointerUp.bind(this), + pointermove: this.handlePointerMove.bind(this), + click: () => this.handleSimpleInvalidate(), + pointerleave: () => this.handleSimpleInvalidate(), + wheel: () => this.handleSimpleInvalidate(), + keydown: this.handleKeyDown.bind(this) }; } @@ -106,14 +119,27 @@ export class VitrineComponent { this.renderer = new ImmediateRenderer({ canvas: this.canvas, - width, - height, + dx: width, + dy: height, pixelRatio: this.config.pixelRatio, - enableEvents: true, + fEnableEvents: true, ...this.config.rendererConfig }); + // Initialize selection manager if configured + if (this.config.selectionConfig?.enabled === true) { + this.selectionManager = new TextSelectionManager(this.config.selectionConfig); + const canvasMeasure = document.createElement('canvas'); + const canvasContext = canvasMeasure.getContext('2d'); + this.selectionMeasureContext = canvasContext ? new Canvas2DContext(canvasContext) : undefined; + this.selectionBoundsAdapter = createCharacterBoundsAdapter({ + context: this.selectionMeasureContext + }); + this.selectionManager.setCharacterBoundsProvider(this.selectionBoundsAdapter.getProvider()); + } + this.mounted = true; + VitrineComponent.cMountedComponents += 1; this.setupInteractionInvalidation(); this.invalidate(); } @@ -127,6 +153,9 @@ export class VitrineComponent { this.fDirty = false; this.activeAnimationCount = 0; this.hasExplicitAnimationControl = false; + this.selectionManager = null; + this.selectionMeasureContext = undefined; + this.selectionBoundsAdapter = null; if (this.renderer) { this.renderer.destroy(); @@ -140,6 +169,10 @@ export class VitrineComponent { this.canvas = null; this.container = null; this.mounted = false; + VitrineComponent.cMountedComponents = Math.max(0, VitrineComponent.cMountedComponents - 1); + if (VitrineComponent.cMountedComponents === 0) { + clearTextLayoutCaches(); + } } /** Update the render function. Takes effect on the next frame. */ @@ -194,6 +227,14 @@ export class VitrineComponent { return this.renderMode; } + /** + * Get the text selection manager (if enabled). + * Returns null if selection is not configured for this component. + */ + getSelectionManager(): TextSelectionManager | null { + return this.selectionManager; + } + /** * Signals that an animation has started. * In auto mode, this enables continuous RAF until endAnimation() balances it. @@ -279,6 +320,10 @@ export class VitrineComponent { this.canvas.addEventListener('click', this.boundInteractionHandlers.click); this.canvas.addEventListener('pointerleave', this.boundInteractionHandlers.pointerleave); this.canvas.addEventListener('wheel', this.boundInteractionHandlers.wheel, { passive: true }); + + // Make canvas focusable for keyboard events + this.canvas.tabIndex = 0; + this.canvas.addEventListener('keydown', this.boundInteractionHandlers.keydown); } private removeInteractionInvalidation(): void { @@ -289,14 +334,87 @@ export class VitrineComponent { this.canvas.removeEventListener('click', this.boundInteractionHandlers.click); this.canvas.removeEventListener('pointerleave', this.boundInteractionHandlers.pointerleave); this.canvas.removeEventListener('wheel', this.boundInteractionHandlers.wheel); + this.canvas.removeEventListener('keydown', this.boundInteractionHandlers.keydown); } - private handleInteractionInvalidate(): void { + private handleSimpleInvalidate(): void { if (!this.invalidateOnInteraction) return; if (this.renderMode === 'continuous') return; this.invalidate(); } + private getCanvasCoordinates(e: PointerEvent): { x: number; y: number } | null { + if (!this.canvas) return null; + const { left: xwCanvas, top: ywCanvas } = this.canvas.getBoundingClientRect(); + const { clientX: xwPointer, clientY: ywPointer } = e; + + return { + x: xwPointer - xwCanvas, + y: ywPointer - ywCanvas + }; + } + + private handlePointerDown(e: PointerEvent): void { + this.handleSimpleInvalidate(); + if (!this.selectionManager) return; + this.canvas?.focus(); + + const coords = this.getCanvasCoordinates(e); + if (!coords) return; + + for (let i = this.selectableTextBlockIds.length - 1; i >= 0; i--) { + const blockId = this.selectableTextBlockIds[i]; + const charIndex = this.selectionManager.hitTestBlockCharacter(blockId, coords.x, coords.y); + if (charIndex !== null) { + this.selectionManager.handlePointerDown(blockId, charIndex); + this.invalidate(); + return; + } + } + } + + private handlePointerUp(e: PointerEvent): void { + this.handleSimpleInvalidate(); + if (!this.selectionManager) return; + + this.selectionManager.handlePointerUp(); + this.invalidate(); + } + + private handlePointerMove(e: PointerEvent): void { + this.handleSimpleInvalidate(); + if (!this.selectionManager) return; + + const coords = this.getCanvasCoordinates(e); + if (!coords) return; + + for (let i = this.selectableTextBlockIds.length - 1; i >= 0; i--) { + const blockId = this.selectableTextBlockIds[i]; + const charIndex = this.selectionManager.hitTestBlockCharacter(blockId, coords.x, coords.y); + if (charIndex !== null) { + this.selectionManager.handlePointerMove(charIndex); + this.invalidate(); + return; + } + } + } + + private handleKeyDown(e: KeyboardEvent): void { + if (!this.selectionManager) return; + + // Only handle text navigation keys + const handled = this.selectionManager.handleKeyDown( + e.key, + e.shiftKey, + e.ctrlKey || e.metaKey + ); + + if (handled) { + e.preventDefault(); + this.invalidate(); + } + } + private stopRenderLoop(): void { if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); @@ -305,11 +423,25 @@ export class VitrineComponent { } private buildBlock(): Block { - if (this.mode === 'gui') { - const control = (this.renderFn as GUIControlBuilder)(); - const context: TransformContext = { theme: this.theme }; - return transformGUIControl(control, context); + const contentBlock = this.mode === 'gui' + ? transformGUIControl((this.renderFn as GUIControlBuilder)(), { theme: this.theme }) + : (this.renderFn as BlockBuilder)(); + + // If selection manager is active, wrap content with selection overlay + if ( + this.selectionManager + && this.selectionBoundsAdapter + && this.selectionManager.isRenderingEnabled() + ) { + const updateResult = this.selectionBoundsAdapter.updateFromBlockTree(contentBlock); + this.selectableTextBlockIds = updateResult.selectableTextBlockIds; + this.selectionManager.invalidateInsertionGeometry(updateResult.changedBlockIds); + const selectionOverlay = this.selectionManager.buildSelectionOverlays(); + if (selectionOverlay) { + return group({}, [contentBlock, selectionOverlay]); + } } - return (this.renderFn as BlockBuilder)(); + + return contentBlock; } } diff --git a/packages/gui/src/index.ts b/packages/gui/src/index.ts index ee84419..f0faaaa 100644 --- a/packages/gui/src/index.ts +++ b/packages/gui/src/index.ts @@ -68,3 +68,9 @@ export { // Component system export { VitrineComponent } from './component.ts'; export type { VitrineComponentConfig, GUIControlBuilder, BlockBuilder, RenderFunction, RenderMode } from './component.ts'; + +// Text selection system +export { TextSelectionManager } from './selection/TextSelectionManager.ts'; +export type { SelectionRenderConfig, CharacterBoundsProvider } from './selection/TextSelectionManager.ts'; +export { createCharacterBoundsProviderFromBlockTree } from './selection/character-bounds-adapter.ts'; +export type { CharacterBoundsAdapterOptions } from './selection/character-bounds-adapter.ts'; diff --git a/packages/gui/src/selection/TextSelectionManager.ts b/packages/gui/src/selection/TextSelectionManager.ts new file mode 100644 index 0000000..d6e358c --- /dev/null +++ b/packages/gui/src/selection/TextSelectionManager.ts @@ -0,0 +1,735 @@ +// Copyright (c) 2026 François Rouaix + +// Text selection manager for VitrineComponent +// Tracks selections across text/texta blocks and manages selection state. + +import type { Selection, Block } from 'vitrine'; +import { rectangle, portal, group } from 'vitrine'; +import type { Rc } from 'vitrine'; + +/** Configuration for selection rendering. */ +export interface SelectionRenderConfig { + /** Whether to auto-render selections. Defaults to true. */ + enabled?: boolean; + /** Caret color. Defaults to '#000'. */ + caretColor?: string; + /** Selection highlight color. Defaults to 'rgba(0, 0, 255, 0.2)'. */ + selectionColor?: string; + /** Caret stroke width. Defaults to 2. */ + caretWidth?: number; + /** Enable internal selection geometry debug logs. Defaults to false. */ + debug?: boolean; + /** Optional debug logger; defaults to console.debug when debug is enabled. */ + debugLogger?: (...args: unknown[]) => void; +} + +/** + * Provides character bounds in scene/CSS coordinates for a text block. + * + * Contract: + * - `charIndex` refers to a character position in the block's text. + * - Returned bounds must use the same coordinate space as rendered block positions. + * - Return `null` when the index is out of range for the block. + */ +export type CharacterBoundsProvider = (blockId: string, charIndex: number) => Rc | null; + +interface InsertionPoint { + index: number; + x: number; + y: number; + height: number; +} + +interface LineGroup { + y: number; + height: number; + points: Array<{ index: number; x: number }>; +} + +interface RangeRect { + x: number; + y: number; + dx: number; + dy: number; +} + +interface BlockInsertionGeometry { + points: InsertionPoint[]; + pointByIndex: Map; +} + +/** Text selection manager for a VitrineComponent instance. */ +export class TextSelectionManager { + private selections: Map = new Map(); + private lastLoggedGeometry: Map = new Map(); + private preferredXBySelection: Map = new Map(); + private renderConfig: SelectionRenderConfig; + private isDragging: boolean = false; + private dragStartBlockId: string | null = null; + private dragStartCharIndex: number | null = null; + private characterBoundsProvider: CharacterBoundsProvider | null = null; + private insertionGeometryByBlock: Map = new Map(); + + constructor(config: SelectionRenderConfig = {}) { + this.renderConfig = { + enabled: config.enabled ?? true, + caretColor: config.caretColor ?? '#000', + selectionColor: config.selectionColor ?? 'rgba(0, 0, 255, 0.2)', + caretWidth: config.caretWidth ?? 2, + debug: config.debug ?? false, + debugLogger: config.debugLogger + }; + } + + private debugLog(...args: unknown[]): void { + if (!this.renderConfig.debug) { + return; + } + const debugLogger = this.renderConfig.debugLogger ?? console.debug.bind(console); + debugLogger(...args); + } + + private getSelectionKey(userId?: string): string { + return userId ?? 'default'; + } + + /** + * Set a selection for a specific block and optional user. + * If userId is not provided, uses a default key. + */ + setSelection(blockId: string, anchor: number, focus: number, userId?: string): void { + const key = this.getSelectionKey(userId); + this.selections.set(key, { + blockId, + anchor, + focus, + userId + }); + } + + /** + * Get selection for a user (or default user if userId not provided). + */ + getSelection(userId?: string): Selection | undefined { + const key = this.getSelectionKey(userId); + return this.selections.get(key); + } + + /** + * Get all active selections. + */ + getAllSelections(): Selection[] { + return Array.from(this.selections.values()); + } + + /** + * Clear selection for a user. + */ + clearSelection(userId?: string): void { + const key = this.getSelectionKey(userId); + this.selections.delete(key); + this.lastLoggedGeometry.delete(key); + this.preferredXBySelection.delete(key); + } + + /** + * Clear all selections. + */ + clearAllSelections(): void { + this.selections.clear(); + this.lastLoggedGeometry.clear(); + this.preferredXBySelection.clear(); + } + + /** + * Get render configuration. + */ + getRenderConfig(): SelectionRenderConfig { + return { ...this.renderConfig }; + } + + /** + * Update render configuration. + */ + setRenderConfig(config: Partial): void { + this.renderConfig = { ...this.renderConfig, ...config }; + } + + /** + * Check if selection rendering is enabled. + */ + isRenderingEnabled(): boolean { + return this.renderConfig.enabled ?? true; + } + + /** + * Set the function that provides character bounds for rendering/hit-testing. + * VitrineComponent provides this automatically for standard `text` blocks. + * Call this to override the default provider or to support custom text rendering. + */ + setCharacterBoundsProvider(provider: CharacterBoundsProvider): void { + if (this.characterBoundsProvider !== provider) { + this.insertionGeometryByBlock.clear(); + } + this.characterBoundsProvider = provider; + } + + invalidateInsertionGeometry(blockIds?: string[]): void { + if (!blockIds) { + this.insertionGeometryByBlock.clear(); + return; + } + if (blockIds.length === 0) { + return; + } + + for (const blockId of blockIds) { + this.insertionGeometryByBlock.delete(blockId); + } + } + + private queryInsertionBounds(blockId: string, index: number): Rc | null { + if (!this.characterBoundsProvider) { + return null; + } + + const direct = this.characterBoundsProvider(blockId, index); + if (direct) { + return { + x: direct.x, + y: direct.y, + width: 0, + height: direct.height + }; + } + + if (index > 0) { + const prev = this.characterBoundsProvider(blockId, index - 1); + if (prev) { + return { + x: prev.x + prev.width, + y: prev.y, + width: 0, + height: prev.height + }; + } + } + + return null; + } + + private buildBlockInsertionGeometry(blockId: string): BlockInsertionGeometry { + const points: InsertionPoint[] = []; + const pointByIndex = new Map(); + let missStreak = 0; + const maxProbe = 20000; + + for (let index = 0; index <= maxProbe; index++) { + const bounds = this.queryInsertionBounds(blockId, index); + if (!bounds) { + missStreak++; + if (missStreak >= 8) { + break; + } + continue; + } + + missStreak = 0; + const point = this.toInsertionPoint(index, bounds); + points.push(point); + pointByIndex.set(index, point); + } + + return { points, pointByIndex }; + } + + private getBlockInsertionGeometry(blockId: string): BlockInsertionGeometry { + const geometry = this.insertionGeometryByBlock.get(blockId); + if (geometry) { + return geometry; + } + const nextGeometry = this.buildBlockInsertionGeometry(blockId); + this.insertionGeometryByBlock.set(blockId, nextGeometry); + return nextGeometry; + } + + private getInsertionBounds(blockId: string, index: number): Rc | null { + const point = this.getInsertionPoint(blockId, index); + if (!point) { + return null; + } + return { + x: point.x, + y: point.y, + width: 0, + height: point.height + }; + } + + private toInsertionPoint(index: number, bounds: Rc): InsertionPoint { + return { + index, + x: bounds.x, + y: bounds.y, + height: bounds.height + }; + } + + private getInsertionPoint(blockId: string, index: number): InsertionPoint | null { + const { pointByIndex } = this.getBlockInsertionGeometry(blockId); + return pointByIndex.get(index) ?? null; + } + + private collectInsertionPointsInRange(blockId: string, start: number, end: number): InsertionPoint[] { + const { points } = this.getBlockInsertionGeometry(blockId); + return points.filter((point) => point.index >= start && point.index <= end); + } + + // TODO: this is brute force search, should be optimized + private groupInsertionPointsByLine(points: InsertionPoint[], lineTolerance: number = 0.5): LineGroup[] { + const lineGroups: LineGroup[] = []; + for (const point of points) { + const lineGroup = lineGroups.find((line) => + Math.abs(line.y - point.y) <= lineTolerance + && Math.abs(line.height - point.height) <= lineTolerance + ); + if (lineGroup) { + lineGroup.points.push({ index: point.index, x: point.x }); + continue; + } + lineGroups.push({ + y: point.y, + height: point.height, + points: [{ index: point.index, x: point.x }] + }); + } + return lineGroups; + } + + private createRangeRectsFromLineGroups(lineGroups: LineGroup[]): RangeRect[] { + return lineGroups + .filter((lineGroup) => lineGroup.points.length >= 2) + .map((lineGroup) => { + const pointsSorted = [...lineGroup.points].sort((a, b) => a.index - b.index); + const xStart = pointsSorted[0]!.x; + const xEnd = pointsSorted[pointsSorted.length - 1]!.x; + return { + x: Math.min(xStart, xEnd), + y: lineGroup.y, + dx: Math.max(1, Math.abs(xEnd - xStart)), + dy: lineGroup.height + }; + }); + } + + private buildRangeRects( + blockId: string, + start: number, + end: number + ): RangeRect[] { + const insertionPoints = this.collectInsertionPointsInRange(blockId, start, end); + if (insertionPoints.length < 2) { + return []; + } + const lineGroups = this.groupInsertionPointsByLine(insertionPoints); + return this.createRangeRectsFromLineGroups(lineGroups); + } + + private collectInsertionPoints(blockId: string): InsertionPoint[] { + const { points } = this.getBlockInsertionGeometry(blockId); + return points; + } + + /** Returns the largest known insertion index for a block from current bounds provider data. */ + getTextLengthForBlock(blockId: string): number { + const points = this.collectInsertionPoints(blockId); + if (points.length === 0) { + return 0; + } + return Math.max(...points.map((point) => point.index)); + } + + private findCurrentLineIndex(lineGroups: LineGroup[], index: number, currentY: number): number { + const lineIndex = lineGroups.findIndex((lineGroup) => + lineGroup.points.some((point) => point.index === index) + ); + if (lineIndex >= 0) { + return lineIndex; + } + return lineGroups.reduce((bestIdx, lineGroup, idx) => { + const bestDistance = Math.abs(lineGroups[bestIdx]!.y - currentY); + const distance = Math.abs(lineGroup.y - currentY); + return distance < bestDistance ? idx : bestIdx; + }, 0); + } + + private getNearestPointByX(points: Array<{ index: number; x: number }>, desiredX: number): { index: number; x: number } { + let bestPoint = points[0]!; + let bestDistance = Math.abs(bestPoint.x - desiredX); + for (const point of points) { + const distance = Math.abs(point.x - desiredX); + if (distance < bestDistance) { + bestDistance = distance; + bestPoint = point; + } + } + return bestPoint; + } + + private moveVerticalInsertion(blockId: string, index: number, direction: -1 | 1, selectionKey: string): number | null { + const points = this.collectInsertionPoints(blockId); + const current = points.find((point) => point.index === index); + if (!current) { + return null; + } + + const lineGroups = this.groupInsertionPointsByLine(points) + .map((lineGroup) => ({ + ...lineGroup, + points: [...lineGroup.points].sort((a, b) => a.index - b.index) + })) + .sort((a, b) => a.y - b.y); + if (lineGroups.length <= 1) { + return index; + } + + const currentLineIndex = this.findCurrentLineIndex(lineGroups, index, current.y); + const targetLineIndex = currentLineIndex + direction; + if (targetLineIndex < 0 || targetLineIndex >= lineGroups.length) { + return index; + } + + const desiredX = this.preferredXBySelection.get(selectionKey) ?? current.x; + const targetLine = lineGroups[targetLineIndex]!; + return this.getNearestPointByX(targetLine.points, desiredX).index; + } + + private setCaretSelection(blockId: string, position: number, userId?: string): void { + this.setSelection(blockId, position, position, userId); + } + + private setFocusSelection(blockId: string, anchor: number, focus: number, userId?: string): void { + this.setSelection(blockId, anchor, focus, userId); + } + + /** + * Build overlay blocks for all active selections. + * Returns a Portal block containing caret/highlight blocks. + * Returns null if rendering is disabled or no selections exist. + */ + buildSelectionOverlays(): Block | null { + if (!this.isRenderingEnabled() || this.selections.size === 0 || !this.characterBoundsProvider) { + return null; + } + + const overlayChildren: Block[] = []; + + for (const [selectionKey, sel] of this.selections.entries()) { + const isCaret = sel.anchor === sel.focus; + + if (isCaret) { + const caretBounds = this.getInsertionBounds(sel.blockId, sel.anchor); + if (caretBounds) { + const logSignature = `caret:${sel.blockId}:${sel.anchor}:${caretBounds.x}:${caretBounds.y}:${caretBounds.height}`; + if (this.renderConfig.debug && this.lastLoggedGeometry.get(selectionKey) !== logSignature) { + this.lastLoggedGeometry.set(selectionKey, logSignature); + this.debugLog('[TextSelectionManager] Caret geometry', { + blockId: sel.blockId, + index: sel.anchor, + x: caretBounds.x, + y: caretBounds.y, + height: caretBounds.height + }); + } + const caretBlock = rectangle( + { + x: caretBounds.x - (this.renderConfig.caretWidth ?? 1) / 2, + y: caretBounds.y, + dx: this.renderConfig.caretWidth ?? 1, + dy: caretBounds.height, + fill: sel.color ?? this.renderConfig.caretColor ?? '#000' + }, + [] + ); + overlayChildren.push(caretBlock); + } + } else { + const start = Math.min(sel.anchor, sel.focus); + const end = Math.max(sel.anchor, sel.focus); + const rangeRects = this.buildRangeRects(sel.blockId, start, end); + + if (rangeRects.length > 0) { + const logSignature = `range:${sel.blockId}:${start}:${end}:${JSON.stringify(rangeRects)}`; + if (this.renderConfig.debug && this.lastLoggedGeometry.get(selectionKey) !== logSignature) { + this.lastLoggedGeometry.set(selectionKey, logSignature); + this.debugLog('[TextSelectionManager] Range geometry', { + blockId: sel.blockId, + start, + end, + rects: rangeRects + }); + } + + for (const rangeRect of rangeRects) { + const highlightBlock = rectangle( + { + x: rangeRect.x, + y: rangeRect.y, + dx: rangeRect.dx, + dy: rangeRect.dy, + fill: sel.color ?? this.renderConfig.selectionColor ?? 'rgba(0, 0, 255, 0.2)' + }, + [] + ); + overlayChildren.push(highlightBlock); + } + } + } + } + + if (overlayChildren.length === 0) { + return null; + } + + return portal( + { + fVisible: true + }, + overlayChildren.length === 1 + ? [overlayChildren[0]!] + : [group({}, overlayChildren)] + ); + } + + /** + * Handle pointer down: start or update selection. + * charIndex: character index where pointer down occurred (result of hit-testing). + * blockId: ID of the text block under the pointer. + */ + handlePointerDown(blockId: string, charIndex: number, userId?: string): void { + if (this.getTextLengthForBlock(blockId) === 0) { + return; + } + + const selectionKey = this.getSelectionKey(userId); + this.preferredXBySelection.delete(selectionKey); + this.isDragging = true; + this.dragStartBlockId = blockId; + this.dragStartCharIndex = charIndex; + + // Place caret at click position + this.setCaretSelection(blockId, charIndex, userId); + } + + /** + * Handle pointer move: extend selection during drag. + * charIndex: character index under the pointer. + * userId: optional user ID. + */ + handlePointerMove(charIndex: number, userId?: string): void { + if (!this.isDragging || this.dragStartCharIndex === null || this.dragStartBlockId === null) { + return; + } + + const sel = this.getSelection(userId); + if (!sel || sel.blockId !== this.dragStartBlockId) { + return; + } + + // Extend selection from anchor to current position + const anchor = Math.min(this.dragStartCharIndex, charIndex); + const focus = Math.max(this.dragStartCharIndex, charIndex); + + this.setFocusSelection(this.dragStartBlockId, anchor, focus, userId); + } + + /** + * Handle pointer up: end drag operation. + */ + handlePointerUp(): void { + this.isDragging = false; + this.dragStartBlockId = null; + this.dragStartCharIndex = null; + } + + /** + * Hit-test within a specific block. + * blockId: the text block to test + * x, y: screen coordinates + * maxChars: optional maximum characters in the block. + * Returns insertion index (caret position) or null if no hit. + */ + hitTestBlockCharacter(blockId: string, x: number, y: number, maxChars?: number): number | null { + const points = this.collectInsertionPoints(blockId); + if (points.length === 0) { + return null; + } + + const dxHitPadding = 6; + const lineGroups = this.groupInsertionPointsByLine(points) + .map((lineGroup) => ({ + ...lineGroup, + points: [...lineGroup.points].sort((a, b) => a.index - b.index) + })) + .sort((a, b) => a.y - b.y); + + for (const lineGroup of lineGroups) { + const isInVerticalRange = y >= lineGroup.y && y < lineGroup.y + lineGroup.height; + if (!isInVerticalRange) { + continue; + } + if (maxChars !== undefined) { + const pointsWithinLimit = lineGroup.points.filter((point) => point.index <= maxChars); + if (pointsWithinLimit.length === 0) { + continue; + } + const xMinRow = Math.min(...pointsWithinLimit.map((point) => point.x)); + const xMaxRow = Math.max(...pointsWithinLimit.map((point) => point.x)); + if (x < xMinRow - dxHitPadding || x > xMaxRow + dxHitPadding) { + return null; + } + return this.getNearestPointByX(pointsWithinLimit, x).index; + } + + const xMinRow = Math.min(...lineGroup.points.map((point) => point.x)); + const xMaxRow = Math.max(...lineGroup.points.map((point) => point.x)); + if (x < xMinRow - dxHitPadding || x > xMaxRow + dxHitPadding) { + return null; + } + return this.getNearestPointByX(lineGroup.points, x).index; + } + + return null; + } + + /** + * Handle keyboard event for selection navigation and editing. + * Returns whether the key was handled. + */ + private handleHorizontalArrowKey( + key: 'ArrowLeft' | 'ArrowRight', + selection: Selection, + shiftKey: boolean, + textLength: number, + userId?: string + ): boolean { + if (shiftKey) { + if (key === 'ArrowLeft') { + const focus = selection.focus > 0 ? selection.focus - 1 : selection.focus; + this.setFocusSelection(selection.blockId, selection.anchor, focus, userId); + return true; + } + const focus = selection.focus < textLength ? selection.focus + 1 : selection.focus; + this.setFocusSelection(selection.blockId, selection.anchor, focus, userId); + return true; + } + + if (key === 'ArrowLeft') { + const newPos = selection.focus < selection.anchor ? selection.anchor - 1 : selection.focus - 1; + if (newPos >= 0) { + this.setCaretSelection(selection.blockId, newPos, userId); + } + return true; + } + + const newPos = selection.focus > selection.anchor ? selection.focus + 1 : selection.anchor + 1; + if (newPos <= textLength) { + this.setCaretSelection(selection.blockId, newPos, userId); + } + return true; + } + + private getVerticalSourceIndex(selection: Selection, direction: -1 | 1, shiftKey: boolean): number { + if (shiftKey || selection.anchor === selection.focus) { + return selection.focus; + } + return direction < 0 + ? Math.min(selection.anchor, selection.focus) + : Math.max(selection.anchor, selection.focus); + } + + private ensurePreferredX(selection: Selection, sourceIndex: number, selectionKey: string): void { + const sourceBounds = this.getInsertionBounds(selection.blockId, sourceIndex); + if (sourceBounds && !this.preferredXBySelection.has(selectionKey)) { + this.preferredXBySelection.set(selectionKey, sourceBounds.x); + } + } + + private handleVerticalArrowKey( + key: 'ArrowUp' | 'ArrowDown', + selection: Selection, + shiftKey: boolean, + selectionKey: string, + userId?: string + ): boolean { + const direction: -1 | 1 = key === 'ArrowUp' ? -1 : 1; + const sourceIndex = this.getVerticalSourceIndex(selection, direction, shiftKey); + this.ensurePreferredX(selection, sourceIndex, selectionKey); + + const targetIndex = this.moveVerticalInsertion(selection.blockId, sourceIndex, direction, selectionKey); + if (targetIndex === null) { + return true; + } + + if (shiftKey) { + this.setFocusSelection(selection.blockId, selection.anchor, targetIndex, userId); + return true; + } + + this.setCaretSelection(selection.blockId, targetIndex, userId); + return true; + } + + private handleBoundaryNavigationKey( + key: 'Home' | 'End', + selection: Selection, + shiftKey: boolean, + textLength: number, + userId?: string + ): boolean { + const target = key === 'Home' ? 0 : textLength; + if (shiftKey) { + this.setFocusSelection(selection.blockId, selection.anchor, target, userId); + return true; + } + this.setCaretSelection(selection.blockId, target, userId); + return true; + } + + handleKeyDown(key: string, shiftKey: boolean, ctrlKey: boolean, userId?: string): boolean { + const selection = this.getSelection(userId); + if (!selection) { + return false; + } + + const selectionKey = this.getSelectionKey(userId); + const textLength = this.getTextLengthForBlock(selection.blockId); + + switch (key) { + case 'ArrowLeft': + case 'ArrowRight': { + this.preferredXBySelection.delete(selectionKey); + return this.handleHorizontalArrowKey(key, selection, shiftKey, textLength, userId); + } + case 'ArrowUp': + case 'ArrowDown': { + return this.handleVerticalArrowKey(key, selection, shiftKey, selectionKey, userId); + } + case 'Home': + case 'End': { + this.preferredXBySelection.delete(selectionKey); + return this.handleBoundaryNavigationKey(key, selection, shiftKey, textLength, userId); + } + case 'a': { + this.preferredXBySelection.delete(selectionKey); + if (ctrlKey) { + this.setSelection(selection.blockId, 0, textLength, userId); + return true; + } + return false; + } + default: + return false; + } + } +} diff --git a/packages/gui/src/selection/character-bounds-adapter.ts b/packages/gui/src/selection/character-bounds-adapter.ts new file mode 100644 index 0000000..d07ce05 --- /dev/null +++ b/packages/gui/src/selection/character-bounds-adapter.ts @@ -0,0 +1,266 @@ +// Copyright (c) 2026 François Rouaix + +import { + BlockType, + Matrix2D, + Canvas2DContext, + layoutTextCharacterBounds, + getBlockTransform, + transformRc +} from 'vitrine'; +import type { + Block, + Rc, + RenderContext, + TextProps +} from 'vitrine'; +import type { CharacterBoundsProvider } from './TextSelectionManager.ts'; + +export interface CharacterBoundsAdapterOptions { + /** + * Optional measurement context. When omitted, the adapter attempts to create + * an internal fallback 2D measurement context and falls back to approximated + * metrics if that is unavailable. + */ + context?: RenderContext; +} + +export interface CharacterBoundsUpdateResult { + changedBlockIds: string[]; + selectableTextBlockIds: string[]; +} + +interface TextBlockDescriptor { + blockId: string; + text: string; + props: TextProps; + transformWorld: Matrix2D; + layoutSignature: string; + worldSignature: string; +} + +/** Cache bounds per character for text block, keyed by id+hash of text/props/transform */ +interface TextBoundsCacheEntry { + descriptor: TextBlockDescriptor; + rgrcl?: Rc[]; + rgrcs?: Rc[]; +} + +function createFallbackRenderContext(): RenderContext | undefined { + if (typeof document === 'undefined') { + return undefined; + } + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + return undefined; + } + return new Canvas2DContext(ctx); +} + +function signaturePart(value: unknown): string { + if (value === undefined) { + return 'u'; + } + if (value === null) { + return 'n'; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + return 'nan'; + } + return String(value); + } + return String(value); +} + +function buildTextLayoutSignature(props: TextProps): string { + return [ + props.text, + props.font, + props.fontSize, + props.align, + props.baseline, + props.dx, + props.dy, + props.dyLineHeight + ].map(signaturePart).join('|'); +} + +function buildWorldTransformSignature(transform: Matrix2D): string { + return [ + transform.a, + transform.b, + transform.c, + transform.d, + transform.e, + transform.f + ].map(signaturePart).join('|'); +} + +function toTextDescriptor(block: Block, xfCur: Matrix2D): TextBlockDescriptor | null { + if ( + typeof block.props.id !== 'string' + || block.props.id.length === 0 + ) { + return null; + } + + const props = block.props as TextProps; + const blockId = block.props.id; + return { + blockId, + text: props.text, + props, + transformWorld: xfCur, + layoutSignature: buildTextLayoutSignature(props), + worldSignature: buildWorldTransformSignature(xfCur) + }; +} + +function transformBoundsCollection(rgrcl: Rc[], xf: Matrix2D): Rc[] { + return rgrcl.map((rc) => transformRc(rc, xf)); +} + +export class CharacterBoundsAdapter { + private context: RenderContext | undefined; + private cacheByBlockId: Map = new Map(); + private selectableTextBlockIds: string[] = []; + + constructor(options: CharacterBoundsAdapterOptions = {}) { + this.context = options.context ?? createFallbackRenderContext(); + } + + private ensureRgrcl(entry: TextBoundsCacheEntry): Rc[] { + if (!entry.rgrcl) { + entry.rgrcl = layoutTextCharacterBounds( + entry.descriptor.text, + entry.descriptor.props, + this.context + ); + } + return entry.rgrcl; + } + + private ensureRgrcs(entry: TextBoundsCacheEntry): Rc[] { + if (!entry.rgrcs) { + entry.rgrcs = transformBoundsCollection(this.ensureRgrcl(entry), entry.descriptor.transformWorld); + } + return entry.rgrcs; + } + + 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) { + const current = stack.pop(); + if (!current) { + continue; + } + + const { block, transform: transformParent } = current; + const transformWorld = transformParent.multiply(getBlockTransform(block.props)); + if (block.type === BlockType.Text) { + const descriptor = toTextDescriptor(block, transformWorld); + if (descriptor) { + descriptorsByBlockId.set(descriptor.blockId, descriptor); + } + } + + const rgblChildren = block.rgblChildren; + if (!rgblChildren) { + continue; + } + + for (let i = rgblChildren.length - 1; i >= 0; i--) { + const child = rgblChildren[i]; + if (!child) { + continue; + } + stack.push({ block: child, transform: transformWorld }); + } + } + + return descriptorsByBlockId; + } + + updateFromBlockTree(blRoot: Block): CharacterBoundsUpdateResult { + const descriptorsByBlockId = this.buildDescriptorMap(blRoot); + const nextCacheByBlockId = new Map(); + const changedBlockIds = new Set(); + const selectableTextBlockIds = Array.from(descriptorsByBlockId.keys()); + + for (const [blockId, descriptor] of descriptorsByBlockId.entries()) { + const previous = this.cacheByBlockId.get(blockId); + if (!previous) { + nextCacheByBlockId.set(blockId, { descriptor }); + changedBlockIds.add(blockId); + continue; + } + + const layoutChanged = previous.descriptor.layoutSignature !== descriptor.layoutSignature; + const worldChanged = previous.descriptor.worldSignature !== descriptor.worldSignature; + if (layoutChanged || worldChanged) { + changedBlockIds.add(blockId); + } + + nextCacheByBlockId.set(blockId, { + descriptor, + rgrcl: layoutChanged ? undefined : previous.rgrcl, + rgrcs: layoutChanged || worldChanged ? undefined : previous.rgrcs + }); + } + + for (const blockId of this.cacheByBlockId.keys()) { + if (!nextCacheByBlockId.has(blockId)) { + changedBlockIds.add(blockId); + } + } + + this.cacheByBlockId = nextCacheByBlockId; + this.selectableTextBlockIds = selectableTextBlockIds; + + return { + changedBlockIds: Array.from(changedBlockIds), + selectableTextBlockIds: [...this.selectableTextBlockIds] + }; + } + + getSelectableTextBlockIds(): string[] { + return [...this.selectableTextBlockIds]; + } + + getProvider(): CharacterBoundsProvider { + return (blockId: string, charIndex: number): Rc | null => { + if (charIndex < 0) { + return null; + } + const entry = this.cacheByBlockId.get(blockId); + if (!entry) { + return null; + } + const boundsWorld = this.ensureRgrcs(entry); + return boundsWorld[charIndex] ?? null; + }; + } +} + +export function createCharacterBoundsAdapter( + options: CharacterBoundsAdapterOptions = {} +): CharacterBoundsAdapter { + return new CharacterBoundsAdapter(options); +} + +/** + * Build a CharacterBoundsProvider from a rendered block tree. + * The provider uses the same text metrics/wrap/alignment conventions as Vitrine's renderer. + */ +export function createCharacterBoundsProviderFromBlockTree( + root: Block, + options: CharacterBoundsAdapterOptions = {} +): CharacterBoundsProvider { + const adapter = createCharacterBoundsAdapter(options); + adapter.updateFromBlockTree(root); + return adapter.getProvider(); +} diff --git a/packages/texta/package.json b/packages/texta/package.json index d0e42c5..831e3be 100644 --- a/packages/texta/package.json +++ b/packages/texta/package.json @@ -23,6 +23,9 @@ "files": [ "dist" ], + "dependencies": { + "vitrine": "workspace:*" + }, "scripts": { "build": "tsc", "lint": "tsc --noEmit", @@ -36,4 +39,4 @@ "typescript": "^5.7.3", "vitest": "^3.0.0" } -} \ No newline at end of file +} diff --git a/packages/texta/src/browser.ts b/packages/texta/src/browser.ts index 8e11368..c80206c 100644 --- a/packages/texta/src/browser.ts +++ b/packages/texta/src/browser.ts @@ -77,3 +77,10 @@ export { type RenderDecorationKind, type RenderBridgeDecorationRange } from "./render-bridges.ts"; + +export { + registerTextaBlockType, + texta, + stBlockTypeTexta, + type TextaBlockProps +} from "./vitrine.ts"; diff --git a/packages/texta/src/index.ts b/packages/texta/src/index.ts index 4541a24..ce43ac4 100644 --- a/packages/texta/src/index.ts +++ b/packages/texta/src/index.ts @@ -80,6 +80,13 @@ export { type RenderBridgeDecorationRange } from "./render-bridges.ts"; +export { + registerTextaBlockType, + texta, + stBlockTypeTexta, + type TextaBlockProps +} from "./vitrine.ts"; + export { getTextaBaselineBenchmarkScenario, runBenchmarkScenario, diff --git a/packages/texta/src/vitrine.ts b/packages/texta/src/vitrine.ts new file mode 100644 index 0000000..be1ed68 --- /dev/null +++ b/packages/texta/src/vitrine.ts @@ -0,0 +1,355 @@ +import type { AttributedTextValue } from './types.ts'; +import { getRgRenderBridgeRun } from './render-bridges.ts'; +import { + customBlock, + registerBlockType, + measureText, + calculateTextOffset, + SF_TEXT_ADVANCE_APPROX_DEFAULT +} from 'vitrine'; +import type { + BaseBlockProps, + Block, + Rc, + CustomBlockHandlers, + TextMeasure +} from 'vitrine'; + +export const stBlockTypeTexta = 'texta'; + +type StyleEntryLike = { + fontFamily?: string; + fontSize?: number; + fontWeight?: string; + fontStyle?: string; + lineHeight?: number; + fill?: string; + background?: string; + stroke?: string; + opacity?: number; +}; + +type Segment = { + text: string; + style: StyleEntryLike; +}; + +type SegmentMetrics = { + text: string; + style: StyleEntryLike; + font: string | undefined; + width: number; + ascent: number; + descent: number; + fontSize: number; + lineHeight: number; +}; + +export interface TextaBlockProps extends BaseBlockProps { + texta: AttributedTextValue; + align?: 'left' | 'center' | 'right' | 'start' | 'end'; + baseline?: 'top' | 'middle' | 'bottom' | 'alphabetic' | 'hanging'; + fill?: string; + stroke?: string; + strokeWidth?: number; + font?: string; + fontSize?: number; + lineHeight?: number; + dx?: number; +} + +function getDefaultStyle(props: TextaBlockProps): StyleEntryLike { + const { texta } = props; + const mpStyleById = texta.mpId_StyleEntry as Record; + return mpStyleById[texta.idStyleDefault] ?? {}; +} + +function buildFont(style: StyleEntryLike, styleDefault: StyleEntryLike, fontDefault?: string): string | undefined { + if (fontDefault && !style.fontFamily && !style.fontWeight && !style.fontStyle && style.fontSize === undefined) { + return fontDefault; + } + const fontFamily = style.fontFamily ?? styleDefault.fontFamily; + if (!fontFamily) { + return undefined; + } + const fontStyle = style.fontStyle ?? styleDefault.fontStyle ?? 'normal'; + const fontWeight = style.fontWeight ?? styleDefault.fontWeight ?? 'normal'; + const fontSize = style.fontSize ?? styleDefault.fontSize ?? 16; + return `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`; +} + +function getFontSize(style: StyleEntryLike, styleDefault: StyleEntryLike, fontSizeDefault?: number): number { + return style.fontSize ?? fontSizeDefault ?? styleDefault.fontSize ?? 16; +} + +function getLineHeight(style: StyleEntryLike, styleDefault: StyleEntryLike, lineHeightDefault?: number, fontSizeDefault?: number): number { + const fontSize = getFontSize(style, styleDefault, fontSizeDefault); + return style.lineHeight ?? lineHeightDefault ?? styleDefault.lineHeight ?? fontSize * 1.4; +} + +function measureSegment( + segment: Segment, + styleDefault: StyleEntryLike, + contextMeasure: (text: string, props: { font?: string; fontSize?: number }) => TextMeasure, + defaults: Pick +): SegmentMetrics { + const fontSize = getFontSize(segment.style, styleDefault, defaults.fontSize); + const font = buildFont(segment.style, styleDefault, defaults.font); + const metrics = contextMeasure(segment.text, font ? { font } : { fontSize }); + return { + text: segment.text, + style: segment.style, + font, + width: metrics.width, + ascent: metrics.ascent, + descent: metrics.descent, + fontSize, + lineHeight: getLineHeight(segment.style, styleDefault, defaults.lineHeight, defaults.fontSize) + }; +} + +function splitRunLines(props: TextaBlockProps): Segment[][] { + const runs = getRgRenderBridgeRun(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) { + lineSegments.push([]); + } + } + } + + return lineSegments; +} + +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); + + let lineMetrics: SegmentMetrics[][]; + if (props.dx !== undefined) { + lineMetrics = []; + 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 linesVisual: SegmentMetrics[][] = [[]]; + let widthCurrent = 0; + for (const atom of atoms) { + if (widthCurrent + atom.width > props.dx && linesVisual[linesVisual.length - 1]!.length > 0) { + linesVisual.push([]); + widthCurrent = 0; + } + linesVisual[linesVisual.length - 1]!.push(atom); + widthCurrent += atom.width; + } + for (const line of linesVisual) { + lineMetrics.push(line); + } + } + } else { + lineMetrics = lineSegments.map((segments) => + segments.map((segment) => measureSegment(segment, styleDefault, contextMeasure, props)) + ); + } + + const lineWidths = lineMetrics.map((line) => line.reduce((sum, segment) => sum + segment.width, 0)); + const lineHeights = lineMetrics.map((line) => { + if (line.length === 0) { + return props.lineHeight ?? props.fontSize ?? styleDefault.fontSize ?? 16; + } + return Math.max(...line.map((segment) => segment.lineHeight)); + }); + const lineAscents = lineMetrics.map((line, i) => { + if (line.length === 0) { + return props.fontSize ?? styleDefault.fontSize ?? 16; + } + return Math.max(...line.map((segment) => segment.ascent), lineHeights[i]! * 0.7); + }); + + 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 + ); + return { x: xOffset, y: yOffset, width, height: totalHeight }; +} + +function createTextaHandlers(): CustomBlockHandlers { + return { + 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) { + 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; + } + + 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) { + const { style } = segment; + const fill = style.fill + ?? (typeof fillDefault === 'string' ? fillDefault : undefined) + ?? styleDefault.fill; + const background = style.background; + const stroke = style.stroke + ?? (typeof strokeDefault === 'string' ? strokeDefault : undefined) + ?? styleDefault.stroke; + const opacity = style.opacity ?? 1; + + if (!fill && !stroke && !background) { + xRun += segment.width; + continue; + } + + api.context.save(); + api.context.setOpacity(api.context.opacity * opacity); + + if (background) { + const bgHeight = segment.ascent + segment.descent; + api.context.drawRectangle(xRun, yBaseline - segment.ascent, segment.width, bgHeight, { fill: background }); + } + + api.context.drawText(segment.text, xRun, yBaseline, { + font: segment.font, + fontSize: segment.fontSize, + fill, + stroke, + strokeWidth: strokeWidthDefault, + align: 'left', + baseline: 'alphabetic' + }); + api.context.restore(); + xRun += segment.width; + } + + yBaseline += lineHeights[i] ?? 0; + } + }, + hitTestShape: (block, xl, yl, { layoutCache }): boolean => { + const cachedBounds = layoutCache?.mpbl_rc.get(block as unknown as Block); + if (cachedBounds) { + return xl >= cachedBounds.x + && xl <= cachedBounds.x + cachedBounds.width + && yl >= cachedBounds.y + && yl <= cachedBounds.y + cachedBounds.height; + } + const bounds = estimateBounds(block.props as unknown as TextaBlockProps); + 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) + }; +} + +let fRegistered = false; + +export function registerTextaBlockType(): void { + if (fRegistered) { + return; + } + registerBlockType(stBlockTypeTexta, createTextaHandlers()); + fRegistered = true; +} + +export function texta(props: TextaBlockProps, children?: Block[]): Block { + return customBlock(stBlockTypeTexta, props, children); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0314b08..6618681 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,10 +13,6 @@ importers: version: 6.3.0 packages/core: - dependencies: - texta: - specifier: workspace:* - version: link:../texta devDependencies: '@types/node': specifier: ^22.10.5 @@ -82,6 +78,10 @@ importers: version: 5.9.3 packages/texta: + dependencies: + vitrine: + specifier: workspace:* + version: link:../core devDependencies: '@types/node': specifier: ^22.10.5 diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..e69de29 diff --git a/tsconfig.base.json b/tsconfig.base.json index 4de5e5f..d3af61b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -12,5 +12,6 @@ "rewriteRelativeImportExtensions": true, "resolveJsonModule": true, "allowSyntheticDefaultImports": true - } + }, + "exclude": ["**/*.d.ts"] }