Compare commits

...

8 Commits

Author SHA1 Message Date
Ilia Mashkov
49822f8af7 feat: install pretext library 2026-04-12 09:08:01 +03:00
Ilia Mashkov
338ca9b4fd feat: export TextLayoutEngine and CharacterComparisonEngine from shared helpers index
Remove deleted createCharacterComparison exports and benchmark.
2026-04-11 16:44:49 +03:00
Ilia Mashkov
99f662e2d5 fix: iterate pre-computed chars array in Line.svelte to fix unicode grapheme splitting bug 2026-04-11 16:26:41 +03:00
Ilia Mashkov
5977e0a0dc fix: correct advances null-check in CharacterComparisonEngine and remove unused TextLayoutEngine dep 2026-04-11 16:14:28 +03:00
Ilia Mashkov
2b0d8470e5 test: fix CharacterComparisonEngine tests — correct env directive, canvas mock, and full spec coverage 2026-04-11 16:14:24 +03:00
Ilia Mashkov
351ee9fd52 docs: add inline documentation to TextLayoutEngine 2026-04-11 16:10:01 +03:00
Ilia Mashkov
a526a51af8 test: fix TextLayoutEngine tests — correct jsdom directive placement and canvas mock setup
fix: correct grapheme-width fallback in TextLayoutEngine for null breakableFitAdvances
2026-04-11 15:48:52 +03:00
Ilia Mashkov
fcde78abad test: add canvas mock helper for pretext-based engine tests 2026-04-11 15:48:47 +03:00
13 changed files with 721 additions and 728 deletions

View File

@@ -66,6 +66,7 @@
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "^2.0.1"
}, },
"dependencies": { "dependencies": {
"@chenglou/pretext": "^0.0.5",
"@tanstack/svelte-query": "^6.0.14" "@tanstack/svelte-query": "^6.0.14"
} }
} }

View File

@@ -0,0 +1,208 @@
import {
type PreparedTextWithSegments,
layoutWithLines,
prepareWithSegments,
} from '@chenglou/pretext';
/**
* Result of dual-font comparison layout
*/
export interface ComparisonLine {
text: string;
width: number; // Max width between font A and B
chars: Array<{
char: string;
xA: number; // X offset in font A
widthA: number;
xB: number; // X offset in font B
widthB: number;
}>;
}
export interface ComparisonResult {
lines: ComparisonLine[];
totalHeight: number;
}
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' });
}
/**
* Unified layout for two fonts
* Ensures consistent line breaks by taking the worst-case width
*/
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
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;
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 state without any DOM calls
*/
getCharState(
lineIndex: number,
charIndex: number,
sliderPos: number, // 0-100 percentage
containerWidth: number,
) {
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 {
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;
}
}

View File

@@ -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 (FontB) width 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);
});
});

View File

@@ -0,0 +1,149 @@
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;
}>;
}
export interface LayoutResult {
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;
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,
};
}
}

View File

@@ -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);
});
});

View File

@@ -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),
});
}

View File

