feat: export TextLayoutEngine and CharacterComparisonEngine from shared helpers index

Remove deleted createCharacterComparison exports and benchmark.
This commit is contained in:
Ilia Mashkov
2026-04-11 16:44:49 +03:00
parent 99f662e2d5
commit 338ca9b4fd
4 changed files with 16 additions and 693 deletions

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';
export {
type CharacterComparison,
createCharacterComparison,
type LineData,
} from './createCharacterComparison/createCharacterComparison.svelte';
CharacterComparisonEngine,
type ComparisonLine,
type ComparisonResult,
} from './CharacterComparisonEngine/CharacterComparisonEngine.svelte';
export {
type LayoutLine as TextLayoutLine,
type LayoutResult as TextLayoutResult,
TextLayoutEngine,
} from './TextLayoutEngine/TextLayoutEngine.svelte';
export {
createPersistentStore,

View File

@@ -5,10 +5,11 @@
*/
export {
type CharacterComparison,
CharacterComparisonEngine,
type ComparisonLine,
type ComparisonResult,
type ControlDataModel,
type ControlModel,
createCharacterComparison,
createDebouncedState,
createEntityStore,
createFilter,
@@ -21,12 +22,14 @@ export {
type EntityStore,
type Filter,
type FilterModel,
type LineData,
type PersistentStore,
type PerspectiveManager,
type Property,
type ResponsiveManager,
responsiveManager,
TextLayoutEngine,
type TextLayoutLine,
type TextLayoutResult,
type TypographyControl,
type VirtualItem,
type Virtualizer,