375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
/**
|
|
* Character-by-character font comparison helper
|
|
*
|
|
* Creates utilities for comparing two fonts character by character.
|
|
* Used by the ComparisonView widget to render morphing text effects
|
|
* where characters transition between font A and font B based on
|
|
* slider position.
|
|
*
|
|
* Features:
|
|
* - Responsive text measurement using canvas
|
|
* - Binary search for optimal line breaking
|
|
* - Character proximity calculation for morphing effects
|
|
* - Handles CSS transforms correctly (uses offsetWidth)
|
|
*
|
|
* @example
|
|
* ```svelte
|
|
* <script lang="ts">
|
|
* import { createCharacterComparison } from '$shared/lib/helpers';
|
|
*
|
|
* const comparison = createCharacterComparison(
|
|
* () => text,
|
|
* () => fontA,
|
|
* () => fontB,
|
|
* () => weight,
|
|
* () => size
|
|
* );
|
|
*
|
|
* $: lines = comparison.lines;
|
|
* </script>
|
|
*
|
|
* <canvas bind:this={measureCanvas} hidden></canvas>
|
|
* <div bind:this={container}>
|
|
* {#each lines as line}
|
|
* <span>{line.text}</span>
|
|
* {/each}
|
|
* </div>
|
|
* ```
|
|
*/
|
|
|
|
/**
|
|
* Represents a single line of text with its measured width
|
|
*/
|
|
export interface LineData {
|
|
/** The text content of the line */
|
|
text: string;
|
|
/** Maximum width between both fonts in pixels */
|
|
width: number;
|
|
}
|
|
|
|
/**
|
|
* Creates a character comparison helper for morphing text effects
|
|
*
|
|
* Measures text in both fonts to determine line breaks and calculates
|
|
* character-level proximity for morphing animations.
|
|
*
|
|
* @param text - Getter for the text to compare
|
|
* @param fontA - Getter for the first font (left/top side)
|
|
* @param fontB - Getter for the second font (right/bottom side)
|
|
* @param weight - Getter for the current font weight
|
|
* @param size - Getter for the controlled font size
|
|
* @returns Character comparison instance with lines and proximity calculations
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const comparison = createCharacterComparison(
|
|
* () => $sampleText,
|
|
* () => $selectedFontA,
|
|
* () => $selectedFontB,
|
|
* () => $fontWeight,
|
|
* () => $fontSize
|
|
* );
|
|
*
|
|
* // Call when DOM is ready
|
|
* comparison.breakIntoLines(container, canvas);
|
|
*
|
|
* // Get character state for morphing
|
|
* const state = comparison.getCharState(5, sliderPosition, lineEl, container);
|
|
* // state.proximity: 0-1 value for opacity/interpolation
|
|
* // state.isPast: true if slider is past this character
|
|
* ```
|
|
*/
|
|
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);
|
|
|
|
/**
|
|
* Type guard to check if a font is defined
|
|
*/
|
|
function fontDefined<T extends { name: string; id: string }>(font: T | undefined): font is T {
|
|
return font !== undefined;
|
|
}
|
|
|
|
/**
|
|
* Measures text width using canvas 2D context
|
|
*
|
|
* @param ctx - Canvas rendering context
|
|
* @param text - Text string to measure
|
|
* @param fontSize - Font size in pixels
|
|
* @param fontWeight - Font weight (100-900)
|
|
* @param fontFamily - Font family name (optional, returns 0 if missing)
|
|
* @returns Width of text in pixels
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Gets responsive font size based on viewport width
|
|
*
|
|
* Matches Tailwind breakpoints used in the component:
|
|
* - < 640px: 64px
|
|
* - 640-767px: 80px
|
|
* - 768-1023px: 96px
|
|
* - >= 1024px: 112px
|
|
*/
|
|
function getFontSize() {
|
|
if (typeof window === 'undefined') {
|
|
return 64;
|
|
}
|
|
return window.innerWidth >= 1024
|
|
? 112
|
|
: window.innerWidth >= 768
|
|
? 96
|
|
: window.innerWidth >= 640
|
|
? 80
|
|
: 64;
|
|
}
|
|
|
|
/**
|
|
* Breaks text into lines based on container width
|
|
*
|
|
* Measures text in BOTH fonts and uses the wider width to prevent
|
|
* layout shifts. Uses binary search for efficient word breaking.
|
|
*
|
|
* @param container - Container element to measure width from
|
|
* @param measureCanvas - Hidden canvas element for text measurement
|
|
*/
|
|
function breakIntoLines(
|
|
container: HTMLElement | undefined,
|
|
measureCanvas: HTMLCanvasElement | undefined,
|
|
) {
|
|
if (!container || !measureCanvas || !fontA() || !fontB()) {
|
|
return;
|
|
}
|
|
|
|
// Use offsetWidth to avoid CSS transform scaling issues
|
|
// getBoundingClientRect() includes transform scale which breaks calculations
|
|
const width = container.offsetWidth;
|
|
containerWidth = width;
|
|
|
|
const padding = window.innerWidth < 640 ? 48 : 96;
|
|
const availableWidth = width - padding;
|
|
const ctx = measureCanvas.getContext('2d');
|
|
if (!ctx) {
|
|
return;
|
|
}
|
|
|
|
const controlledFontSize = size();
|
|
const fontSize = getFontSize();
|
|
const currentWeight = weight();
|
|
const words = text().split(' ');
|
|
const newLines: LineData[] = [];
|
|
let currentLineWords: string[] = [];
|
|
|
|
/**
|
|
* Adds a line to the output using the wider font's width
|
|
*/
|
|
function pushLine(words: string[]) {
|
|
if (words.length === 0 || !fontDefined(fontA()) || !fontDefined(fontB())) {
|
|
return;
|
|
}
|
|
const lineText = words.join(' ');
|
|
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 - use wider to prevent 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 = [];
|
|
}
|
|
|
|
// Check if word alone fits
|
|
const wordWidthA = measureText(
|
|
ctx,
|
|
word,
|
|
Math.min(fontSize, controlledFontSize),
|
|
currentWeight,
|
|
fontA()?.name,
|
|
);
|
|
const wordWidthB = measureText(
|
|
ctx,
|
|
word,
|
|
Math.min(fontSize, controlledFontSize),
|
|
currentWeight,
|
|
fontB()?.name,
|
|
);
|
|
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
|
|
|
|
if (wordAloneWidth <= availableWidth) {
|
|
currentLineWords = [word];
|
|
} else {
|
|
// Word doesn't fit - binary search to find break point
|
|
let remainingWord = word;
|
|
while (remainingWord.length > 0) {
|
|
let low = 1;
|
|
let high = remainingWord.length;
|
|
let bestBreak = 1;
|
|
|
|
// Binary search for 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;
|
|
}
|
|
|
|
/**
|
|
* Calculates character proximity to slider position
|
|
*
|
|
* Used for morphing effects - returns how close a character is to
|
|
* the slider and whether it's on the "past" side.
|
|
*
|
|
* @param charIndex - Index of character within its line
|
|
* @param sliderPos - Slider position (0-100, percent across container)
|
|
* @param lineElement - The line element containing the character
|
|
* @param container - The container element for position calculations
|
|
* @returns Proximity (0-1, 1 = at slider) and isPast (true = right of slider)
|
|
*/
|
|
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 character bounding box relative to container
|
|
const charRect = charElement.getBoundingClientRect();
|
|
const containerRect = container.getBoundingClientRect();
|
|
|
|
// Calculate character center as percentage of container width
|
|
const charCenter = charRect.left + (charRect.width / 2) - containerRect.left;
|
|
const charGlobalPercent = (charCenter / containerWidth) * 100;
|
|
|
|
// Calculate proximity (1.0 = at slider, 0.0 = 5% away)
|
|
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 {
|
|
/** Reactive array of broken lines */
|
|
get lines() {
|
|
return lines;
|
|
},
|
|
/** Container width in pixels */
|
|
get containerWidth() {
|
|
return containerWidth;
|
|
},
|
|
/** Break text into lines based on current container and fonts */
|
|
breakIntoLines,
|
|
/** Get character state for morphing calculations */
|
|
getCharState,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Type representing a character comparison instance
|
|
*/
|
|
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;
|