@@ -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
* <script lang="ts">
* import { createCharacterComparison } from '$shared/lib/helpers';
*
* const comparison = createCharacterComparison(
* () => text,
* () => fontA,
* () => fontB,
* () => weight,
* () => size
* );
*
* $: lines = comparison.lines;
* </script>
*
* <canvas bind:this={measureCanvas} hidden></canvas>
* <div bind:this={container}>
* {#each lines as line}
* <span>{line.text}</span>
* {/each}
* </div>
* ```
*/
/**
* 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<LineData[]>([]);
let containerWidth = $state(0);
/**
* Type guard to check if a font is defined
*/
function fontDefined<T extends { name: string; id: string }>(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<typeof createCharacterComparison>;

View File

@@ -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);
});
});
});

View File

@@ -52,10 +52,16 @@ export {
} from './createEntityStore/createEntityStore.svelte'; } from './createEntityStore/createEntityStore.svelte';
export { export {
type CharacterComparison, CharacterComparisonEngine,
createCharacterComparison, type ComparisonLine,
type LineData, type ComparisonResult,
} from './createCharacterComparison/createCharacterComparison.svelte'; } from './CharacterComparisonEngine/CharacterComparisonEngine.svelte';
export {
type LayoutLine as TextLayoutLine,
type LayoutResult as TextLayoutResult,
TextLayoutEngine,
} from './TextLayoutEngine/TextLayoutEngine.svelte';
export { export {
createPersistentStore, createPersistentStore,

View File

@@ -5,10 +5,11 @@
*/ */
export { export {
type CharacterComparison, CharacterComparisonEngine,
type ComparisonLine,
type ComparisonResult,
type ControlDataModel, type ControlDataModel,
type ControlModel, type ControlModel,
createCharacterComparison,
createDebouncedState, createDebouncedState,
createEntityStore, createEntityStore,
createFilter, createFilter,
@@ -21,12 +22,14 @@ export {
type EntityStore, type EntityStore,
type Filter, type Filter,
type FilterModel, type FilterModel,
type LineData,
type PersistentStore, type PersistentStore,
type PerspectiveManager, type PerspectiveManager,
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,

View File

@@ -6,15 +6,21 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { comparisonStore } from '../../model'; import { comparisonStore } from '../../model';
interface LineChar {
char: string;
xA: number;
widthA: number;
xB: number;
widthB: number;
}
interface Props { 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; chars: LineChar[];
/**
* DOM element reference
*/
element?: HTMLElement;
/** /**
* Character render snippet * Character render snippet
*/ */
@@ -22,18 +28,15 @@ interface Props {
} }
const typography = $derived(comparisonStore.typography); const typography = $derived(comparisonStore.typography);
let { text, element = $bindable<HTMLElement>(), character }: Props = $props(); let { chars, character }: Props = $props();
const characters = $derived(text.split(''));
</script> </script>
<div <div
bind:this={element}
class="relative flex w-full justify-center items-center whitespace-nowrap" class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height="{typography.height}em" style:height="{typography.height}em"
style:line-height="{typography.height}em" style:line-height="{typography.height}em"
> >
{#each characters as char, index} {#each chars as c, index}
{@render character?.({ char, index })} {@render character?.({ char: c.char, index })}
{/each} {/each}
</div> </div>

View File

@@ -9,11 +9,12 @@
--> -->
<script lang="ts"> <script lang="ts">
import { import {
type CharacterComparison,
type ResponsiveManager, type ResponsiveManager,
createCharacterComparison,
debounce, debounce,
} from '$shared/lib'; } from '$shared/lib';
import {
CharacterComparisonEngine,
} from '$shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte';
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { Loader } from '$shared/ui'; import { Loader } from '$shared/ui';
import { getContext } from 'svelte'; import { getContext } from 'svelte';
@@ -44,22 +45,16 @@ const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady
const typography = $derived(comparisonStore.typography); const typography = $derived(comparisonStore.typography);
let container = $state<HTMLElement>(); let container = $state<HTMLElement>();
let measureCanvas = $state<HTMLCanvasElement>();
const responsive = getContext<ResponsiveManager>('responsive'); const responsive = getContext<ResponsiveManager>('responsive');
const isMobile = $derived(responsive?.isMobile ?? false); const isMobile = $derived(responsive?.isMobile ?? false);
let isDragging = $state(false); let isDragging = $state(false);
const charComparison: CharacterComparison = createCharacterComparison( // New high-performance layout engine
() => comparisonStore.text, const comparisonEngine = new CharacterComparisonEngine();
() => fontA,
() => fontB,
() => typography.weight,
() => typography.renderedSize,
);
let lineElements = $state<(HTMLElement | undefined)[]>([]); let layoutResult = $state<ReturnType<typeof comparisonEngine.layout>>({ lines: [], totalHeight: 0 });
const sliderSpring = new Spring(50, { const sliderSpring = new Spring(50, {
stiffness: 0.2, stiffness: 0.2,
@@ -123,18 +118,41 @@ $effect(() => {
const _weight = typography.weight; const _weight = typography.weight;
const _size = typography.renderedSize; const _size = typography.renderedSize;
const _height = typography.height; const _height = typography.height;
if (container && measureCanvas && fontA && fontB) {
requestAnimationFrame(() => { if (container && fontA && fontB) {
charComparison.breakIntoLines(container, measureCanvas); // PRETEXT API strings: "weight sizepx family"
}); const fontAStr = `${_weight} ${_size}px "${fontA.name}"`;
const fontBStr = `${_weight} ${_size}px "${fontB.name}"`;
// Use offsetWidth to avoid transform scaling issues
const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
const availableWidth = width - padding;
const lineHeight = _size * 1.2; // Approximate
layoutResult = comparisonEngine.layout(
_text,
fontAStr,
fontBStr,
availableWidth,
lineHeight,
);
} }
}); });
$effect(() => { $effect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const handleResize = () => { const handleResize = () => {
if (container && measureCanvas) { if (container && fontA && fontB) {
charComparison.breakIntoLines(container, measureCanvas); const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
layoutResult = comparisonEngine.layout(
comparisonStore.text,
`${typography.weight} ${typography.renderedSize}px "${fontA.name}"`,
`${typography.weight} ${typography.renderedSize}px "${fontB.name}"`,
width - padding,
typography.renderedSize * 1.2,
);
} }
}; };
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
@@ -156,9 +174,6 @@ const scaleClass = $derived(
); );
</script> </script>
<!-- Hidden measurement canvas -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<!-- <!--
Outer flex container — fills parent. Outer flex container — fills parent.
The paper div inside scales down when the sidebar opens on desktop. The paper div inside scales down when the sidebar opens on desktop.
@@ -218,10 +233,10 @@ const scaleClass = $derived(
my-auto my-auto
" "
> >
{#each charComparison.lines as line, lineIndex} {#each layoutResult.lines as line, lineIndex}
<Line bind:element={lineElements[lineIndex]} text={line.text}> <Line chars={line.chars}>
{#snippet character({ char, index })} {#snippet character({ char, index })}
{@const { proximity, isPast } = charComparison.getCharState(index, sliderPos, lineElements[lineIndex], container)} {@const { proximity, isPast } = comparisonEngine.getCharState(lineIndex, index, sliderPos, container?.offsetWidth ?? 0)}
<Character {char} {proximity} {isPast} /> <Character {char} {proximity} {isPast} />
{/snippet} {/snippet}
</Line> </Line>

View File

@@ -122,6 +122,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@chenglou/pretext@npm:^0.0.5":
version: 0.0.5
resolution: "@chenglou/pretext@npm:0.0.5"
checksum: 10c0/5139b39a166fbe7d1e0cf31c95f83125cc0658d8951b19dff3ac14b94d08c2bb53e954801c0325dac79c5b2b21157fa7763e0c561d46773baa37253f1a526242
languageName: node
linkType: hard
"@chromatic-com/storybook@npm:^4.1.3": "@chromatic-com/storybook@npm:^4.1.3":
version: 4.1.3 version: 4.1.3
resolution: "@chromatic-com/storybook@npm:4.1.3" resolution: "@chromatic-com/storybook@npm:4.1.3"
@@ -2436,6 +2443,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "glyphdiff@workspace:." resolution: "glyphdiff@workspace:."
dependencies: dependencies:
"@chenglou/pretext": "npm:^0.0.5"
"@chromatic-com/storybook": "npm:^4.1.3" "@chromatic-com/storybook": "npm:^4.1.3"
"@internationalized/date": "npm:^3.10.0" "@internationalized/date": "npm:^3.10.0"
"@lucide/svelte": "npm:^0.561.0" "@lucide/svelte": "npm:^0.561.0"