diff --git a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts
deleted file mode 100644
index 99b5430..0000000
--- a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts
+++ /dev/null
@@ -1,374 +0,0 @@
-/**
- * Character-by-character font comparison helper
- *
- * Creates utilities for comparing two fonts character by character.
- * Used by the ComparisonView widget to render morphing text effects
- * where characters transition between font A and font B based on
- * slider position.
- *
- * Features:
- * - Responsive text measurement using canvas
- * - Binary search for optimal line breaking
- * - Character proximity calculation for morphing effects
- * - Handles CSS transforms correctly (uses offsetWidth)
- *
- * @example
- * ```svelte
- *
- *
- *
- *
- * {#each lines as line}
- * {line.text}
- * {/each}
- *
- * ```
- */
-
-/**
- * Represents a single line of text with its measured width
- */
-export interface LineData {
- /** The text content of the line */
- text: string;
- /** Maximum width between both fonts in pixels */
- width: number;
-}
-
-/**
- * Creates a character comparison helper for morphing text effects
- *
- * Measures text in both fonts to determine line breaks and calculates
- * character-level proximity for morphing animations.
- *
- * @param text - Getter for the text to compare
- * @param fontA - Getter for the first font (left/top side)
- * @param fontB - Getter for the second font (right/bottom side)
- * @param weight - Getter for the current font weight
- * @param size - Getter for the controlled font size
- * @returns Character comparison instance with lines and proximity calculations
- *
- * @example
- * ```ts
- * const comparison = createCharacterComparison(
- * () => $sampleText,
- * () => $selectedFontA,
- * () => $selectedFontB,
- * () => $fontWeight,
- * () => $fontSize
- * );
- *
- * // Call when DOM is ready
- * comparison.breakIntoLines(container, canvas);
- *
- * // Get character state for morphing
- * const state = comparison.getCharState(5, sliderPosition, lineEl, container);
- * // state.proximity: 0-1 value for opacity/interpolation
- * // state.isPast: true if slider is past this character
- * ```
- */
-export function createCharacterComparison<
- T extends { name: string; id: string } | undefined = undefined,
->(
- text: () => string,
- fontA: () => T,
- fontB: () => T,
- weight: () => number,
- size: () => number,
-) {
- let lines = $state([]);
- let containerWidth = $state(0);
-
- /**
- * Type guard to check if a font is defined
- */
- function fontDefined(font: T | undefined): font is T {
- return font !== undefined;
- }
-
- /**
- * Measures text width using canvas 2D context
- *
- * @param ctx - Canvas rendering context
- * @param text - Text string to measure
- * @param fontSize - Font size in pixels
- * @param fontWeight - Font weight (100-900)
- * @param fontFamily - Font family name (optional, returns 0 if missing)
- * @returns Width of text in pixels
- */
- function measureText(
- ctx: CanvasRenderingContext2D,
- text: string,
- fontSize: number,
- fontWeight: number,
- fontFamily?: string,
- ): number {
- if (!fontFamily) return 0;
- ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
- return ctx.measureText(text).width;
- }
-
- /**
- * Gets responsive font size based on viewport width
- *
- * Matches Tailwind breakpoints used in the component:
- * - < 640px: 64px
- * - 640-767px: 80px
- * - 768-1023px: 96px
- * - >= 1024px: 112px
- */
- function getFontSize() {
- if (typeof window === 'undefined') {
- return 64;
- }
- return window.innerWidth >= 1024
- ? 112
- : window.innerWidth >= 768
- ? 96
- : window.innerWidth >= 640
- ? 80
- : 64;
- }
-
- /**
- * Breaks text into lines based on container width
- *
- * Measures text in BOTH fonts and uses the wider width to prevent
- * layout shifts. Uses binary search for efficient word breaking.
- *
- * @param container - Container element to measure width from
- * @param measureCanvas - Hidden canvas element for text measurement
- */
- function breakIntoLines(
- container: HTMLElement | undefined,
- measureCanvas: HTMLCanvasElement | undefined,
- ) {
- if (!container || !measureCanvas || !fontA() || !fontB()) {
- return;
- }
-
- // Use offsetWidth to avoid CSS transform scaling issues
- // getBoundingClientRect() includes transform scale which breaks calculations
- const width = container.offsetWidth;
- containerWidth = width;
-
- const padding = window.innerWidth < 640 ? 48 : 96;
- const availableWidth = width - padding;
- const ctx = measureCanvas.getContext('2d');
- if (!ctx) {
- return;
- }
-
- const controlledFontSize = size();
- const fontSize = getFontSize();
- const currentWeight = weight();
- const words = text().split(' ');
- const newLines: LineData[] = [];
- let currentLineWords: string[] = [];
-
- /**
- * Adds a line to the output using the wider font's width
- */
- function pushLine(words: string[]) {
- if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
- return;
- }
- const lineText = words.join(' ');
- const widthA = measureText(
- ctx!,
- lineText,
- Math.min(fontSize, controlledFontSize),
- currentWeight,
- fontA()?.name,
- );
- const widthB = measureText(
- ctx!,
- lineText,
- Math.min(fontSize, controlledFontSize),
- currentWeight,
- fontB()?.name,
- );
- const maxWidth = Math.max(widthA, widthB);
- newLines.push({ text: lineText, width: maxWidth });
- }
-
- for (const word of words) {
- const testLine = currentLineWords.length > 0
- ? currentLineWords.join(' ') + ' ' + word
- : word;
- // Measure with both fonts - use wider to prevent shifts
- const widthA = measureText(
- ctx,
- testLine,
- Math.min(fontSize, controlledFontSize),
- currentWeight,
- fontA()?.name,
- );
- const widthB = measureText(
- ctx,
- testLine,
- Math.min(fontSize, controlledFontSize),
- currentWeight,
- fontB()?.name,
- );
- const maxWidth = Math.max(widthA, widthB);
- const isContainerOverflown = maxWidth > availableWidth;
-
- if (isContainerOverflown) {
- if (currentLineWords.length > 0) {
- pushLine(currentLineWords);
- currentLineWords = [];
- }
-
- // Check if word alone fits
- const wordWidthA = measureText(
- ctx,
- word,
- Math.min(fontSize, controlledFontSize),
- currentWeight,
- fontA()?.name,
- );
- const wordWidthB = measureText(
- ctx,
- word,
- Math.min(fontSize, controlledFontSize),
- currentWeight,
- fontB()?.name,
- );
- const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
-
- if (wordAloneWidth <= availableWidth) {
- currentLineWords = [word];
- } else {
- // Word doesn't fit - binary search to find break point
- let remainingWord = word;
- while (remainingWord.length > 0) {
- let low = 1;
- let high = remainingWord.length;
- let bestBreak = 1;
-
- // Binary search for maximum characters that fit
- while (low <= high) {
- const mid = Math.floor((low + high) / 2);
- const testFragment = remainingWord.slice(0, mid);
-
- const wA = measureText(
- ctx,
- testFragment,
- fontSize,
- currentWeight,
- fontA()?.name,
- );
- const wB = measureText(
- ctx,
- testFragment,
- fontSize,
- currentWeight,
- fontB()?.name,
- );
-
- if (Math.max(wA, wB) <= availableWidth) {
- bestBreak = mid;
- low = mid + 1;
- } else {
- high = mid - 1;
- }
- }
-
- pushLine([remainingWord.slice(0, bestBreak)]);
- remainingWord = remainingWord.slice(bestBreak);
- }
- }
- } else if (maxWidth > availableWidth && currentLineWords.length > 0) {
- pushLine(currentLineWords);
- currentLineWords = [word];
- } else {
- currentLineWords.push(word);
- }
- }
-
- if (currentLineWords.length > 0) {
- pushLine(currentLineWords);
- }
- lines = newLines;
- }
-
- /**
- * Calculates character proximity to slider position
- *
- * Used for morphing effects - returns how close a character is to
- * the slider and whether it's on the "past" side.
- *
- * @param charIndex - Index of character within its line
- * @param sliderPos - Slider position (0-100, percent across container)
- * @param lineElement - The line element containing the character
- * @param container - The container element for position calculations
- * @returns Proximity (0-1, 1 = at slider) and isPast (true = right of slider)
- */
- function getCharState(
- charIndex: number,
- sliderPos: number,
- lineElement?: HTMLElement,
- container?: HTMLElement,
- ) {
- if (!containerWidth || !container) {
- return {
- proximity: 0,
- isPast: false,
- };
- }
- const charElement = lineElement?.children[charIndex] as HTMLElement;
-
- if (!charElement) {
- return { proximity: 0, isPast: false };
- }
-
- // Get character bounding box relative to container
- const charRect = charElement.getBoundingClientRect();
- const containerRect = container.getBoundingClientRect();
-
- // Calculate character center as percentage of container width
- const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
- const charGlobalPercent = (charCenter / containerWidth) * 100;
-
- // Calculate proximity (1.0 = at slider, 0.0 = 5% away)
- const distance = Math.abs(sliderPos - charGlobalPercent);
- const range = 5;
- const proximity = Math.max(0, 1 - distance / range);
- const isPast = sliderPos > charGlobalPercent;
-
- return { proximity, isPast };
- }
-
- return {
- /** Reactive array of broken lines */
- get lines() {
- return lines;
- },
- /** Container width in pixels */
- get containerWidth() {
- return containerWidth;
- },
- /** Break text into lines based on current container and fonts */
- breakIntoLines,
- /** Get character state for morphing calculations */
- getCharState,
- };
-}
-
-/**
- * Type representing a character comparison instance
- */
-export type CharacterComparison = ReturnType;
diff --git a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.test.ts b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.test.ts
deleted file mode 100644
index 04348a1..0000000
--- a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.test.ts
+++ /dev/null
@@ -1,312 +0,0 @@
-import {
- beforeEach,
- describe,
- expect,
- it,
- vi,
-} from 'vitest';
-import { createCharacterComparison } from './createCharacterComparison.svelte';
-
-type Font = { name: string; id: string };
-
-const fontA: Font = { name: 'Roboto', id: 'roboto' };
-const fontB: Font = { name: 'Open Sans', id: 'open-sans' };
-
-function createMockCanvas(charWidth = 10): HTMLCanvasElement {
- return {
- getContext: () => ({
- font: '',
- measureText: (text: string) => ({ width: text.length * charWidth }),
- }),
- } as unknown as HTMLCanvasElement;
-}
-
-function createMockContainer(offsetWidth = 500): HTMLElement {
- return {
- offsetWidth,
- getBoundingClientRect: () => ({
- left: 0,
- width: offsetWidth,
- top: 0,
- right: offsetWidth,
- bottom: 0,
- height: 0,
- }),
- } as unknown as HTMLElement;
-}
-
-describe('createCharacterComparison', () => {
- beforeEach(() => {
- // Mock window.innerWidth for getFontSize and padding calculations
- Object.defineProperty(globalThis, 'window', {
- value: { innerWidth: 1024 },
- writable: true,
- configurable: true,
- });
- });
-
- describe('Initial State', () => {
- it('should initialize with empty lines and zero container width', () => {
- const comparison = createCharacterComparison(
- () => 'test',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- expect(comparison.lines).toEqual([]);
- expect(comparison.containerWidth).toBe(0);
- });
- });
-
- describe('breakIntoLines', () => {
- it('should not break lines when container or canvas is undefined', () => {
- const comparison = createCharacterComparison(
- () => 'Hello world',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- comparison.breakIntoLines(undefined, undefined);
- expect(comparison.lines).toEqual([]);
-
- comparison.breakIntoLines(createMockContainer(), undefined);
- expect(comparison.lines).toEqual([]);
- });
-
- it('should not break lines when fonts are undefined', () => {
- const comparison = createCharacterComparison(
- () => 'Hello world',
- () => undefined,
- () => undefined,
- () => 400,
- () => 48,
- );
-
- comparison.breakIntoLines(createMockContainer(), createMockCanvas());
- expect(comparison.lines).toEqual([]);
- });
-
- it('should produce a single line when text fits within container', () => {
- // charWidth=10, padding=96 (innerWidth>=640), availableWidth=500-96=404
- // "Hello" = 5 chars * 10 = 50px, fits easily
- const comparison = createCharacterComparison(
- () => 'Hello',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
-
- expect(comparison.lines).toHaveLength(1);
- expect(comparison.lines[0].text).toBe('Hello');
- });
-
- it('should break text into multiple lines when it overflows', () => {
- // charWidth=10, container=200, padding=96, availableWidth=104
- // "Hello world test" => "Hello" (50px), "Hello world" (110px > 104)
- // So "Hello" goes on line 1, "world" (50px) fits, "world test" (100px) fits
- const comparison = createCharacterComparison(
- () => 'Hello world test',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- comparison.breakIntoLines(createMockContainer(200), createMockCanvas(10));
-
- expect(comparison.lines.length).toBeGreaterThan(1);
- // All original text should be preserved across lines
- const reconstructed = comparison.lines.map(l => l.text).join(' ');
- expect(reconstructed).toBe('Hello world test');
- });
-
- it('should update containerWidth after breaking lines', () => {
- const comparison = createCharacterComparison(
- () => 'Hi',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- comparison.breakIntoLines(createMockContainer(750), createMockCanvas(10));
-
- expect(comparison.containerWidth).toBe(750);
- });
-
- it('should use smaller padding on narrow viewports', () => {
- Object.defineProperty(globalThis, 'window', {
- value: { innerWidth: 500 },
- writable: true,
- configurable: true,
- });
-
- // container=150, padding=48 (innerWidth<640), availableWidth=102
- // "ABCDEFGHIJ" = 10 chars * 10 = 100px, fits in 102
- const comparison = createCharacterComparison(
- () => 'ABCDEFGHIJ',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
-
- expect(comparison.lines).toHaveLength(1);
- expect(comparison.lines[0].text).toBe('ABCDEFGHIJ');
- });
-
- it('should break a single long word using binary search', () => {
- // container=150, padding=96, availableWidth=54
- // "ABCDEFGHIJ" = 10 chars * 10 = 100px, doesn't fit as single word
- // Binary search should split it
- const comparison = createCharacterComparison(
- () => 'ABCDEFGHIJ',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
-
- expect(comparison.lines.length).toBeGreaterThan(1);
- const reconstructed = comparison.lines.map(l => l.text).join('');
- expect(reconstructed).toBe('ABCDEFGHIJ');
- });
-
- it('should store max width between both fonts for each line', () => {
- // Use a canvas where measureText returns text.length * charWidth
- // Both fonts measure the same, so width = text.length * charWidth
- const comparison = createCharacterComparison(
- () => 'Hi',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
-
- expect(comparison.lines[0].width).toBe(20); // "Hi" = 2 chars * 10
- });
- });
-
- describe('getCharState', () => {
- it('should return zero proximity and isPast=false when containerWidth is 0', () => {
- const comparison = createCharacterComparison(
- () => 'test',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- const state = comparison.getCharState(0, 50, undefined, undefined);
-
- expect(state.proximity).toBe(0);
- expect(state.isPast).toBe(false);
- });
-
- it('should return zero proximity when charElement is not found', () => {
- const comparison = createCharacterComparison(
- () => 'test',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- // First break lines to set containerWidth
- comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
-
- const lineEl = { children: [] } as unknown as HTMLElement;
- const container = createMockContainer(500);
- const state = comparison.getCharState(0, 50, lineEl, container);
-
- expect(state.proximity).toBe(0);
- expect(state.isPast).toBe(false);
- });
-
- it('should calculate proximity based on distance from slider', () => {
- const comparison = createCharacterComparison(
- () => 'test',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
-
- // Character centered at 250px in a 500px container = 50%
- const charEl = {
- getBoundingClientRect: () => ({ left: 240, width: 20 }),
- };
- const lineEl = { children: [charEl] } as unknown as HTMLElement;
- const container = createMockContainer(500);
-
- // Slider at 50% => charCenter at 250px => charGlobalPercent = 50%
- // distance = |50 - 50| = 0, proximity = max(0, 1 - 0/5) = 1
- const state = comparison.getCharState(0, 50, lineEl, container);
-
- expect(state.proximity).toBe(1);
- expect(state.isPast).toBe(false);
- });
-
- it('should return isPast=true when slider is past the character', () => {
- const comparison = createCharacterComparison(
- () => 'test',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
-
- // Character centered at 100px => 20% of 500px
- const charEl = {
- getBoundingClientRect: () => ({ left: 90, width: 20 }),
- };
- const lineEl = { children: [charEl] } as unknown as HTMLElement;
- const container = createMockContainer(500);
-
- // Slider at 80% => past the character at 20%
- const state = comparison.getCharState(0, 80, lineEl, container);
-
- expect(state.isPast).toBe(true);
- });
-
- it('should return zero proximity when character is far from slider', () => {
- const comparison = createCharacterComparison(
- () => 'test',
- () => fontA,
- () => fontB,
- () => 400,
- () => 48,
- );
-
- comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
-
- // Character at 10% of container, slider at 90% => distance = 80%, range = 5%
- const charEl = {
- getBoundingClientRect: () => ({ left: 45, width: 10 }),
- };
- const lineEl = { children: [charEl] } as unknown as HTMLElement;
- const container = createMockContainer(500);
-
- const state = comparison.getCharState(0, 90, lineEl, container);
-
- expect(state.proximity).toBe(0);
- });
- });
-});
diff --git a/src/shared/lib/helpers/index.ts b/src/shared/lib/helpers/index.ts
index fc63b39..1580dcd 100644
--- a/src/shared/lib/helpers/index.ts
+++ b/src/shared/lib/helpers/index.ts
@@ -52,10 +52,16 @@ export {
} from './createEntityStore/createEntityStore.svelte';
export {
- type CharacterComparison,
- createCharacterComparison,
- type LineData,
-} from './createCharacterComparison/createCharacterComparison.svelte';
+ CharacterComparisonEngine,
+ type ComparisonLine,
+ type ComparisonResult,
+} from './CharacterComparisonEngine/CharacterComparisonEngine.svelte';
+
+export {
+ type LayoutLine as TextLayoutLine,
+ type LayoutResult as TextLayoutResult,
+ TextLayoutEngine,
+} from './TextLayoutEngine/TextLayoutEngine.svelte';
export {
createPersistentStore,
diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts
index 5270695..33c077c 100644
--- a/src/shared/lib/index.ts
+++ b/src/shared/lib/index.ts
@@ -5,10 +5,11 @@
*/
export {
- type CharacterComparison,
+ CharacterComparisonEngine,
+ type ComparisonLine,
+ type ComparisonResult,
type ControlDataModel,
type ControlModel,
- createCharacterComparison,
createDebouncedState,
createEntityStore,
createFilter,
@@ -21,12 +22,14 @@ export {
type EntityStore,
type Filter,
type FilterModel,
- type LineData,
type PersistentStore,
type PerspectiveManager,
type Property,
type ResponsiveManager,
responsiveManager,
+ TextLayoutEngine,
+ type TextLayoutLine,
+ type TextLayoutResult,
type TypographyControl,
type VirtualItem,
type Virtualizer,