Compare commits
8 Commits
26737f2f11
...
49822f8af7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49822f8af7 | ||
|
|
338ca9b4fd | ||
|
|
99f662e2d5 | ||
|
|
5977e0a0dc | ||
|
|
2b0d8470e5 | ||
|
|
351ee9fd52 | ||
|
|
a526a51af8 | ||
|
|
fcde78abad |
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/shared/lib/helpers/__mocks__/canvas.ts
Normal file
29
src/shared/lib/helpers/__mocks__/canvas.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>;
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user