/** * 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;