feat(ComparisonSlider): create reusable comparison slider that compare two fonts for the same text. Line breaking is supported
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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 },
|
||||
) {
|
||||
let lines = $state<LineData[]>([]);
|
||||
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
|
||||
*/
|
||||
function measureText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
fontFamily: string,
|
||||
fontSize: number,
|
||||
): number {
|
||||
ctx.font = `bold ${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 fontSize = getFontSize();
|
||||
const words = text().split(' ');
|
||||
const newLines: LineData[] = [];
|
||||
let currentLineWords: string[] = [];
|
||||
|
||||
function pushLine(words: string[]) {
|
||||
if (words.length === 0) return;
|
||||
const lineText = words.join(' ');
|
||||
// Measure width to ensure we know exactly how wide this line renders
|
||||
const widthA = measureText(ctx!, lineText, fontA().name, fontSize);
|
||||
const widthB = measureText(ctx!, lineText, fontB().name, fontSize);
|
||||
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, fontSize);
|
||||
const widthB = measureText(ctx, testLine, fontB().name, fontSize);
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user