From 338ca9b4fd7551f9b4e620cc248f05dc5c425857 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 11 Apr 2026 16:44:49 +0300 Subject: [PATCH] feat: export TextLayoutEngine and CharacterComparisonEngine from shared helpers index Remove deleted createCharacterComparison exports and benchmark. --- .../createCharacterComparison.svelte.ts | 374 ------------------ .../createCharacterComparison.test.ts | 312 --------------- src/shared/lib/helpers/index.ts | 14 +- src/shared/lib/index.ts | 9 +- 4 files changed, 16 insertions(+), 693 deletions(-) delete mode 100644 src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts delete mode 100644 src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.test.ts 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,