refactor(helpers): modernize reactive helpers and add tests

This commit is contained in:
Ilia Mashkov
2026-03-02 22:18:59 +03:00
parent 594af924c7
commit ac73fd5044
12 changed files with 1117 additions and 185 deletions

View File

@@ -1,25 +1,83 @@
/**
* Interface representing a line of text with its measured width.
* 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 {
/**
* Line's text
*/
/** The text content of the line */
text: string;
/**
* It's width
*/
/** Maximum width between both fonts in pixels */
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.
* Creates a character comparison helper for morphing text effects
*
* @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)
* 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,
@@ -33,17 +91,22 @@ export function createCharacterComparison<
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 a canvas context.
* Measures text width using canvas 2D 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
* @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,
@@ -58,8 +121,13 @@ export function createCharacterComparison<
}
/**
* Determines the appropriate font size based on window width.
* Matches the Tailwind breakpoints used in the component.
* 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') {
@@ -75,13 +143,14 @@ export function createCharacterComparison<
}
/**
* Breaks the text into lines based on the container width and measure canvas.
* Populates the `lines` state.
* Breaks text into lines based on container width
*
* @param container - The container element to measure width from
* @param measureCanvas - The canvas element used for text measurement
* 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,
@@ -90,13 +159,11 @@ export function createCharacterComparison<
return;
}
// Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues
// getBoundingClientRect() returns transformed dimensions, which causes incorrect line breaking
// when PerspectivePlan applies scale() transforms (e.g., scale(0.5) in settings mode)
// Use offsetWidth to avoid CSS transform scaling issues
// getBoundingClientRect() includes transform scale which breaks calculations
const width = container.offsetWidth;
containerWidth = width;
// Padding considerations - matches the container padding
const padding = window.innerWidth < 640 ? 48 : 96;
const availableWidth = width - padding;
const ctx = measureCanvas.getContext('2d');
@@ -106,17 +173,19 @@ export function createCharacterComparison<
const controlledFontSize = size();
const fontSize = getFontSize();
const currentWeight = weight(); // Get current weight
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(' ');
// Measure both fonts at the CURRENT weight
const widthA = measureText(
ctx!,
lineText,
@@ -139,7 +208,7 @@ export function createCharacterComparison<
const testLine = currentLineWords.length > 0
? currentLineWords.join(' ') + ' ' + word
: word;
// Measure with both fonts and use the wider one to prevent layout shifts
// Measure with both fonts - use wider to prevent shifts
const widthA = measureText(
ctx,
testLine,
@@ -163,6 +232,7 @@ export function createCharacterComparison<
currentLineWords = [];
}
// Check if word alone fits
const wordWidthA = measureText(
ctx,
word,
@@ -180,16 +250,16 @@ export function createCharacterComparison<
const wordAloneWidth = Math.max(wordWidthA, wordWidthB);
if (wordAloneWidth <= availableWidth) {
// If word fits start new line with it
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 to find the maximum characters that fit
// Binary search for maximum characters that fit
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const testFragment = remainingWord.slice(0, mid);
@@ -236,13 +306,16 @@ export function createCharacterComparison<
}
/**
* precise calculation of character state based on global slider position.
* Calculates character proximity to 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)
* 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,
@@ -262,14 +335,15 @@ export function createCharacterComparison<
return { proximity: 0, isPast: false };
}
// Get the actual bounding box of the character
// Get character bounding box relative to container
const charRect = charElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
// Calculate character center relative to container
// 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);
@@ -279,15 +353,22 @@ export function createCharacterComparison<
}
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>;