198 lines
6.4 KiB
TypeScript
198 lines
6.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(
|
|
text: () => string,
|
|
fontA: () => { name: string; id: string },
|
|
fontB: () => { name: string; id: string },
|
|
weight: () => number,
|
|
size: () => number,
|
|
) {
|
|
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
|
|
* @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,
|
|
};
|
|
}
|