From b5ad3249ae133cc612dae953ee0ec2d5f3888728 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 20 Jan 2026 09:32:12 +0300 Subject: [PATCH] feat(ComparisonSlider): create reusable comparison slider that compare two fonts for the same text. Line breaking is supported --- .../createCharacterComparison.svelte.ts | 170 ++++++++++++++++ src/shared/lib/helpers/index.ts | 4 + src/shared/lib/index.ts | 1 + .../ComparisonSlider/ComparisonSlider.svelte | 182 ++++++++++++++++++ .../ComparisonSlider/components/Labels.svelte | 37 ++++ .../components/SliderLine.svelte | 26 +++ src/shared/ui/index.ts | 2 + 7 files changed, 422 insertions(+) create mode 100644 src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts create mode 100644 src/shared/ui/ComparisonSlider/ComparisonSlider.svelte create mode 100644 src/shared/ui/ComparisonSlider/components/Labels.svelte create mode 100644 src/shared/ui/ComparisonSlider/components/SliderLine.svelte diff --git a/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts new file mode 100644 index 0000000..6c2f458 --- /dev/null +++ b/src/shared/lib/helpers/createCharacterComparison/createCharacterComparison.svelte.ts @@ -0,0 +1,170 @@ +/** + * 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 }, +) { + let lines = $state([]); + 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 + */ + function measureText( + ctx: CanvasRenderingContext2D, + text: string, + fontFamily: string, + fontSize: number, + ): number { + ctx.font = `bold ${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 fontSize = getFontSize(); + const words = text().split(' '); + const newLines: LineData[] = []; + let currentLineWords: string[] = []; + + function pushLine(words: string[]) { + if (words.length === 0) return; + const lineText = words.join(' '); + // Measure width to ensure we know exactly how wide this line renders + const widthA = measureText(ctx!, lineText, fontA().name, fontSize); + const widthB = measureText(ctx!, lineText, fontB().name, fontSize); + 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, fontSize); + const widthB = measureText(ctx, testLine, fontB().name, fontSize); + 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, + }; +} diff --git a/src/shared/lib/helpers/index.ts b/src/shared/lib/helpers/index.ts index 62db226..61b065f 100644 --- a/src/shared/lib/helpers/index.ts +++ b/src/shared/lib/helpers/index.ts @@ -26,3 +26,7 @@ export { type Entity, type EntityStore, } from './createEntityStore/createEntityStore.svelte'; + +export { + createCharacterComparison, +} from './createCharacterComparison/createCharacterComparison.svelte'; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index fea0978..9734dd5 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,6 +1,7 @@ export { type ControlDataModel, type ControlModel, + createCharacterComparison, createDebouncedState, createEntityStore, createFilter, diff --git a/src/shared/ui/ComparisonSlider/ComparisonSlider.svelte b/src/shared/ui/ComparisonSlider/ComparisonSlider.svelte new file mode 100644 index 0000000..00d493b --- /dev/null +++ b/src/shared/ui/ComparisonSlider/ComparisonSlider.svelte @@ -0,0 +1,182 @@ + + + + + + +
+ +
+
+ + +
+ {#each charComparison.lines as line, lineIndex} +
+ {#each line.text.split('') as char, charIndex} + {@const { proximity, isPast } = charComparison.getCharState(lineIndex, charIndex, line, sliderPos)} + + 0 + ? 'transform, font-family, color' + : 'auto'} + > + {char === ' ' ? '\u00A0' : char} + + {/each} +
+ {/each} +
+ + + + +
+ + diff --git a/src/shared/ui/ComparisonSlider/components/Labels.svelte b/src/shared/ui/ComparisonSlider/components/Labels.svelte new file mode 100644 index 0000000..0c01646 --- /dev/null +++ b/src/shared/ui/ComparisonSlider/components/Labels.svelte @@ -0,0 +1,37 @@ + + + +
+ +
+ Baseline + + {fontB.name} + +
+ + +
90 ? 0 : 1} + > + Comparison + + {fontA.name} + +
+
diff --git a/src/shared/ui/ComparisonSlider/components/SliderLine.svelte b/src/shared/ui/ComparisonSlider/components/SliderLine.svelte new file mode 100644 index 0000000..749ab3d --- /dev/null +++ b/src/shared/ui/ComparisonSlider/components/SliderLine.svelte @@ -0,0 +1,26 @@ + + + +
+ +
+
+ + +
+
+
+ + +
+
+
+
diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 307dcc6..bb4bf5b 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -6,6 +6,7 @@ import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte'; import ComboControl from './ComboControl/ComboControl.svelte'; +import ComparisonSlider from './ComparisonSlider/ComparisonSlider.svelte'; import ContentEditable from './ContentEditable/ContentEditable.svelte'; import SearchBar from './SearchBar/SearchBar.svelte'; import VirtualList from './VirtualList/VirtualList.svelte'; @@ -13,6 +14,7 @@ import VirtualList from './VirtualList/VirtualList.svelte'; export { CheckboxFilter, ComboControl, + ComparisonSlider, ContentEditable, SearchBar, VirtualList,