feat(ComparisonSlider): create reusable comparison slider that compare two fonts for the same text. Line breaking is supported

This commit is contained in:
Ilia Mashkov
2026-01-20 09:32:12 +03:00
parent fb190f82b9
commit b5ad3249ae
7 changed files with 422 additions and 0 deletions

View File

@@ -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,
};
}

View File

@@ -26,3 +26,7 @@ export {
type Entity,
type EntityStore,
} from './createEntityStore/createEntityStore.svelte';
export {
createCharacterComparison,
} from './createCharacterComparison/createCharacterComparison.svelte';

View File

@@ -1,6 +1,7 @@
export {
type ControlDataModel,
type ControlModel,
createCharacterComparison,
createDebouncedState,
createEntityStore,
createFilter,