/** * Interface representing a line of text with its measured width. */ export interface LineData { text: string; width: number; } /** * Creates a helper for splitting text into lines and calculating character proximity. * This is used by the ComparisonSlider (TestTen) to render morphing text. * * @param text - The text to split and measure * @param fontA - The first font definition * @param fontB - The second font definition * @returns Object with reactive state (lines, containerWidth) and methods (breakIntoLines, getCharState) */ export function createCharacterComparison( text: () => string, fontA: () => { name: string; id: string }, fontB: () => { name: string; id: string }, weight: () => number, size: () => number, ) { let lines = $state([]); let containerWidth = $state(0); /** * Measures text width using a canvas context. * @param ctx - Canvas rendering context * @param text - Text string to measure * @param fontFamily - Font family name * @param fontSize - Font size in pixels * @param fontWeight - Font weight */ function measureText( ctx: CanvasRenderingContext2D, text: string, fontFamily: string, fontSize: number, fontWeight: number, ): number { ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`; return ctx.measureText(text).width; } /** * Determines the appropriate font size based on window width. * Matches the Tailwind breakpoints used in the component. */ function getFontSize() { if (typeof window === 'undefined') { return 64; } return window.innerWidth >= 1024 ? 112 : window.innerWidth >= 768 ? 96 : window.innerWidth >= 640 ? 80 : 64; } /** * Breaks the text into lines based on the container width and measure canvas. * Populates the `lines` state. * * @param container - The container element to measure width from * @param measureCanvas - The canvas element used for text measurement */ function breakIntoLines( container: HTMLElement | undefined, measureCanvas: HTMLCanvasElement | undefined, ) { if (!container || !measureCanvas) return; const rect = container.getBoundingClientRect(); containerWidth = rect.width; // Padding considerations - matches the container padding const padding = window.innerWidth < 640 ? 48 : 96; const availableWidth = rect.width - padding; const ctx = measureCanvas.getContext('2d'); if (!ctx) return; const controlledFontSize = size(); const fontSize = getFontSize(); const currentWeight = weight(); // Get current weight const words = text().split(' '); const newLines: LineData[] = []; let currentLineWords: string[] = []; function pushLine(words: string[]) { if (words.length === 0) return; const lineText = words.join(' '); // Measure both fonts at the CURRENT weight const widthA = measureText( ctx!, lineText, fontA().name, Math.min(fontSize, controlledFontSize), currentWeight, ); const widthB = measureText( ctx!, lineText, fontB().name, Math.min(fontSize, controlledFontSize), currentWeight, ); 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 and use the wider one to prevent layout shifts const widthA = measureText( ctx, testLine, fontA().name, Math.min(fontSize, controlledFontSize), currentWeight, ); const widthB = measureText( ctx, testLine, fontB().name, Math.min(fontSize, controlledFontSize), currentWeight, ); const maxWidth = Math.max(widthA, widthB); if (maxWidth > availableWidth && currentLineWords.length > 0) { pushLine(currentLineWords); currentLineWords = [word]; } else { currentLineWords.push(word); } } if (currentLineWords.length > 0) pushLine(currentLineWords); lines = newLines; } /** * precise calculation of character state based on global slider position. * * @param lineIndex - Index of the line * @param charIndex - Index of the character in the line * @param lineData - The line data object * @param sliderPos - Current slider position (0-100) * @returns Object containing proximity (0-1) and isPast (boolean) */ function getCharState( lineIndex: number, charIndex: number, lineData: LineData, sliderPos: number, ) { if (!containerWidth) return { proximity: 0, isPast: false }; // Calculate the pixel position of the character relative to the CONTAINER // 1. Find the left edge of the centered line const lineStartOffset = (containerWidth - lineData.width) / 2; // 2. Find the character's center relative to the line const charRelativePercent = (charIndex + 0.5) / lineData.text.length; const charPixelPos = lineStartOffset + (charRelativePercent * lineData.width); // 3. Convert back to global percentage (0-100) const charGlobalPercent = (charPixelPos / containerWidth) * 100; const distance = Math.abs(sliderPos - charGlobalPercent); // Proximity range: +/- 15% around the slider const range = 15; const proximity = Math.max(0, 1 - distance / range); const isPast = sliderPos > charGlobalPercent; return { proximity, isPast }; } return { get lines() { return lines; }, get containerWidth() { return containerWidth; }, breakIntoLines, getCharState, }; }