Refactor/reacrhitecture to fsd+ #49
@@ -1,183 +0,0 @@
|
|||||||
import {
|
|
||||||
layoutWithLines,
|
|
||||||
prepareWithSegments,
|
|
||||||
} from '@chenglou/pretext';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single laid-out line of text, with per-grapheme x offsets and widths.
|
|
||||||
*
|
|
||||||
* `chars` is indexed by grapheme cluster (not UTF-16 code unit), so emoji
|
|
||||||
* sequences and combining characters each produce exactly one entry.
|
|
||||||
*/
|
|
||||||
export interface LayoutLine {
|
|
||||||
/**
|
|
||||||
* Full text of this line as returned by pretext.
|
|
||||||
*/
|
|
||||||
text: string;
|
|
||||||
/**
|
|
||||||
* Rendered width of this line in pixels.
|
|
||||||
*/
|
|
||||||
width: number;
|
|
||||||
/**
|
|
||||||
* Individual character metadata for this line
|
|
||||||
*/
|
|
||||||
chars: Array<{
|
|
||||||
/**
|
|
||||||
* The grapheme cluster string (may be >1 code unit for emoji, etc.).
|
|
||||||
*/
|
|
||||||
char: string;
|
|
||||||
/**
|
|
||||||
* X offset from the start of the line, in pixels.
|
|
||||||
*/
|
|
||||||
x: number;
|
|
||||||
/**
|
|
||||||
* Advance width of this grapheme, in pixels.
|
|
||||||
*/
|
|
||||||
width: number;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregated output of a single-font layout pass.
|
|
||||||
*/
|
|
||||||
export interface LayoutResult {
|
|
||||||
/**
|
|
||||||
* Per-line grapheme data. Empty when input text is empty.
|
|
||||||
*/
|
|
||||||
lines: LayoutLine[];
|
|
||||||
/**
|
|
||||||
* Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee).
|
|
||||||
*/
|
|
||||||
totalHeight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single-font text layout engine backed by `@chenglou/pretext`.
|
|
||||||
*
|
|
||||||
* Replaces the canvas-DOM hybrid `createCharacterComparison` for cases where
|
|
||||||
* only one font is needed. For dual-font comparison use `CharacterComparisonEngine`.
|
|
||||||
*
|
|
||||||
* **Usage**
|
|
||||||
* ```ts
|
|
||||||
* const engine = new TextLayoutEngine();
|
|
||||||
* const result = engine.layout('Hello World', '400 16px "Inter"', 320, 24);
|
|
||||||
* // result.lines[0].chars → [{ char: 'H', x: 0, width: 9 }, ...]
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* **Font string format:** `"${weight} ${size}px \"${family}\""` — e.g. `'400 16px "Inter"'`.
|
|
||||||
* This matches what SliderArea constructs from `typography.weight` and `typography.renderedSize`.
|
|
||||||
*
|
|
||||||
* **Canvas requirement:** pretext calls `document.createElement('canvas').getContext('2d')` on
|
|
||||||
* first use and caches the context for the process lifetime. Tests must install a canvas mock
|
|
||||||
* (see `__mocks__/canvas.ts`) before the first `layout()` call.
|
|
||||||
*
|
|
||||||
* @deprecated No live consumers remain — the only previous caller
|
|
||||||
* (`createFontRowSizeResolver`) now invokes pretext's `prepare` + `layout`
|
|
||||||
* directly (per pretext's "hot-path resize function" guidance). If you need
|
|
||||||
* single-font height-only measurement, use `prepare` + `layout` from
|
|
||||||
* `@chenglou/pretext` directly. If you need per-grapheme x/width data, see
|
|
||||||
* `CharacterComparisonEngine` (dual-font) or revive a slimmer wrapper.
|
|
||||||
* Slated for removal once it has been absent from `main` for a release cycle.
|
|
||||||
*/
|
|
||||||
export class TextLayoutEngine {
|
|
||||||
/**
|
|
||||||
* Grapheme segmenter used to split segment text into individual clusters.
|
|
||||||
*
|
|
||||||
* Pretext maintains its own internal segmenter for line-breaking decisions.
|
|
||||||
* We keep a separate one here so we can iterate graphemes in `layout()`
|
|
||||||
* without depending on pretext internals — the two segmenters produce
|
|
||||||
* identical boundaries because both use `{ granularity: 'grapheme' }`.
|
|
||||||
*/
|
|
||||||
#segmenter: Intl.Segmenter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param locale BCP 47 language tag passed to Intl.Segmenter. Defaults to the runtime locale.
|
|
||||||
*/
|
|
||||||
constructor(locale?: string) {
|
|
||||||
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lay out `text` in the given `font` within `width` pixels.
|
|
||||||
*
|
|
||||||
* @param text Raw text to lay out.
|
|
||||||
* @param font CSS font string: `"weight sizepx \"family\""`.
|
|
||||||
* @param width Available line width in pixels.
|
|
||||||
* @param lineHeight Line height in pixels (passed directly to pretext).
|
|
||||||
* @returns Per-line grapheme data. Empty `lines` when `text` is empty.
|
|
||||||
*/
|
|
||||||
layout(text: string, font: string, width: number, lineHeight: number): LayoutResult {
|
|
||||||
if (!text) {
|
|
||||||
return { lines: [], totalHeight: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// prepareWithSegments measures the text and builds the segment data structure
|
|
||||||
// (widths, breakableFitAdvances, etc.) that the line-walker consumes.
|
|
||||||
const prepared = prepareWithSegments(text, font);
|
|
||||||
const { lines, height } = layoutWithLines(prepared, width, lineHeight);
|
|
||||||
|
|
||||||
// `PreparedTextWithSegments` has these fields in its public type definition
|
|
||||||
// but the TypeScript signature only exposes `segments`. We cast to `any` to
|
|
||||||
// access the parallel numeric arrays — they are documented in the plan and
|
|
||||||
// verified against the pretext source at node_modules/@chenglou/pretext/src/layout.ts.
|
|
||||||
const internal = prepared as any;
|
|
||||||
const breakableFitAdvances = internal.breakableFitAdvances as (number[] | null)[];
|
|
||||||
const widths = internal.widths as number[];
|
|
||||||
|
|
||||||
const resultLines: LayoutLine[] = lines.map(line => {
|
|
||||||
const chars: LayoutLine['chars'] = [];
|
|
||||||
let currentX = 0;
|
|
||||||
|
|
||||||
const start = line.start;
|
|
||||||
const end = line.end;
|
|
||||||
|
|
||||||
// Walk every segment that falls within this line's [start, end] cursors.
|
|
||||||
// Both cursors are grapheme-level: start is inclusive, end is exclusive.
|
|
||||||
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
|
|
||||||
const segmentText = prepared.segments[sIdx];
|
|
||||||
if (segmentText === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
|
|
||||||
const advances = breakableFitAdvances[sIdx];
|
|
||||||
|
|
||||||
// For the first and last segments of the line the cursor may point
|
|
||||||
// into the middle of the segment — respect those boundaries.
|
|
||||||
// All intermediate segments are walked in full (gStart=0, gEnd=length).
|
|
||||||
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
|
|
||||||
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
|
|
||||||
|
|
||||||
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
|
|
||||||
const char = graphemes[gIdx];
|
|
||||||
|
|
||||||
// `breakableFitAdvances[sIdx]` is an array of per-grapheme advance
|
|
||||||
// widths when the segment has >1 grapheme (multi-character words).
|
|
||||||
// It is `null` for single-grapheme segments (spaces, punctuation,
|
|
||||||
// emoji, etc.) — in that case the entire segment width is attributed
|
|
||||||
// to this single grapheme.
|
|
||||||
const charWidth = advances != null ? advances[gIdx]! : widths[sIdx]!;
|
|
||||||
|
|
||||||
chars.push({
|
|
||||||
char,
|
|
||||||
x: currentX,
|
|
||||||
width: charWidth,
|
|
||||||
});
|
|
||||||
currentX += charWidth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
text: line.text,
|
|
||||||
width: line.width,
|
|
||||||
chars,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
lines: resultLines,
|
|
||||||
// pretext guarantees height === lineCount * lineHeight (see layout.ts source).
|
|
||||||
totalHeight: height,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// @vitest-environment jsdom
|
|
||||||
import { clearCache } from '@chenglou/pretext';
|
|
||||||
import {
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
} from 'vitest';
|
|
||||||
import { installCanvasMock } from '../__mocks__/canvas';
|
|
||||||
import { TextLayoutEngine } from './TextLayoutEngine.svelte';
|
|
||||||
|
|
||||||
// Fixed-width mock: every segment is measured as (text.length * 10) px.
|
|
||||||
// This is font-independent so we can reason about wrapping precisely.
|
|
||||||
const CHAR_WIDTH = 10;
|
|
||||||
|
|
||||||
describe('TextLayoutEngine', () => {
|
|
||||||
let engine: TextLayoutEngine;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Install mock BEFORE any prepareWithSegments call.
|
|
||||||
// clearMeasurementCaches resets pretext's cached canvas context
|
|
||||||
// and segment metric caches so each test gets a clean slate.
|
|
||||||
installCanvasMock((_font, text) => text.length * CHAR_WIDTH);
|
|
||||||
clearCache();
|
|
||||||
engine = new TextLayoutEngine();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty result for empty string', () => {
|
|
||||||
const result = engine.layout('', '400 16px "Inter"', 500, 20);
|
|
||||||
expect(result.lines).toHaveLength(0);
|
|
||||||
expect(result.totalHeight).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a single line when text fits within width', () => {
|
|
||||||
// 'ABC' = 3 chars × 10px = 30px, fits in 500px
|
|
||||||
const result = engine.layout('ABC', '400 16px "Inter"', 500, 20);
|
|
||||||
expect(result.lines).toHaveLength(1);
|
|
||||||
expect(result.lines[0].text).toBe('ABC');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('breaks text into multiple lines when it exceeds width', () => {
|
|
||||||
// 'Hello World' — pretext will split at the space.
|
|
||||||
// 'Hello' = 50px, ' ' hangs, 'World' = 50px. Width = 60px forces wrap after 'Hello '.
|
|
||||||
const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20);
|
|
||||||
expect(result.lines.length).toBeGreaterThan(1);
|
|
||||||
// First line must not exceed the container width.
|
|
||||||
expect(result.lines[0].width).toBeLessThanOrEqual(60);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('assigns correct x positions to characters on a single line', () => {
|
|
||||||
// 'ABC': A=10px, B=10px, C=10px; all on one line in 500px container.
|
|
||||||
const result = engine.layout('ABC', '400 16px "Inter"', 500, 20);
|
|
||||||
const chars = result.lines[0].chars;
|
|
||||||
|
|
||||||
expect(chars).toHaveLength(3);
|
|
||||||
expect(chars[0].char).toBe('A');
|
|
||||||
expect(chars[0].x).toBe(0);
|
|
||||||
expect(chars[0].width).toBe(CHAR_WIDTH);
|
|
||||||
|
|
||||||
expect(chars[1].char).toBe('B');
|
|
||||||
expect(chars[1].x).toBe(CHAR_WIDTH);
|
|
||||||
expect(chars[1].width).toBe(CHAR_WIDTH);
|
|
||||||
|
|
||||||
expect(chars[2].char).toBe('C');
|
|
||||||
expect(chars[2].x).toBe(CHAR_WIDTH * 2);
|
|
||||||
expect(chars[2].width).toBe(CHAR_WIDTH);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('x positions are monotonically increasing across a line', () => {
|
|
||||||
const result = engine.layout('ABCDE', '400 16px "Inter"', 500, 20);
|
|
||||||
const chars = result.lines[0].chars;
|
|
||||||
for (let i = 1; i < chars.length; i++) {
|
|
||||||
expect(chars[i].x).toBeGreaterThan(chars[i - 1].x);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('each line has at least one char', () => {
|
|
||||||
const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20);
|
|
||||||
for (const line of result.lines) {
|
|
||||||
expect(line.chars.length).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('totalHeight equals lineCount * lineHeight', () => {
|
|
||||||
const lineHeight = 24;
|
|
||||||
const result = engine.layout('Hello World', '400 16px "Inter"', 60, lineHeight);
|
|
||||||
expect(result.totalHeight).toBe(result.lines.length * lineHeight);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -115,24 +115,6 @@ export {
|
|||||||
type EntityStore,
|
type EntityStore,
|
||||||
} from './createEntityStore/createEntityStore.svelte';
|
} from './createEntityStore/createEntityStore.svelte';
|
||||||
|
|
||||||
/**
|
|
||||||
* Text layout
|
|
||||||
*/
|
|
||||||
export {
|
|
||||||
/**
|
|
||||||
* Single line layout information
|
|
||||||
*/
|
|
||||||
type LayoutLine as TextLayoutLine,
|
|
||||||
/**
|
|
||||||
* Full multi-line layout information
|
|
||||||
*/
|
|
||||||
type LayoutResult as TextLayoutResult,
|
|
||||||
/**
|
|
||||||
* High-level text measurement engine
|
|
||||||
*/
|
|
||||||
TextLayoutEngine,
|
|
||||||
} from './TextLayoutEngine/TextLayoutEngine.svelte';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistence
|
* Persistence
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ export {
|
|||||||
type Property,
|
type Property,
|
||||||
type ResponsiveManager,
|
type ResponsiveManager,
|
||||||
responsiveManager,
|
responsiveManager,
|
||||||
TextLayoutEngine,
|
|
||||||
type TextLayoutLine,
|
|
||||||
type TextLayoutResult,
|
|
||||||
type TypographyControl,
|
type TypographyControl,
|
||||||
type VirtualItem,
|
type VirtualItem,
|
||||||
type Virtualizer,
|
type Virtualizer,
|
||||||
|
|||||||
Reference in New Issue
Block a user