feat: export TextLayoutEngine and CharacterComparisonEngine from shared helpers index
Remove deleted createCharacterComparison exports and benchmark.
This commit is contained in:
@@ -1,374 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>;
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
import {
|
|
||||||
beforeEach,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
it,
|
|
||||||
vi,
|
|
||||||
} from 'vitest';
|
|
||||||
import { createCharacterComparison } from './createCharacterComparison.svelte';
|
|
||||||
|
|
||||||
type Font = { name: string; id: string };
|
|
||||||
|
|
||||||
const fontA: Font = { name: 'Roboto', id: 'roboto' };
|
|
||||||
const fontB: Font = { name: 'Open Sans', id: 'open-sans' };
|
|
||||||
|
|
||||||
function createMockCanvas(charWidth = 10): HTMLCanvasElement {
|
|
||||||
return {
|
|
||||||
getContext: () => ({
|
|
||||||
font: '',
|
|
||||||
measureText: (text: string) => ({ width: text.length * charWidth }),
|
|
||||||
}),
|
|
||||||
} as unknown as HTMLCanvasElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMockContainer(offsetWidth = 500): HTMLElement {
|
|
||||||
return {
|
|
||||||
offsetWidth,
|
|
||||||
getBoundingClientRect: () => ({
|
|
||||||
left: 0,
|
|
||||||
width: offsetWidth,
|
|
||||||
top: 0,
|
|
||||||
right: offsetWidth,
|
|
||||||
bottom: 0,
|
|
||||||
height: 0,
|
|
||||||
}),
|
|
||||||
} as unknown as HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('createCharacterComparison', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
// Mock window.innerWidth for getFontSize and padding calculations
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
value: { innerWidth: 1024 },
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Initial State', () => {
|
|
||||||
it('should initialize with empty lines and zero container width', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(comparison.lines).toEqual([]);
|
|
||||||
expect(comparison.containerWidth).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('breakIntoLines', () => {
|
|
||||||
it('should not break lines when container or canvas is undefined', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'Hello world',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(undefined, undefined);
|
|
||||||
expect(comparison.lines).toEqual([]);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(), undefined);
|
|
||||||
expect(comparison.lines).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not break lines when fonts are undefined', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'Hello world',
|
|
||||||
() => undefined,
|
|
||||||
() => undefined,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(), createMockCanvas());
|
|
||||||
expect(comparison.lines).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should produce a single line when text fits within container', () => {
|
|
||||||
// charWidth=10, padding=96 (innerWidth>=640), availableWidth=500-96=404
|
|
||||||
// "Hello" = 5 chars * 10 = 50px, fits easily
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'Hello',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
|
||||||
|
|
||||||
expect(comparison.lines).toHaveLength(1);
|
|
||||||
expect(comparison.lines[0].text).toBe('Hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should break text into multiple lines when it overflows', () => {
|
|
||||||
// charWidth=10, container=200, padding=96, availableWidth=104
|
|
||||||
// "Hello world test" => "Hello" (50px), "Hello world" (110px > 104)
|
|
||||||
// So "Hello" goes on line 1, "world" (50px) fits, "world test" (100px) fits
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'Hello world test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(200), createMockCanvas(10));
|
|
||||||
|
|
||||||
expect(comparison.lines.length).toBeGreaterThan(1);
|
|
||||||
// All original text should be preserved across lines
|
|
||||||
const reconstructed = comparison.lines.map(l => l.text).join(' ');
|
|
||||||
expect(reconstructed).toBe('Hello world test');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update containerWidth after breaking lines', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'Hi',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(750), createMockCanvas(10));
|
|
||||||
|
|
||||||
expect(comparison.containerWidth).toBe(750);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use smaller padding on narrow viewports', () => {
|
|
||||||
Object.defineProperty(globalThis, 'window', {
|
|
||||||
value: { innerWidth: 500 },
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// container=150, padding=48 (innerWidth<640), availableWidth=102
|
|
||||||
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, fits in 102
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'ABCDEFGHIJ',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
|
|
||||||
|
|
||||||
expect(comparison.lines).toHaveLength(1);
|
|
||||||
expect(comparison.lines[0].text).toBe('ABCDEFGHIJ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should break a single long word using binary search', () => {
|
|
||||||
// container=150, padding=96, availableWidth=54
|
|
||||||
// "ABCDEFGHIJ" = 10 chars * 10 = 100px, doesn't fit as single word
|
|
||||||
// Binary search should split it
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'ABCDEFGHIJ',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(150), createMockCanvas(10));
|
|
||||||
|
|
||||||
expect(comparison.lines.length).toBeGreaterThan(1);
|
|
||||||
const reconstructed = comparison.lines.map(l => l.text).join('');
|
|
||||||
expect(reconstructed).toBe('ABCDEFGHIJ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should store max width between both fonts for each line', () => {
|
|
||||||
// Use a canvas where measureText returns text.length * charWidth
|
|
||||||
// Both fonts measure the same, so width = text.length * charWidth
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'Hi',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
|
||||||
|
|
||||||
expect(comparison.lines[0].width).toBe(20); // "Hi" = 2 chars * 10
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getCharState', () => {
|
|
||||||
it('should return zero proximity and isPast=false when containerWidth is 0', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
const state = comparison.getCharState(0, 50, undefined, undefined);
|
|
||||||
|
|
||||||
expect(state.proximity).toBe(0);
|
|
||||||
expect(state.isPast).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return zero proximity when charElement is not found', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
// First break lines to set containerWidth
|
|
||||||
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
|
||||||
|
|
||||||
const lineEl = { children: [] } as unknown as HTMLElement;
|
|
||||||
const container = createMockContainer(500);
|
|
||||||
const state = comparison.getCharState(0, 50, lineEl, container);
|
|
||||||
|
|
||||||
expect(state.proximity).toBe(0);
|
|
||||||
expect(state.isPast).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate proximity based on distance from slider', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
|
||||||
|
|
||||||
// Character centered at 250px in a 500px container = 50%
|
|
||||||
const charEl = {
|
|
||||||
getBoundingClientRect: () => ({ left: 240, width: 20 }),
|
|
||||||
};
|
|
||||||
const lineEl = { children: [charEl] } as unknown as HTMLElement;
|
|
||||||
const container = createMockContainer(500);
|
|
||||||
|
|
||||||
// Slider at 50% => charCenter at 250px => charGlobalPercent = 50%
|
|
||||||
// distance = |50 - 50| = 0, proximity = max(0, 1 - 0/5) = 1
|
|
||||||
const state = comparison.getCharState(0, 50, lineEl, container);
|
|
||||||
|
|
||||||
expect(state.proximity).toBe(1);
|
|
||||||
expect(state.isPast).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return isPast=true when slider is past the character', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
|
||||||
|
|
||||||
// Character centered at 100px => 20% of 500px
|
|
||||||
const charEl = {
|
|
||||||
getBoundingClientRect: () => ({ left: 90, width: 20 }),
|
|
||||||
};
|
|
||||||
const lineEl = { children: [charEl] } as unknown as HTMLElement;
|
|
||||||
const container = createMockContainer(500);
|
|
||||||
|
|
||||||
// Slider at 80% => past the character at 20%
|
|
||||||
const state = comparison.getCharState(0, 80, lineEl, container);
|
|
||||||
|
|
||||||
expect(state.isPast).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return zero proximity when character is far from slider', () => {
|
|
||||||
const comparison = createCharacterComparison(
|
|
||||||
() => 'test',
|
|
||||||
() => fontA,
|
|
||||||
() => fontB,
|
|
||||||
() => 400,
|
|
||||||
() => 48,
|
|
||||||
);
|
|
||||||
|
|
||||||
comparison.breakIntoLines(createMockContainer(500), createMockCanvas(10));
|
|
||||||
|
|
||||||
// Character at 10% of container, slider at 90% => distance = 80%, range = 5%
|
|
||||||
const charEl = {
|
|
||||||
getBoundingClientRect: () => ({ left: 45, width: 10 }),
|
|
||||||
};
|
|
||||||
const lineEl = { children: [charEl] } as unknown as HTMLElement;
|
|
||||||
const container = createMockContainer(500);
|
|
||||||
|
|
||||||
const state = comparison.getCharState(0, 90, lineEl, container);
|
|
||||||
|
|
||||||
expect(state.proximity).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -52,10 +52,16 @@ export {
|
|||||||
} from './createEntityStore/createEntityStore.svelte';
|
} from './createEntityStore/createEntityStore.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type CharacterComparison,
|
CharacterComparisonEngine,
|
||||||
createCharacterComparison,
|
type ComparisonLine,
|
||||||
type LineData,
|
type ComparisonResult,
|
||||||
} from './createCharacterComparison/createCharacterComparison.svelte';
|
} from './CharacterComparisonEngine/CharacterComparisonEngine.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
type LayoutLine as TextLayoutLine,
|
||||||
|
type LayoutResult as TextLayoutResult,
|
||||||
|
TextLayoutEngine,
|
||||||
|
} from './TextLayoutEngine/TextLayoutEngine.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createPersistentStore,
|
createPersistentStore,
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export {
|
export {
|
||||||
type CharacterComparison,
|
CharacterComparisonEngine,
|
||||||
|
type ComparisonLine,
|
||||||
|
type ComparisonResult,
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
createCharacterComparison,
|
|
||||||
createDebouncedState,
|
createDebouncedState,
|
||||||
createEntityStore,
|
createEntityStore,
|
||||||
createFilter,
|
createFilter,
|
||||||
@@ -21,12 +22,14 @@ export {
|
|||||||
type EntityStore,
|
type EntityStore,
|
||||||
type Filter,
|
type Filter,
|
||||||
type FilterModel,
|
type FilterModel,
|
||||||
type LineData,
|
|
||||||
type PersistentStore,
|
type PersistentStore,
|
||||||
type PerspectiveManager,
|
type PerspectiveManager,
|
||||||
type Property,
|
type Property,
|
||||||
type ResponsiveManager,
|
type ResponsiveManager,
|
||||||
responsiveManager,
|
responsiveManager,
|
||||||
|
TextLayoutEngine,
|
||||||
|
type TextLayoutLine,
|
||||||
|
type TextLayoutResult,
|
||||||
type TypographyControl,
|
type TypographyControl,
|
||||||
type VirtualItem,
|
type VirtualItem,
|
||||||
type Virtualizer,
|
type Virtualizer,
|
||||||
|
|||||||
Reference in New Issue
Block a user