feature/comparison-slider #19

Merged
ilia merged 129 commits from feature/comparison-slider into main 2026-02-02 09:23:46 +00:00
7 changed files with 422 additions and 0 deletions
Showing only changes of commit b5ad3249ae - Show all commits

View File

@@ -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<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
*/
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,
};
}

View File

@@ -26,3 +26,7 @@ export {
type Entity, type Entity,
type EntityStore, type EntityStore,
} from './createEntityStore/createEntityStore.svelte'; } from './createEntityStore/createEntityStore.svelte';
export {
createCharacterComparison,
} from './createCharacterComparison/createCharacterComparison.svelte';

View File

@@ -1,6 +1,7 @@
export { export {
type ControlDataModel, type ControlDataModel,
type ControlModel, type ControlModel,
createCharacterComparison,
createDebouncedState, createDebouncedState,
createEntityStore, createEntityStore,
createFilter, createFilter,

View File

@@ -0,0 +1,182 @@
<!--
Component: ComparisonSlider (Ultimate Comparison Slider)
A multiline text comparison slider that morphs between two fonts.
Features:
- Multiline support with precise line breaking matching container width.
- Character-level morphing: Font changes exactly when the slider passes the character's global position.
- Responsive layout with Tailwind breakpoints for font sizing.
- Performance optimized using offscreen canvas for measurements and transform-based animations.
-->
<script lang="ts" generics="T extends { name: string; id: string }">
import { createCharacterComparison } from '$shared/lib';
import { Spring } from 'svelte/motion';
import Labels from './components/Labels.svelte';
import SliderLine from './components/SliderLine.svelte';
interface Props<T extends { name: string; id: string }> {
/** First font definition ({name, id}) */
fontA: T;
/** Second font definition ({name, id}) */
fontB: T;
/** Text to display and compare */
text?: string;
}
let {
fontA,
fontB,
text = 'The quick brown fox jumps over the lazy dog',
}: Props<T> = $props();
let container: HTMLElement | undefined = $state();
let measureCanvas: HTMLCanvasElement | undefined = $state();
let isDragging = $state(false);
/**
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
* Manages line breaking and character state based on fonts and container dimensions.
*/
const charComparison = createCharacterComparison(
() => text,
() => fontA,
() => fontB,
);
/** Physics-based spring for smooth handle movement */
const sliderSpring = new Spring(50, {
stiffness: 0.2, // Balanced for responsiveness
damping: 0.7, // No bounce, just smooth stop
});
const sliderPos = $derived(sliderSpring.current);
/** Updates spring target based on pointer position */
function handleMove(e: PointerEvent) {
if (!isDragging || !container) return;
const rect = container.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const percentage = (x / rect.width) * 100;
sliderSpring.target = percentage;
}
function startDragging(e: PointerEvent) {
e.preventDefault();
isDragging = true;
handleMove(e);
}
$effect(() => {
if (isDragging) {
window.addEventListener('pointermove', handleMove);
const stop = () => (isDragging = false);
window.addEventListener('pointerup', stop);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', stop);
};
}
});
// Re-run line breaking when container resizes or dependencies change
$effect(() => {
if (container && measureCanvas) {
// Using rAF to ensure DOM is ready/stabilized
requestAnimationFrame(() => {
charComparison.breakIntoLines(container, measureCanvas);
});
}
});
$effect(() => {
if (typeof window === 'undefined') return;
const handleResize = () => {
if (container && measureCanvas) {
charComparison.breakIntoLines(container, measureCanvas);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
</script>
<!-- Hidden canvas used for text measurement by the helper -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<div
bind:this={container}
role="slider"
tabindex="0"
aria-valuenow={Math.round(sliderPos)}
aria-label="Font comparison slider"
onpointerdown={startDragging}
class="group relative w-full py-16 px-6 sm:py-24 sm:px-12 overflow-hidden bg-white rounded-[2.5rem] border border-slate-100 shadow-2xl select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center"
>
<!-- Background Gradient Accent -->
<div
class="
absolute inset-0 bg-linear-to-br
from-slate-50/50 via-white to-slate-100/50
opacity-50 pointer-events-none
"
>
</div>
<!-- Text Rendering Container -->
<div
class="relative flex flex-col items-center gap-4 text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.15] z-10 pointer-events-none text-center"
style:perspective="1000px"
>
{#each charComparison.lines as line, lineIndex}
<div class="relative w-full whitespace-nowrap">
{#each line.text.split('') as char, charIndex}
{@const { proximity, isPast } = charComparison.getCharState(lineIndex, charIndex, line, sliderPos)}
<!--
Single Character Span
- Font Family switches based on `isPast`
- Transitions/Transforms provide the "morph" feel
-->
<span
class="inline-block transition-all duration-300 ease-out will-change-transform"
style:font-family={isPast ? fontB.name : fontA.name}
style:color={isPast
? 'rgb(79, 70, 229)'
: 'rgb(15, 23, 42)'}
style:transform="
scale({1 + proximity * 0.2}) translateY({-proximity *
12}px) rotateY({proximity *
25 *
(isPast ? -1 : 1)}deg)
"
style:will-change={proximity > 0
? 'transform, font-family, color'
: 'auto'}
>
{char === ' ' ? '\u00A0' : char}
</span>
{/each}
</div>
{/each}
</div>
<!-- Visual Components -->
<SliderLine {sliderPos} />
<Labels {fontA} {fontB} {sliderPos} />
</div>
<style>
span {
/*
Optimize for performance and smooth transitions.
step-end logic is effectively handled by binary font switching in JS.
*/
transition:
font-family 0.15s ease-out,
color 0.2s ease-out,
transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
transform-style: preserve-3d;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
fontA: { name: string; id: string };
fontB: { name: string; id: string };
sliderPos: number;
}
let { fontA, fontB, sliderPos }: Props = $props();
</script>
<!-- Bottom Labels -->
<div class="absolute bottom-6 inset-x-8 sm:inset-x-12 flex justify-between items-center pointer-events-none z-20">
<!-- Left Label (Font A) -->
<div
class="flex flex-col gap-1 transition-opacity duration-300"
style:opacity={sliderPos < 10 ? 0 : 1}
>
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-indigo-400"
>Baseline</span>
<span class="text-xs sm:text-sm font-bold text-indigo-600">
{fontB.name}
</span>
</div>
<!-- Right Label (Font B) -->
<div
class="flex flex-col items-end text-right gap-1 transition-opacity duration-300"
style:opacity={sliderPos > 90 ? 0 : 1}
>
<span class="text-[0.5rem] font-mono uppercase tracking-widest text-slate-400"
>Comparison</span>
<span class="text-xs sm:text-sm font-bold text-slate-900">
{fontA.name}
</span>
</div>
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
interface Props {
sliderPos: number;
}
let { sliderPos }: Props = $props();
</script>
<!-- Vertical Divider & Knobs -->
<div
class="absolute top-0 bottom-0 z-30 pointer-events-none"
style:left="{sliderPos}%"
>
<!-- Vertical Line -->
<div class="absolute inset-y-0 -left-px w-0.5 bg-indigo-500 shadow-[0_0_10px_rgba(99,102,241,0.5)]">
</div>
<!-- Top Knob -->
<div class="absolute top-6 left-0 -translate-x-1/2">
<div class="w-2.5 h-2.5 bg-indigo-500 rounded-full shadow ring-2 ring-white"></div>
</div>
<!-- Bottom Knob -->
<div class="absolute bottom-6 left-0 -translate-x-1/2">
<div class="w-2.5 h-2.5 bg-indigo-500 rounded-full shadow ring-2 ring-white"></div>
</div>
</div>

View File

@@ -6,6 +6,7 @@
import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte'; import CheckboxFilter from './CheckboxFilter/CheckboxFilter.svelte';
import ComboControl from './ComboControl/ComboControl.svelte'; import ComboControl from './ComboControl/ComboControl.svelte';
import ComparisonSlider from './ComparisonSlider/ComparisonSlider.svelte';
import ContentEditable from './ContentEditable/ContentEditable.svelte'; import ContentEditable from './ContentEditable/ContentEditable.svelte';
import SearchBar from './SearchBar/SearchBar.svelte'; import SearchBar from './SearchBar/SearchBar.svelte';
import VirtualList from './VirtualList/VirtualList.svelte'; import VirtualList from './VirtualList/VirtualList.svelte';
@@ -13,6 +14,7 @@ import VirtualList from './VirtualList/VirtualList.svelte';
export { export {
CheckboxFilter, CheckboxFilter,
ComboControl, ComboControl,
ComparisonSlider,
ContentEditable, ContentEditable,
SearchBar, SearchBar,
VirtualList, VirtualList,