diff --git a/package.json b/package.json index 3bdf137..113ce24 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "vitest-browser-svelte": "^2.0.1" }, "dependencies": { + "@chenglou/pretext": "^0.0.5", "@tanstack/svelte-query": "^6.0.14" } } diff --git a/src/entities/Font/lib/index.ts b/src/entities/Font/lib/index.ts index 07d5f5f..161c396 100644 --- a/src/entities/Font/lib/index.ts +++ b/src/entities/Font/lib/index.ts @@ -48,3 +48,6 @@ export { FontNetworkError, FontResponseError, } from './errors/errors'; + +export { createFontRowSizeResolver } from './sizeResolver/createFontRowSizeResolver'; +export type { FontRowSizeResolverOptions } from './sizeResolver/createFontRowSizeResolver'; diff --git a/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.test.ts b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.test.ts new file mode 100644 index 0000000..33525d6 --- /dev/null +++ b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +import { TextLayoutEngine } from '$shared/lib'; +import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas'; +import { clearCache } from '@chenglou/pretext'; +import { + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import type { FontLoadStatus } from '../../model/types'; +import { mockUnifiedFont } from '../mocks'; +import { createFontRowSizeResolver } from './createFontRowSizeResolver'; + +// Fixed-width canvas mock: every character is 10px wide regardless of font. +// This makes wrapping math predictable: N chars × 10px = N×10 total width. +const CHAR_WIDTH = 10; +const LINE_HEIGHT = 20; +const CONTAINER_WIDTH = 200; +const CONTENT_PADDING_X = 32; // p-4 × 2 sides = 32px +const CHROME_HEIGHT = 56; +const FALLBACK_HEIGHT = 220; +const FONT_SIZE_PX = 16; + +describe('createFontRowSizeResolver', () => { + let statusMap: Map; + let getStatus: (key: string) => FontLoadStatus | undefined; + + beforeEach(() => { + installCanvasMock((_font, text) => text.length * CHAR_WIDTH); + clearCache(); + statusMap = new Map(); + getStatus = key => statusMap.get(key); + }); + + function makeResolver(overrides?: Partial[0]>) { + const font = mockUnifiedFont({ id: 'inter', name: 'Inter' }); + return { + font, + resolver: createFontRowSizeResolver({ + getFonts: () => [font], + getWeight: () => 400, + getPreviewText: () => 'Hello', + getContainerWidth: () => CONTAINER_WIDTH, + getFontSizePx: () => FONT_SIZE_PX, + getLineHeightPx: () => LINE_HEIGHT, + getStatus, + contentHorizontalPadding: CONTENT_PADDING_X, + chromeHeight: CHROME_HEIGHT, + fallbackHeight: FALLBACK_HEIGHT, + ...overrides, + }), + }; + } + + it('returns fallbackHeight when font status is undefined', () => { + const { resolver } = makeResolver(); + expect(resolver(0)).toBe(FALLBACK_HEIGHT); + }); + + it('returns fallbackHeight when font status is "loading"', () => { + const { resolver } = makeResolver(); + statusMap.set('inter@400', 'loading'); + expect(resolver(0)).toBe(FALLBACK_HEIGHT); + }); + + it('returns fallbackHeight when font status is "error"', () => { + const { resolver } = makeResolver(); + statusMap.set('inter@400', 'error'); + expect(resolver(0)).toBe(FALLBACK_HEIGHT); + }); + + it('returns fallbackHeight when containerWidth is 0', () => { + const { resolver } = makeResolver({ getContainerWidth: () => 0 }); + statusMap.set('inter@400', 'loaded'); + expect(resolver(0)).toBe(FALLBACK_HEIGHT); + }); + + it('returns fallbackHeight when previewText is empty', () => { + const { resolver } = makeResolver({ getPreviewText: () => '' }); + statusMap.set('inter@400', 'loaded'); + expect(resolver(0)).toBe(FALLBACK_HEIGHT); + }); + + it('returns fallbackHeight for out-of-bounds rowIndex', () => { + const { resolver } = makeResolver(); + statusMap.set('inter@400', 'loaded'); + expect(resolver(99)).toBe(FALLBACK_HEIGHT); + }); + + it('returns computed height (totalHeight + chromeHeight) when font is loaded', () => { + const { resolver } = makeResolver(); + statusMap.set('inter@400', 'loaded'); + + // 'Hello' = 5 chars × 10px = 50px. contentWidth = 200 - 32 = 168px. Fits on one line. + // totalHeight = 1 × LINE_HEIGHT = 20. result = 20 + CHROME_HEIGHT = 76. + const result = resolver(0); + expect(result).toBe(LINE_HEIGHT + CHROME_HEIGHT); + }); + + it('returns increased height when text wraps due to narrow container', () => { + // contentWidth = 40 - 32 = 8px — 'Hello' (50px) forces wrapping onto many lines + const { resolver } = makeResolver({ getContainerWidth: () => 40 }); + statusMap.set('inter@400', 'loaded'); + + const result = resolver(0); + expect(result).toBeGreaterThan(LINE_HEIGHT + CHROME_HEIGHT); + }); + + it('does not call layout() again on second call with same arguments', () => { + const { resolver } = makeResolver(); + statusMap.set('inter@400', 'loaded'); + + const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout'); + + resolver(0); + resolver(0); + + expect(layoutSpy).toHaveBeenCalledTimes(1); + layoutSpy.mockRestore(); + }); + + it('calls layout() again when containerWidth changes (cache miss)', () => { + let width = CONTAINER_WIDTH; + const { resolver } = makeResolver({ getContainerWidth: () => width }); + statusMap.set('inter@400', 'loaded'); + + const layoutSpy = vi.spyOn(TextLayoutEngine.prototype, 'layout'); + + resolver(0); + width = 100; + resolver(0); + + expect(layoutSpy).toHaveBeenCalledTimes(2); + layoutSpy.mockRestore(); + }); + + it('returns greater height when container narrows (more wrapping)', () => { + let width = CONTAINER_WIDTH; + const { resolver } = makeResolver({ getContainerWidth: () => width }); + statusMap.set('inter@400', 'loaded'); + + const h1 = resolver(0); + width = 100; // narrower → more wrapping + const h2 = resolver(0); + + expect(h2).toBeGreaterThanOrEqual(h1); + }); + + it('uses variable font key for variable fonts', () => { + const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } }); + const { resolver } = makeResolver({ getFonts: () => [vfFont] }); + // Variable fonts use '{id}@vf' key, not '{id}@{weight}' + statusMap.set('roboto@vf', 'loaded'); + const result = resolver(0); + expect(result).not.toBe(FALLBACK_HEIGHT); + expect(result).toBeGreaterThan(0); + }); + + it('returns fallbackHeight for variable font when static key is set instead', () => { + const vfFont = mockUnifiedFont({ id: 'roboto', name: 'Roboto', features: { isVariable: true } }); + const { resolver } = makeResolver({ getFonts: () => [vfFont] }); + // Setting the static key should NOT unlock computed height for variable fonts + statusMap.set('roboto@400', 'loaded'); + expect(resolver(0)).toBe(FALLBACK_HEIGHT); + }); +}); diff --git a/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts new file mode 100644 index 0000000..fe053c3 --- /dev/null +++ b/src/entities/Font/lib/sizeResolver/createFontRowSizeResolver.ts @@ -0,0 +1,112 @@ +import { TextLayoutEngine } from '$shared/lib'; +import { generateFontKey } from '../../model/store/appliedFontsStore/utils/generateFontKey/generateFontKey'; +import type { + FontLoadStatus, + UnifiedFont, +} from '../../model/types'; + +/** + * Options for {@link createFontRowSizeResolver}. + * + * All getter functions are called on every resolver invocation. When called + * inside a Svelte `$derived.by` block, any reactive state read within them + * (e.g. `SvelteMap.get()`) is automatically tracked as a dependency. + */ +export interface FontRowSizeResolverOptions { + /** Returns the current fonts array. Index `i` corresponds to row `i`. */ + getFonts: () => UnifiedFont[]; + /** Returns the active font weight (e.g. 400). */ + getWeight: () => number; + /** Returns the preview text string. */ + getPreviewText: () => string; + /** Returns the scroll container's inner width in pixels. Returns 0 before mount. */ + getContainerWidth: () => number; + /** Returns the font size in pixels (e.g. `controlManager.renderedSize`). */ + getFontSizePx: () => number; + /** + * Returns the computed line height in pixels. + * Typically `controlManager.height * controlManager.renderedSize`. + */ + getLineHeightPx: () => number; + /** + * Returns the font load status for a given font key (`'{id}@{weight}'` or `'{id}@vf'`). + * + * In production: `(key) => appliedFontsManager.statuses.get(key)`. + * Injected for testability — avoids a module-level singleton dependency in tests. + * The call to `.get()` on a `SvelteMap` must happen inside a `$derived.by` context + * for reactivity to work. This is satisfied when `itemHeight` is called by + * `createVirtualizer`'s `estimateSize`. + */ + getStatus: (fontKey: string) => FontLoadStatus | undefined; + /** + * Total horizontal padding of the text content area in pixels. + * Use the smallest breakpoint value (mobile `p-4` = 32px) to guarantee + * the content width is never over-estimated, keeping the height estimate safe. + */ + contentHorizontalPadding: number; + /** Fixed height in pixels of chrome that is not text content (header bar, etc.). */ + chromeHeight: number; + /** Height in pixels to return when the font is not loaded or container width is 0. */ + fallbackHeight: number; +} + +/** + * Creates a row-height resolver for `FontSampler` rows in `VirtualList`. + * + * The returned function is suitable as the `itemHeight` prop of `VirtualList`. + * Pass it from the widget layer (`SampleList`) so that typography values from + * `controlManager` are injected as getter functions rather than imported directly, + * keeping `$entities/Font` free of `$features` dependencies. + * + * **Reactivity:** When the returned function reads `getStatus()` inside a + * `$derived.by` block (as `estimateSize` does in `createVirtualizer`), any + * `SvelteMap.get()` call within `getStatus` registers a Svelte 5 dependency. + * When a font's status changes to `'loaded'`, `offsets` recomputes automatically — + * no DOM snap occurs. + * + * **Caching:** A `Map` keyed by `fontCssString|text|contentWidth|lineHeightPx` + * prevents redundant `TextLayoutEngine.layout()` calls. The cache is invalidated + * naturally because a change in any input produces a different cache key. + * + * @param options - Configuration and getter functions (all injected for testability). + * @returns A function `(rowIndex: number) => number` for use as `VirtualList.itemHeight`. + */ +export function createFontRowSizeResolver(options: FontRowSizeResolverOptions): (rowIndex: number) => number { + const engine = new TextLayoutEngine(); + // Key: `${fontCssString}|${text}|${contentWidth}|${lineHeightPx}` + const cache = new Map(); + + return function resolveRowHeight(rowIndex: number): number { + const fonts = options.getFonts(); + const font = fonts[rowIndex]; + if (!font) return options.fallbackHeight; + + const containerWidth = options.getContainerWidth(); + const previewText = options.getPreviewText(); + + if (containerWidth <= 0 || !previewText) return options.fallbackHeight; + + const weight = options.getWeight(); + // generateFontKey: '{id}@{weight}' for static fonts, '{id}@vf' for variable fonts. + const fontKey = generateFontKey({ id: font.id, weight, isVariable: font.features?.isVariable }); + + // Reading via getStatus() allows the caller to pass appliedFontsManager.statuses.get(), + // which creates a Svelte 5 reactive dependency when called inside $derived.by. + const status = options.getStatus(fontKey); + if (status !== 'loaded') return options.fallbackHeight; + + const fontSizePx = options.getFontSizePx(); + const lineHeightPx = options.getLineHeightPx(); + const contentWidth = containerWidth - options.contentHorizontalPadding; + const fontCssString = `${weight} ${fontSizePx}px "${font.name}"`; + + const cacheKey = `${fontCssString}|${previewText}|${contentWidth}|${lineHeightPx}`; + const cached = cache.get(cacheKey); + if (cached !== undefined) return cached; + + const { totalHeight } = engine.layout(previewText, fontCssString, contentWidth, lineHeightPx); + const result = totalHeight + options.chromeHeight; + cache.set(cacheKey, result); + return result; + }; +} diff --git a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts new file mode 100644 index 0000000..2f36bf1 --- /dev/null +++ b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts @@ -0,0 +1,270 @@ +import { + type PreparedTextWithSegments, + layoutWithLines, + prepareWithSegments, +} from '@chenglou/pretext'; + +/** + * A single laid-out line produced by dual-font comparison layout. + * + * Line breaking is determined by the unified worst-case widths, so both fonts + * always break at identical positions. Per-character `xA`/`xB` offsets reflect + * each font's actual advance widths independently. + */ +export interface ComparisonLine { + /** Full text of this line as returned by pretext. */ + text: string; + /** Rendered width of this line in pixels — maximum across font A and font B. */ + width: number; + 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 font A, in pixels. */ + xA: number; + /** Advance width of this grapheme in font A, in pixels. */ + widthA: number; + /** X offset from the start of the line in font B, in pixels. */ + xB: number; + /** Advance width of this grapheme in font B, in pixels. */ + widthB: number; + }>; +} + +/** + * Aggregated output of a dual-font layout pass. + */ +export interface ComparisonResult { + /** Per-line grapheme data for both fonts. Empty when input text is empty. */ + lines: ComparisonLine[]; + /** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */ + totalHeight: number; +} + +/** + * Dual-font text layout engine backed by `@chenglou/pretext`. + * + * Computes identical line breaks for two fonts simultaneously by constructing a + * "unified" prepared-text object whose per-glyph widths are the worst-case maximum + * of font A and font B. This guarantees that both fonts wrap at exactly the same + * positions, making side-by-side or slider comparison visually coherent. + * + * **Two-level caching strategy** + * 1. Font-change cache (`#preparedA`, `#preparedB`, `#unifiedPrepared`): rebuilt only + * when `text`, `fontA`, or `fontB` changes. `prepareWithSegments` is expensive + * (canvas measurement), so this avoids re-measuring during slider interaction. + * 2. Layout cache (`#lastResult`): rebuilt when `width` or `lineHeight` changes but + * the fonts have not changed. Line-breaking is cheap relative to measurement, but + * still worth skipping on every render tick. + * + * **`as any` casts:** `PreparedTextWithSegments` exposes only the `segments` field in + * its public TypeScript type. The numeric arrays (`widths`, `breakableFitAdvances`, + * `lineEndFitAdvances`, `lineEndPaintAdvances`) are internal implementation details of + * pretext that are not part of the published type signature. The casts are required to + * access these fields; they are verified against the pretext source at + * `node_modules/@chenglou/pretext/src/layout.ts`. + */ +export class CharacterComparisonEngine { + #segmenter: Intl.Segmenter; + + // Cached prepared data + #preparedA: PreparedTextWithSegments | null = null; + #preparedB: PreparedTextWithSegments | null = null; + #unifiedPrepared: PreparedTextWithSegments | null = null; + + #lastText = ''; + #lastFontA = ''; + #lastFontB = ''; + + // Cached layout results + #lastWidth = -1; + #lastLineHeight = -1; + #lastResult: ComparisonResult | null = null; + + constructor(locale?: string) { + this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' }); + } + + /** + * Lay out `text` using both fonts within `width` pixels. + * + * Line breaks are determined by the worst-case (maximum) glyph widths across + * both fonts, so both fonts always wrap at identical positions. + * + * @param text Raw text to lay out. + * @param fontA CSS font string for the first font: `"weight sizepx \"family\""`. + * @param fontB CSS font string for the second font: `"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 for both fonts. Empty `lines` when `text` is empty. + */ + layout( + text: string, + fontA: string, + fontB: string, + width: number, + lineHeight: number, + ): ComparisonResult { + if (!text) { + return { lines: [], totalHeight: 0 }; + } + + const isFontChange = text !== this.#lastText || fontA !== this.#lastFontA || fontB !== this.#lastFontB; + const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight; + + if (!isFontChange && !isLayoutChange && this.#lastResult) { + return this.#lastResult; + } + + // 1. Prepare (or use cache) + if (isFontChange) { + this.#preparedA = prepareWithSegments(text, fontA); + this.#preparedB = prepareWithSegments(text, fontB); + this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB); + + this.#lastText = text; + this.#lastFontA = fontA; + this.#lastFontB = fontB; + } + + if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) { + return { lines: [], totalHeight: 0 }; + } + + // 2. Layout using the unified widths. + // `PreparedTextWithSegments` only exposes `segments` in its public type; cast to `any` + // so pretext's layoutWithLines can read the internal numeric arrays at runtime. + const { lines, height } = layoutWithLines(this.#unifiedPrepared as any, width, lineHeight); + + // 3. Map results back to both fonts + const resultLines: ComparisonLine[] = lines.map(line => { + const chars: ComparisonLine['chars'] = []; + let currentXA = 0; + let currentXB = 0; + + const start = line.start; + const end = line.end; + + // Cast to `any`: accessing internal numeric arrays not in the public type signature. + const intA = this.#preparedA as any; + const intB = this.#preparedB as any; + + for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) { + const segmentText = this.#preparedA!.segments[sIdx]; + if (segmentText === undefined) continue; + + // PERFORMANCE: Reuse segmenter results if possible, but for now just optimize the loop + const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment); + + const advA = intA.breakableFitAdvances[sIdx]; + const advB = intB.breakableFitAdvances[sIdx]; + + 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]; + const wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!; + const wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!; + + chars.push({ + char, + xA: currentXA, + widthA: wA, + xB: currentXB, + widthB: wB, + }); + currentXA += wA; + currentXB += wB; + } + } + + return { + text: line.text, + width: line.width, + chars, + }; + }); + + this.#lastWidth = width; + this.#lastLineHeight = lineHeight; + this.#lastResult = { + lines: resultLines, + totalHeight: height, + }; + + return this.#lastResult; + } + + /** + * Calculates character proximity and direction relative to a slider position. + * + * Uses the most recent `layout()` result — must be called after `layout()`. + * No DOM calls are made; all geometry is derived from cached layout data. + * + * @param lineIndex Zero-based index of the line within the last layout result. + * @param charIndex Zero-based index of the character within that line's `chars` array. + * @param sliderPos Current slider position as a percentage (0–100) of `containerWidth`. + * @param containerWidth Total container width in pixels, used to convert pixel offsets to %. + * @returns `proximity` in [0, 1] (1 = slider exactly over char center) and + * `isPast` (true when the slider has already passed the char center). + */ + getCharState( + lineIndex: number, + charIndex: number, + sliderPos: number, + containerWidth: number, + ): { proximity: number; isPast: boolean } { + if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) { + return { proximity: 0, isPast: false }; + } + + const line = this.#lastResult.lines[lineIndex]; + const char = line.chars[charIndex]; + + if (!char) return { proximity: 0, isPast: false }; + + // Center the comparison on the unified width + // In the UI, lines are centered. So we need to calculate the global X. + const lineXOffset = (containerWidth - line.width) / 2; + const charCenterX = lineXOffset + char.xA + (char.widthA / 2); + + const charGlobalPercent = (charCenterX / containerWidth) * 100; + + const distance = Math.abs(sliderPos - charGlobalPercent); + const range = 5; + const proximity = Math.max(0, 1 - distance / range); + const isPast = sliderPos > charGlobalPercent; + + return { proximity, isPast }; + } + + /** + * Internal helper to merge two prepared texts into a "worst-case" unified version + */ + #createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments { + // Cast to `any`: accessing internal numeric arrays not in the public type signature. + const intA = a as any; + const intB = b as any; + + const unified = { ...intA }; + + unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i])); + unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) => + Math.max(w, intB.lineEndFitAdvances[i]) + ); + unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) => + Math.max(w, intB.lineEndPaintAdvances[i]) + ); + + unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => { + const advB = intB.breakableFitAdvances[i]; + if (!advA && !advB) return null; + if (!advA) return advB; + if (!advB) return advA; + + return advA.map((w: number, j: number) => Math.max(w, advB[j])); + }); + + return unified; + } +} diff --git a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts new file mode 100644 index 0000000..28c6c4c --- /dev/null +++ b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +import { clearCache } from '@chenglou/pretext'; +import { + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { installCanvasMock } from '../__mocks__/canvas'; +import { CharacterComparisonEngine } from './CharacterComparisonEngine.svelte'; + +// FontA: 10px per character. FontB: 15px per character. +// The mock dispatches on whether the font string contains 'FontA' or 'FontB'. +const FONT_A_WIDTH = 10; +const FONT_B_WIDTH = 15; + +function fontWidthFactory(font: string, text: string): number { + const perChar = font.includes('FontA') ? FONT_A_WIDTH : FONT_B_WIDTH; + return text.length * perChar; +} + +describe('CharacterComparisonEngine', () => { + let engine: CharacterComparisonEngine; + + beforeEach(() => { + installCanvasMock(fontWidthFactory); + clearCache(); + engine = new CharacterComparisonEngine(); + }); + + // --- layout() --- + + it('returns empty result for empty string', () => { + const result = engine.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + expect(result.lines).toHaveLength(0); + expect(result.totalHeight).toBe(0); + }); + + it('uses worst-case width across both fonts to determine line breaks', () => { + // 'AB CD' — two 2-char words separated by a space. + // FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total. + // FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '. + // Unified must use FontB widths — so it must wrap at the same place FontB wraps. + const result = engine.layout('AB CD', '400 16px "FontA"', '400 16px "FontB"', 35, 20); + expect(result.lines.length).toBeGreaterThan(1); + // First line text must not include both words. + expect(result.lines[0].text).not.toContain('CD'); + }); + + it('provides xA and xB offsets for both fonts on a single line', () => { + // 'ABC' fits in 500px for both fonts. + // FontA: A@0(w=10), B@10(w=10), C@20(w=10) + // FontB: A@0(w=15), B@15(w=15), C@30(w=15) + const result = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const chars = result.lines[0].chars; + + expect(chars).toHaveLength(3); + + expect(chars[0].xA).toBe(0); + expect(chars[0].widthA).toBe(FONT_A_WIDTH); + expect(chars[0].xB).toBe(0); + expect(chars[0].widthB).toBe(FONT_B_WIDTH); + + expect(chars[1].xA).toBe(FONT_A_WIDTH); // 10 + expect(chars[1].widthA).toBe(FONT_A_WIDTH); + expect(chars[1].xB).toBe(FONT_B_WIDTH); // 15 + expect(chars[1].widthB).toBe(FONT_B_WIDTH); + + expect(chars[2].xA).toBe(FONT_A_WIDTH * 2); // 20 + expect(chars[2].xB).toBe(FONT_B_WIDTH * 2); // 30 + }); + + it('xA positions are monotonically increasing', () => { + const result = engine.layout('ABCDE', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const chars = result.lines[0].chars; + for (let i = 1; i < chars.length; i++) { + expect(chars[i].xA).toBeGreaterThan(chars[i - 1].xA); + } + }); + + it('xB positions are monotonically increasing', () => { + const result = engine.layout('ABCDE', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const chars = result.lines[0].chars; + for (let i = 1; i < chars.length; i++) { + expect(chars[i].xB).toBeGreaterThan(chars[i - 1].xB); + } + }); + + it('returns cached result when called again with same arguments', () => { + const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const r2 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + expect(r2).toBe(r1); // strict reference equality — same object + }); + + it('re-computes when text changes', () => { + const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const r2 = engine.layout('DEF', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + expect(r2).not.toBe(r1); + expect(r2.lines[0].text).not.toBe(r1.lines[0].text); + }); + + it('re-computes when width changes', () => { + const r1 = engine.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const r2 = engine.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 60, 20); + expect(r2).not.toBe(r1); + }); + + it('re-computes when fontA changes', () => { + const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const r2 = engine.layout('ABC', '400 24px "FontA"', '400 16px "FontB"', 500, 20); + expect(r2).not.toBe(r1); + }); + + // --- getCharState() --- + + it('getCharState returns proximity 1 when slider is exactly over char center', () => { + // 'A' only: FontA width=10. Container=500px. Line centered. + // lineXOffset = (500 - maxWidth) / 2. maxWidth = max(10, 15) = 15 (FontB is wider). + // charCenterX = lineXOffset + xA + widthA/2. + // Using xA=0, widthA=10: charCenterX = (500-15)/2 + 0 + 5 = 247.5 + 5 = 252.5 + // charGlobalPercent = (252.5 / 500) * 100 = 50.5 + // distance = |50.5 - 50.5| = 0 => proximity = 1 + const containerWidth = 500; + engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20); + // Recalculate expected percent manually: + const lineWidth = Math.max(FONT_A_WIDTH, FONT_B_WIDTH); // 15 (unified worst-case) + const lineXOffset = (containerWidth - lineWidth) / 2; + const charCenterX = lineXOffset + 0 + FONT_A_WIDTH / 2; + const charPercent = (charCenterX / containerWidth) * 100; + + const state = engine.getCharState(0, 0, charPercent, containerWidth); + expect(state.proximity).toBe(1); + expect(state.isPast).toBe(false); + }); + + it('getCharState returns proximity 0 when slider is far from char', () => { + engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + // Slider at 0%, char is near 50% — distance > 5 range => proximity = 0 + const state = engine.getCharState(0, 0, 0, 500); + expect(state.proximity).toBe(0); + }); + + it('getCharState isPast is true when slider has passed char center', () => { + engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const state = engine.getCharState(0, 0, 100, 500); + expect(state.isPast).toBe(true); + }); + + it('getCharState returns safe default for out-of-range lineIndex', () => { + engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const state = engine.getCharState(99, 0, 50, 500); + expect(state.proximity).toBe(0); + expect(state.isPast).toBe(false); + }); + + it('getCharState returns safe default for out-of-range charIndex', () => { + engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const state = engine.getCharState(0, 99, 50, 500); + expect(state.proximity).toBe(0); + expect(state.isPast).toBe(false); + }); + + it('getCharState returns safe default before layout() has been called', () => { + const state = engine.getCharState(0, 0, 50, 500); + expect(state.proximity).toBe(0); + expect(state.isPast).toBe(false); + }); +}); diff --git a/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts new file mode 100644 index 0000000..048bf0f --- /dev/null +++ b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts @@ -0,0 +1,154 @@ +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; + 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. + */ +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, + }; + } +} diff --git a/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.test.ts b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.test.ts new file mode 100644 index 0000000..7aeac46 --- /dev/null +++ b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.test.ts @@ -0,0 +1,89 @@ +// @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); + }); +}); diff --git a/src/shared/lib/helpers/__mocks__/canvas.ts b/src/shared/lib/helpers/__mocks__/canvas.ts new file mode 100644 index 0000000..326de30 --- /dev/null +++ b/src/shared/lib/helpers/__mocks__/canvas.ts @@ -0,0 +1,29 @@ +// src/shared/lib/helpers/__mocks__/canvas.ts +// +// Call installCanvasMock(fn) before any pretext import to control measureText. +// The factory receives the current ctx.font string and the text to measure. +import { vi } from 'vitest'; + +export type MeasureFactory = (font: string, text: string) => number; + +export function installCanvasMock(factory: MeasureFactory): void { + let currentFont = ''; + + const mockCtx = { + get font() { + return currentFont; + }, + set font(f: string) { + currentFont = f; + }, + measureText: vi.fn((text: string) => ({ width: factory(currentFont, text) })), + }; + + // HTMLCanvasElement.prototype.getContext is the entry point pretext uses in DOM environments. + // OffscreenCanvas takes priority in pretext; jsdom does not define it so DOM path is used. + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + writable: true, + value: vi.fn(() => mockCtx), + }); +} diff --git a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts deleted file mode 100644 index 99b5430..0000000 --- a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts +++ /dev/null @@ -1,374 +0,0 @@ -/** - * Character-by-character font comparison helper - * - * Creates utilities for comparing two fonts character by character. - * Used by the ComparisonView widget to render morphing text effects - * where characters transition between font A and font B based on - * slider position. - * - * Features: - * - Responsive text measurement using canvas - * - Binary search for optimal line breaking - * - Character proximity calculation for morphing effects - * - Handles CSS transforms correctly (uses offsetWidth) - * - * @example - * ```svelte - * - * - * - *
- * {#each lines as line} - * {line.text} - * {/each} - *
- * ``` - */ - -/** - * Represents a single line of text with its measured width - */ -export interface LineData { - /** The text content of the line */ - text: string; - /** Maximum width between both fonts in pixels */ - width: number; -} - -/** - * Creates a character comparison helper for morphing text effects - * - * Measures text in both fonts to determine line breaks and calculates - * character-level proximity for morphing animations. - * - * @param text - Getter for the text to compare - * @param fontA - Getter for the first font (left/top side) - * @param fontB - Getter for the second font (right/bottom side) - * @param weight - Getter for the current font weight - * @param size - Getter for the controlled font size - * @returns Character comparison instance with lines and proximity calculations - * - * @example - * ```ts - * const comparison = createCharacterComparison( - * () => $sampleText, - * () => $selectedFontA, - * () => $selectedFontB, - * () => $fontWeight, - * () => $fontSize - * ); - * - * // Call when DOM is ready - * comparison.breakIntoLines(container, canvas); - * - * // Get character state for morphing - * const state = comparison.getCharState(5, sliderPosition, lineEl, container); - * // state.proximity: 0-1 value for opacity/interpolation - * // state.isPast: true if slider is past this character - * ``` - */ -export function createCharacterComparison< - T extends { name: string; id: string } | undefined = undefined, ->( - text: () => string, - fontA: () => T, - fontB: () => T, - weight: () => number, - size: () => number, -) { - let lines = $state([]); - let containerWidth = $state(0); - - /** - * Type guard to check if a font is defined - */ - function fontDefined(font: T | undefined): font is T { - return font !== undefined; - } - - /** - * Measures text width using canvas 2D context - * - * @param ctx - Canvas rendering context - * @param text - Text string to measure - * @param fontSize - Font size in pixels - * @param fontWeight - Font weight (100-900) - * @param fontFamily - Font family name (optional, returns 0 if missing) - * @returns Width of text in pixels - */ - function measureText( - ctx: CanvasRenderingContext2D, - text: string, - fontSize: number, - fontWeight: number, - fontFamily?: string, - ): number { - if (!fontFamily) return 0; - ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`; - return ctx.measureText(text).width; - } - - /** - * Gets responsive font size based on viewport width - * - * Matches Tailwind breakpoints used in the component: - * - < 640px: 64px - * - 640-767px: 80px - * - 768-1023px: 96px - * - >= 1024px: 112px - */ - function getFontSize() { - if (typeof window === 'undefined') { - return 64; - } - return window.innerWidth >= 1024 - ? 112 - : window.innerWidth >= 768 - ? 96 - : window.innerWidth >= 640 - ? 80 - : 64; - } - - /** - * Breaks text into lines based on container width - * - * Measures text in BOTH fonts and uses the wider width to prevent - * layout shifts. Uses binary search for efficient word breaking. - * - * @param container - Container element to measure width from - * @param measureCanvas - Hidden canvas element for text measurement - */ - function breakIntoLines( - container: HTMLElement | undefined, - measureCanvas: HTMLCanvasElement | undefined, - ) { - if (!container || !measureCanvas || !fontA() || !fontB()) { - return; - } - - // Use offsetWidth to avoid CSS transform scaling issues - // getBoundingClientRect() includes transform scale which breaks calculations - const width = container.offsetWidth; - containerWidth = width; - - const padding = window.innerWidth < 640 ? 48 : 96; - const availableWidth = width - padding; - const ctx = measureCanvas.getContext('2d'); - if (!ctx) { - return; - } - - const controlledFontSize = size(); - const fontSize = getFontSize(); - const currentWeight = weight(); - const words = text().split(' '); - const newLines: LineData[] = []; - let currentLineWords: string[] = []; - - /** - * Adds a line to the output using the wider font's width - */ - function pushLine(words: string[]) { - if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) { - return; - } - const lineText = words.join(' '); - const widthA = measureText( - ctx!, - lineText, - Math.min(fontSize, controlledFontSize), - currentWeight, - fontA()?.name, - ); - const widthB = measureText( - ctx!, - lineText, - Math.min(fontSize, controlledFontSize), - currentWeight, - fontB()?.name, - ); - const maxWidth = Math.max(widthA, widthB); - newLines.push({ text: lineText, width: maxWidth }); - } - - for (const word of words) { - const testLine = currentLineWords.length > 0 - ? currentLineWords.join(' ') + ' ' + word - : word; - // Measure with both fonts - use wider to prevent shifts - const widthA = measureText( - ctx, - testLine, - Math.min(fontSize, controlledFontSize), - currentWeight, - fontA()?.name, - ); - const widthB = measureText( - ctx, - testLine, - Math.min(fontSize, controlledFontSize), - currentWeight, - fontB()?.name, - ); - const maxWidth = Math.max(widthA, widthB); - const isContainerOverflown = maxWidth > availableWidth; - - if (isContainerOverflown) { - if (currentLineWords.length > 0) { - pushLine(currentLineWords); - currentLineWords = []; - } - - // Check if word alone fits - const wordWidthA = measureText( - ctx, - word, - Math.min(fontSize, controlledFontSize), - currentWeight, - fontA()?.name, - ); - const wordWidthB = measureText( - ctx, - word, - Math.min(fontSize, controlledFontSize), - currentWeight, - fontB()?.name, - ); - const wordAloneWidth = Math.max(wordWidthA, wordWidthB); - - if (wordAloneWidth <= availableWidth) { - currentLineWords = [word]; - } else { - // Word doesn't fit - binary search to find break point - let remainingWord = word; - while (remainingWord.length > 0) { - let low = 1; - let high = remainingWord.length; - let bestBreak = 1; - - // Binary search for maximum characters that fit - while (low <= high) { - const mid = Math.floor((low + high) / 2); - const testFragment = remainingWord.slice(0, mid); - - const wA = measureText( - ctx, - testFragment, - fontSize, - currentWeight, - fontA()?.name, - ); - const wB = measureText( - ctx, - testFragment, - fontSize, - currentWeight, - fontB()?.name, - ); - - if (Math.max(wA, wB) <= availableWidth) { - bestBreak = mid; - low = mid + 1; - } else { - high = mid - 1; - } - } - - pushLine([remainingWord.slice(0, bestBreak)]); - remainingWord = remainingWord.slice(bestBreak); - } - } - } else if (maxWidth > availableWidth && currentLineWords.length > 0) { - pushLine(currentLineWords); - currentLineWords = [word]; - } else { - currentLineWords.push(word); - } - } - - if (currentLineWords.length > 0) { - pushLine(currentLineWords); - } - lines = newLines; - } - - /** - * Calculates character proximity to slider position - * - * Used for morphing effects - returns how close a character is to - * the slider and whether it's on the "past" side. - * - * @param charIndex - Index of character within its line - * @param sliderPos - Slider position (0-100, percent across container) - * @param lineElement - The line element containing the character - * @param container - The container element for position calculations - * @returns Proximity (0-1, 1 = at slider) and isPast (true = right of slider) - */ - function getCharState( - charIndex: number, - sliderPos: number, - lineElement?: HTMLElement, - container?: HTMLElement, - ) { - if (!containerWidth || !container) { - return { - proximity: 0, - isPast: false, - }; - } - const charElement = lineElement?.children[charIndex] as HTMLElement; - - if (!charElement) { - return { proximity: 0, isPast: false }; - } - - // Get character bounding box relative to container - const charRect = charElement.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - - // Calculate character center as percentage of container width - const charCenter = charRect.left + (charRect.width / 2) - containerRect.left; - const charGlobalPercent = (charCenter / containerWidth) * 100; - - // Calculate proximity (1.0 = at slider, 0.0 = 5% away) - const distance = Math.abs(sliderPos - charGlobalPercent); - const range = 5; - const proximity = Math.max(0, 1 - distance / range); - const isPast = sliderPos > charGlobalPercent; - - return { proximity, isPast }; - } - - return { - /** Reactive array of broken lines */ - get lines() { - return lines; - }, - /** Container width in pixels */ - get containerWidth() { - return containerWidth; - }, - /** Break text into lines based on current container and fonts */ - breakIntoLines, - /** Get character state for morphing calculations */ - getCharState, - }; -} - -/** - * Type representing a character comparison instance - */ -export type CharacterComparison = ReturnType; diff --git a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.test.ts b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.test.ts deleted file mode 100644 index 04348a1..0000000 --- a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest'; -import { createCharacterComparison } from './createCharacterComparison.svelte'; - -type Font = { name: string; id: string }; - -const fontA: Font = { name: 'Roboto', id: 'roboto' }; -const fontB: Font = { name: 'Open Sans', id: 'open-sans' }; - -function createMockCanvas(charWidth = 10): HTMLCanvasElement { - return { - getContext: () => ({ - font: '', - measureText: (text: string) => ({ width: text.length * charWidth }), - }), - } as unknown as HTMLCanvasElement; -} - -function createMockContainer(offsetWidth = 500): HTMLElement { - return { - offsetWidth, - getBoundingClientRect: () => ({ - left: 0, - width: offsetWidth, - top: 0, - right: offsetWidth, - bottom: 0, - height: 0, - }), - } as unknown as HTMLElement; -} - -describe('createCharacterComparison', () => { - beforeEach(() => { - // Mock window.innerWidth for getFontSize and padding calculations - Object.defineProperty(globalThis, 'window', { - value: { innerWidth: 1024 }, - writable: true, - configurable: true, - }); - }); - - describe('Initial State', () => { - it('should initialize with empty lines and zero container width', () => { - const comparison = createCharacterComparison( - () => 'test', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - expect(comparison.lines).toEqual([]); - expect(comparison.containerWidth).toBe(0); - }); - }); - - describe('breakIntoLines', () => { - it('should not break lines when container or canvas is undefined', () => { - const comparison = createCharacterComparison( - () => 'Hello world', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - comparison.breakIntoLines(undefined, undefined); - expect(comparison.lines).toEqual([]); - - comparison.breakIntoLines(createMockContainer(), undefined); - expect(comparison.lines).toEqual([]); - }); - - it('should not break lines when fonts are undefined', () => { - const comparison = createCharacterComparison( - () => 'Hello world', - () => undefined, - () => undefined, - () => 400, - () => 48, - ); - - comparison.breakIntoLines(createMockContainer(), createMockCanvas()); - expect(comparison.lines).toEqual([]); - }); - - it('should produce a single line when text fits within container', () => { - // charWidth=10, padding=96 (innerWidth>=640), availableWidth=500-96=404 - // "Hello" = 5 chars * 10 = 50px, fits easily - const comparison = createCharacterComparison( - () => 'Hello', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10)); - - expect(comparison.lines).toHaveLength(1); - expect(comparison.lines[0].text).toBe('Hello'); - }); - - it('should break text into multiple lines when it overflows', () => { - // charWidth=10, container=200, padding=96, availableWidth=104 - // "Hello world test" => "Hello" (50px), "Hello world" (110px > 104) - // So "Hello" goes on line 1, "world" (50px) fits, "world test" (100px) fits - const comparison = createCharacterComparison( - () => 'Hello world test', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - comparison.breakIntoLines(createMockContainer(200), createMockCanvas(10)); - - expect(comparison.lines.length).toBeGreaterThan(1); - // All original text should be preserved across lines - const reconstructed = comparison.lines.map(l => l.text).join(' '); - expect(reconstructed).toBe('Hello world test'); - }); - - it('should update containerWidth after breaking lines', () => { - const comparison = createCharacterComparison( - () => 'Hi', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - comparison.breakIntoLines(createMockContainer(750), createMockCanvas(10)); - - expect(comparison.containerWidth).toBe(750); - }); - - it('should use smaller padding on narrow viewports', () => { - Object.defineProperty(globalThis, 'window', { - value: { innerWidth: 500 }, - writable: true, - configurable: true, - }); - - // container=150, padding=48 (innerWidth<640), availableWidth=102 - // "ABCDEFGHIJ" = 10 chars * 10 = 100px, fits in 102 - const comparison = createCharacterComparison( - () => 'ABCDEFGHIJ', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10)); - - expect(comparison.lines).toHaveLength(1); - expect(comparison.lines[0].text).toBe('ABCDEFGHIJ'); - }); - - it('should break a single long word using binary search', () => { - // container=150, padding=96, availableWidth=54 - // "ABCDEFGHIJ" = 10 chars * 10 = 100px, doesn't fit as single word - // Binary search should split it - const comparison = createCharacterComparison( - () => 'ABCDEFGHIJ', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10)); - - expect(comparison.lines.length).toBeGreaterThan(1); - const reconstructed = comparison.lines.map(l => l.text).join(''); - expect(reconstructed).toBe('ABCDEFGHIJ'); - }); - - it('should store max width between both fonts for each line', () => { - // Use a canvas where measureText returns text.length * charWidth - // Both fonts measure the same, so width = text.length * charWidth - const comparison = createCharacterComparison( - () => 'Hi', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10)); - - expect(comparison.lines[0].width).toBe(20); // "Hi" = 2 chars * 10 - }); - }); - - describe('getCharState', () => { - it('should return zero proximity and isPast=false when containerWidth is 0', () => { - const comparison = createCharacterComparison( - () => 'test', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - const state = comparison.getCharState(0, 50, undefined, undefined); - - expect(state.proximity).toBe(0); - expect(state.isPast).toBe(false); - }); - - it('should return zero proximity when charElement is not found', () => { - const comparison = createCharacterComparison( - () => 'test', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - // First break lines to set containerWidth - comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10)); - - const lineEl = { children: [] } as unknown as HTMLElement; - const container = createMockContainer(500); - const state = comparison.getCharState(0, 50, lineEl, container); - - expect(state.proximity).toBe(0); - expect(state.isPast).toBe(false); - }); - - it('should calculate proximity based on distance from slider', () => { - const comparison = createCharacterComparison( - () => 'test', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10)); - - // Character centered at 250px in a 500px container = 50% - const charEl = { - getBoundingClientRect: () => ({ left: 240, width: 20 }), - }; - const lineEl = { children: [charEl] } as unknown as HTMLElement; - const container = createMockContainer(500); - - // Slider at 50% => charCenter at 250px => charGlobalPercent = 50% - // distance = |50 - 50| = 0, proximity = max(0, 1 - 0/5) = 1 - const state = comparison.getCharState(0, 50, lineEl, container); - - expect(state.proximity).toBe(1); - expect(state.isPast).toBe(false); - }); - - it('should return isPast=true when slider is past the character', () => { - const comparison = createCharacterComparison( - () => 'test', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10)); - - // Character centered at 100px => 20% of 500px - const charEl = { - getBoundingClientRect: () => ({ left: 90, width: 20 }), - }; - const lineEl = { children: [charEl] } as unknown as HTMLElement; - const container = createMockContainer(500); - - // Slider at 80% => past the character at 20% - const state = comparison.getCharState(0, 80, lineEl, container); - - expect(state.isPast).toBe(true); - }); - - it('should return zero proximity when character is far from slider', () => { - const comparison = createCharacterComparison( - () => 'test', - () => fontA, - () => fontB, - () => 400, - () => 48, - ); - - comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10)); - - // Character at 10% of container, slider at 90% => distance = 80%, range = 5% - const charEl = { - getBoundingClientRect: () => ({ left: 45, width: 10 }), - }; - const lineEl = { children: [charEl] } as unknown as HTMLElement; - const container = createMockContainer(500); - - const state = comparison.getCharState(0, 90, lineEl, container); - - expect(state.proximity).toBe(0); - }); - }); -}); diff --git a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts index 7f5c5fa..301f1d6 100644 --- a/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts +++ b/src/shared/lib/helpers/createVirtualizer/createVirtualizer.svelte.ts @@ -50,6 +50,14 @@ export interface VirtualizerOptions { /** * Function to estimate the size of an item at a given index. * Used for initial layout before actual measurements are available. + * + * Called inside a `$derived.by` block. Any `$state` or `$derived` value + * read within this function is automatically tracked as a dependency — + * when those values change, `offsets` and `totalSize` recompute instantly. + * + * For font preview rows, pass a closure that reads + * `appliedFontsManager.statuses` so the virtualizer recalculates heights + * as fonts finish loading, eliminating the DOM-measurement snap on load. */ estimateSize: (index: number) => number; /** Number of extra items to render outside viewport for smoother scrolling (default: 5) */ @@ -71,6 +79,18 @@ export interface VirtualizerOptions { useWindowScroll?: boolean; } +/** + * A height resolver for a single virtual-list row. + * + * When this function reads reactive state (e.g. `SvelteMap.get()`), calling + * it inside a `$derived.by` block automatically subscribes to that state. + * Return `fallbackHeight` whenever the true height is not yet known. + * + * @param rowIndex Zero-based row index within the data array. + * @returns Row height in pixels, excluding the list gap. + */ +export type ItemSizeResolver = (rowIndex: number) => number; + /** * Creates a reactive virtualizer for efficiently rendering large lists by only rendering visible items. * diff --git a/src/shared/lib/helpers/index.ts b/src/shared/lib/helpers/index.ts index fc63b39..1580dcd 100644 --- a/src/shared/lib/helpers/index.ts +++ b/src/shared/lib/helpers/index.ts @@ -52,10 +52,16 @@ export { } from './createEntityStore/createEntityStore.svelte'; export { - type CharacterComparison, - createCharacterComparison, - type LineData, -} from './createCharacterComparison/createCharacterComparison.svelte'; + CharacterComparisonEngine, + type ComparisonLine, + type ComparisonResult, +} from './CharacterComparisonEngine/CharacterComparisonEngine.svelte'; + +export { + type LayoutLine as TextLayoutLine, + type LayoutResult as TextLayoutResult, + TextLayoutEngine, +} from './TextLayoutEngine/TextLayoutEngine.svelte'; export { createPersistentStore, diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 5270695..33c077c 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -5,10 +5,11 @@ */ export { - type CharacterComparison, + CharacterComparisonEngine, + type ComparisonLine, + type ComparisonResult, type ControlDataModel, type ControlModel, - createCharacterComparison, createDebouncedState, createEntityStore, createFilter, @@ -21,12 +22,14 @@ export { type EntityStore, type Filter, type FilterModel, - type LineData, type PersistentStore, type PerspectiveManager, type Property, type ResponsiveManager, responsiveManager, + TextLayoutEngine, + type TextLayoutLine, + type TextLayoutResult, type TypographyControl, type VirtualItem, type Virtualizer, diff --git a/src/widgets/ComparisonView/ui/Line/Line.svelte b/src/widgets/ComparisonView/ui/Line/Line.svelte index 9258138..98879e5 100644 --- a/src/widgets/ComparisonView/ui/Line/Line.svelte +++ b/src/widgets/ComparisonView/ui/Line/Line.svelte @@ -6,15 +6,21 @@ import type { Snippet } from 'svelte'; import { comparisonStore } from '../../model'; +interface LineChar { + char: string; + xA: number; + widthA: number; + xB: number; + widthB: number; +} + interface Props { /** - * Line text + * Pre-computed grapheme array from CharacterComparisonEngine. + * Using the engine's chars array (rather than splitting line.text) ensures + * correct grapheme-cluster boundaries for emoji and multi-codepoint characters. */ - text: string; - /** - * DOM element reference - */ - element?: HTMLElement; + chars: LineChar[]; /** * Character render snippet */ @@ -22,18 +28,15 @@ interface Props { } const typography = $derived(comparisonStore.typography); -let { text, element = $bindable(), character }: Props = $props(); - -const characters = $derived(text.split('')); +let { chars, character }: Props = $props();
- {#each characters as char, index} - {@render character?.({ char, index })} + {#each chars as c, index} + {@render character?.({ char: c.char, index })} {/each}
diff --git a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte index 3fa1948..c6d1f45 100644 --- a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte +++ b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte @@ -9,11 +9,12 @@ --> - - - {#snippet skeleton()} @@ -52,9 +93,9 @@ const checkPosition = throttle(() => { onresize={checkPosition} /> -
+