258 lines
8.4 KiB
TypeScript
258 lines
8.4 KiB
TypeScript
/**
|
|
* 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<
|
|
T extends { name: string; id: string } | undefined = undefined,
|
|
>(
|
|
text: () => string,
|
|
fontA: () => T,
|
|
fontB: () => T,
|
|
weight: () => number,
|
|
size: () => number,
|
|
) {
|
|
let lines = $state<LineData[]>([]);
|
|
let containerWidth = $state(0);
|
|
|
|
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
|
|
return font !== undefined;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
fontSize: number,
|
|
fontWeight: number,
|
|
fontFamily?: string,
|
|
): number {
|
|
if (!fontFamily) return 0;
|
|
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 || !fontA() || !fontB()) 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 || !fontDefined(fontA()) || !fontDefined(fontB())) {
|
|
return;
|
|
}
|
|
const lineText = words.join(' ');
|
|
// Measure both fonts at the CURRENT weight
|
|
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 and use the wider one to prevent layout 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 = [];
|
|
}
|
|
|
|
let remainingWord = word;
|
|
while (remainingWord.length > 0) {
|
|
let low = 1;
|
|
let high = remainingWord.length;
|
|
let bestBreak = 1;
|
|
|
|
// Binary Search to find the 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;
|
|
}
|
|
|
|
/**
|
|
* precise calculation of character state based on global slider position.
|
|
*
|
|
* @param charIndex - Index of the character in the line
|
|
* @param sliderPos - Current slider position (0-100)
|
|
* @param lineElement - The line element
|
|
* @param container - The container element
|
|
* @returns Object containing proximity (0-1) and isPast (boolean)
|
|
*/
|
|
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 the actual bounding box of the character
|
|
const charRect = charElement.getBoundingClientRect();
|
|
const containerRect = container.getBoundingClientRect();
|
|
|
|
// Calculate character center relative to container
|
|
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
|
|
const charGlobalPercent = (charCenter / containerWidth) * 100;
|
|
|
|
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 {
|
|
get lines() {
|
|
return lines;
|
|
},
|
|
get containerWidth() {
|
|
return containerWidth;
|
|
},
|
|
breakIntoLines,
|
|
getCharState,
|
|
};
|
|
}
|