feat(ComparisonSlider): Massively improve the slider and move it to the widgets layer

This commit is contained in:
Ilia Mashkov
2026-01-21 21:52:55 +03:00
parent 2ee66316f7
commit 91300bdc25
6 changed files with 382 additions and 56 deletions

View File

@@ -10,35 +10,64 @@
- Performance optimized using offscreen canvas for measurements and transform-based animations. - Performance optimized using offscreen canvas for measurements and transform-based animations.
--> -->
<script lang="ts" generics="T extends { name: string; id: string }"> <script lang="ts" generics="T extends { name: string; id: string }">
import { createCharacterComparison } from '$shared/lib'; import {
createCharacterComparison,
createTypographyControl,
} from '$shared/lib';
import type { LineData } from '$shared/lib'; import type { LineData } from '$shared/lib';
import { Spring } from 'svelte/motion'; import { Spring } from 'svelte/motion';
import CharacterSlot from './components/CharacterSlot.svelte'; import CharacterSlot from './components/CharacterSlot.svelte';
import ControlsWrapper from './components/ControlsWrapper.svelte';
import Labels from './components/Labels.svelte'; import Labels from './components/Labels.svelte';
import SliderLine from './components/SliderLine.svelte'; import SliderLine from './components/SliderLine.svelte';
interface Props<T extends { name: string; id: string }> { interface Props<T extends { name: string; id: string }> {
/** First font definition ({name, id}) */ /**
* First font definition ({name, id})
*/
fontA: T; fontA: T;
/** Second font definition ({name, id}) */ /**
* Second font definition ({name, id})
*/
fontB: T; fontB: T;
/** Text to display and compare */ /**
* Text to display and compare
*/
text?: string; text?: string;
weight?: number;
} }
let { let {
fontA, fontA,
fontB, fontB,
text = 'The quick brown fox jumps over the lazy dog', text = $bindable('The quick brown fox jumps over the lazy dog'),
weight = 400,
}: Props<T> = $props(); }: Props<T> = $props();
let container: HTMLElement | undefined = $state(); let container: HTMLElement | undefined = $state();
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
let measureCanvas: HTMLCanvasElement | undefined = $state(); let measureCanvas: HTMLCanvasElement | undefined = $state();
let isDragging = $state(false); let isDragging = $state(false);
const weightControl = createTypographyControl({
min: 100,
max: 700,
step: 100,
value: 400,
});
const heightControl = createTypographyControl({
min: 1,
max: 2,
step: 0.05,
value: 1.2,
});
const sizeControl = createTypographyControl({
min: 1,
max: 112,
step: 1,
value: 64,
});
/** /**
* Encapsulated helper for text splitting, measuring, and character proximity calculations. * Encapsulated helper for text splitting, measuring, and character proximity calculations.
* Manages line breaking and character state based on fonts and container dimensions. * Manages line breaking and character state based on fonts and container dimensions.
@@ -47,7 +76,8 @@ const charComparison = createCharacterComparison(
() => text, () => text,
() => fontA, () => fontA,
() => fontB, () => fontB,
() => weight, () => weightControl.value,
() => sizeControl.value,
); );
/** Physics-based spring for smooth handle movement */ /** Physics-based spring for smooth handle movement */
@@ -67,6 +97,11 @@ function handleMove(e: PointerEvent) {
} }
function startDragging(e: PointerEvent) { function startDragging(e: PointerEvent) {
if (e.target === controlsWrapperElement || controlsWrapperElement?.contains(e.target as Node)) {
console.log('Pointer down on controls wrapper');
e.stopPropagation();
return;
}
e.preventDefault(); e.preventDefault();
isDragging = true; isDragging = true;
handleMove(e); handleMove(e);
@@ -86,7 +121,13 @@ $effect(() => {
// Re-run line breaking when container resizes or dependencies change // Re-run line breaking when container resizes or dependencies change
$effect(() => { $effect(() => {
if (container && measureCanvas && weight && fontA && fontB) { // React on text and typography settings changes
const _text = text;
const _weight = weightControl.value;
const _size = sizeControl.value;
const _height = heightControl.value;
if (container && measureCanvas && fontA && fontB) {
// Using rAF to ensure DOM is ready/stabilized // Using rAF to ensure DOM is ready/stabilized
requestAnimationFrame(() => { requestAnimationFrame(() => {
charComparison.breakIntoLines(container, measureCanvas); charComparison.breakIntoLines(container, measureCanvas);
@@ -97,7 +138,9 @@ $effect(() => {
$effect(() => { $effect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const handleResize = () => { const handleResize = () => {
if (container && measureCanvas && weight) { if (
container && measureCanvas
) {
charComparison.breakIntoLines(container, measureCanvas); charComparison.breakIntoLines(container, measureCanvas);
} }
}; };
@@ -109,7 +152,8 @@ $effect(() => {
{#snippet renderLine(line: LineData, lineIndex: number)} {#snippet renderLine(line: LineData, lineIndex: number)}
<div <div
class="relative flex w-full justify-center items-center whitespace-nowrap" class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height="1.2em" style:height={`${heightControl.value}em`}
style:line-height={`${heightControl.value}em`}
> >
{#each line.text.split('') as char, charIndex} {#each line.text.split('') as char, charIndex}
{@const { proximity, isPast } = charComparison.getCharState(lineIndex, charIndex, line, sliderPos)} {@const { proximity, isPast } = charComparison.getCharState(lineIndex, charIndex, line, sliderPos)}
@@ -122,7 +166,8 @@ $effect(() => {
{char} {char}
{proximity} {proximity}
{isPast} {isPast}
{weight} weight={weightControl.value}
size={sizeControl.value}
fontAName={fontA.name} fontAName={fontA.name}
fontBName={fontB.name} fontBName={fontB.name}
/> />
@@ -133,52 +178,66 @@ $effect(() => {
<!-- Hidden canvas used for text measurement by the helper --> <!-- Hidden canvas used for text measurement by the helper -->
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas> <canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
<div <div class="relative">
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 <div
bind:this={container}
role="slider"
tabindex="0"
aria-valuenow={Math.round(sliderPos)}
aria-label="Font comparison slider"
onpointerdown={startDragging}
class=" class="
absolute inset-0 bg-linear-to-br group relative w-full py-16 px-6 sm:py-24 sm:px-12 overflow-hidden
from-slate-50/50 via-white to-slate-100/50 bg-indigo-50 rounded-[2.5rem] border border-slate-100 shadow-2xl
opacity-50 pointer-events-none select-none touch-none cursor-ew-resize min-h-100 flex flex-col justify-center
" "
class:box-shadow={'-20px 20px 60px #bebebe, 20px -20px 60px #ffffff;'}
> >
</div> <!-- 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 --> <!-- Text Rendering Container -->
<div <div
class=" class="
relative flex flex-col items-center gap-4 relative flex flex-col items-center gap-4
text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.15] text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[1.15]
z-10 pointer-events-none text-center z-10 pointer-events-none text-center
" "
style:perspective="1000px" style:perspective="1000px"
> >
{#each charComparison.lines as line, lineIndex} {#each charComparison.lines as line, lineIndex}
<div <div
class="relative w-full whitespace-nowrap" class="relative w-full whitespace-nowrap"
style:height="1.2em" style:height={`${heightControl.value}em`}
style:display="flex" style:display="flex"
style:align-items="center" style:align-items="center"
style:justify-content="center" style:justify-content="center"
> >
{@render renderLine(line, lineIndex)} {@render renderLine(line, lineIndex)}
</div> </div>
{/each} {/each}
</div> </div>
<!-- Visual Components --> <!-- Visual Components -->
<SliderLine {sliderPos} isDragging={isDragging} /> <SliderLine {sliderPos} {isDragging} />
<Labels {fontA} {fontB} {sliderPos} /> <Labels {fontA} {fontB} {sliderPos} />
</div>
<!-- Since there're slider controls inside we put them outside the main one -->
<ControlsWrapper
bind:wrapper={controlsWrapperElement}
{sliderPos}
{isDragging}
bind:text={text}
containerWidth={container?.clientWidth}
weightControl={weightControl}
sizeControl={sizeControl}
heightControl={heightControl}
/>
</div> </div>

View File

@@ -6,15 +6,37 @@
import { cn } from '$shared/shadcn/utils/shadcn-utils'; import { cn } from '$shared/shadcn/utils/shadcn-utils';
interface Props { interface Props {
/**
* Displayed character
*/
char: string; char: string;
/**
* Proximity of the character to the center of the slider
*/
proximity: number; proximity: number;
/**
* Flag indicating whether character needed to be changed
*/
isPast: boolean; isPast: boolean;
/**
* Font weight of the character
*/
weight: number; weight: number;
/**
* Font size of the character
*/
size: number;
/**
* Name of the font for the character after the change
*/
fontAName: string; fontAName: string;
/**
* Name of the font for the character before the change
*/
fontBName: string; fontBName: string;
} }
let { char, proximity, isPast, weight, fontAName, fontBName }: Props = $props(); let { char, proximity, isPast, weight, size, fontAName, fontBName }: Props = $props();
</script> </script>
<span <span
@@ -24,6 +46,7 @@ let { char, proximity, isPast, weight, fontAName, fontBName }: Props = $props();
)} )}
style:font-family={isPast ? fontBName : fontAName} style:font-family={isPast ? fontBName : fontAName}
style:font-weight={weight} style:font-weight={weight}
style:font-size={`${size}px`}
style:transform=" style:transform="
scale({1 + proximity * 0.2}) scale({1 + proximity * 0.2})
translateY({-proximity * 12}px) translateY({-proximity * 12}px)

View File

@@ -0,0 +1,241 @@
<script lang="ts">
import type { TypographyControl } from '$shared/lib';
import { Input } from '$shared/shadcn/ui/input';
import { cn } from '$shared/shadcn/utils/shadcn-utils';
import { ComboControlV2 } from '$shared/ui';
import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
import { Spring } from 'svelte/motion';
import { slide } from 'svelte/transition';
interface Props {
wrapper?: HTMLDivElement | null;
sliderPos: number;
isDragging: boolean;
text: string;
containerWidth: number;
weightControl: TypographyControl;
sizeControl: TypographyControl;
heightControl: TypographyControl;
}
let {
sliderPos,
isDragging,
wrapper = $bindable(null),
text = $bindable(),
containerWidth = 0,
weightControl,
sizeControl,
heightControl,
}: Props = $props();
let panelWidth = $state(0);
const margin = 24;
let side = $state<'left' | 'right'>('left');
// Unified active state for the entire wrapper
let isActive = $state(false);
function handleWrapperClick() {
if (!isDragging) {
isActive = true;
}
}
function handleClickOutside(e: MouseEvent) {
if (wrapper && !wrapper.contains(e.target as Node)) {
isActive = false;
}
}
function handleInputFocus() {
isActive = true;
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleWrapperClick();
}
}
// Movement Logic
$effect(() => {
if (containerWidth === 0 || panelWidth === 0) return;
const sliderX = (sliderPos / 100) * containerWidth;
const buffer = 40;
const leftTrigger = margin + panelWidth + buffer;
const rightTrigger = containerWidth - (margin + panelWidth + buffer);
if (side === 'left' && sliderX < leftTrigger) {
side = 'right';
} else if (side === 'right' && sliderX > rightTrigger) {
side = 'left';
}
});
// The "Dodge"
const xSpring = new Spring(0, {
stiffness: 0.14, // Lower is slower
damping: 0.5, // Settle
});
// The "Focus"
const ySpring = new Spring(0, {
stiffness: 0.32,
damping: 0.65,
});
// The "Rise"
const scaleSpring = new Spring(1, {
stiffness: 0.32,
damping: 0.65,
});
// The "Lean"
const rotateSpring = new Spring(0, {
stiffness: 0.12,
damping: 0.55,
});
$effect(() => {
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0;
if (containerWidth > 0 && panelWidth > 0 && !isActive) {
// On side change set the position and the rotation
xSpring.target = targetX;
rotateSpring.target = side === 'right' ? 3.5 : -3.5;
setTimeout(() => {
rotateSpring.target = 0;
}, 600);
}
});
// Elevation and scale on focus and mouse over
$effect(() => {
if (isActive && !isDragging) {
// Lift up
ySpring.target = 8;
// Slightly bigger
scaleSpring.target = 1.1;
rotateSpring.target = side === 'right' ? -1.1 : 1.1;
setTimeout(() => {
rotateSpring.target = 0;
scaleSpring.target = 1.05;
}, 300);
} else {
ySpring.target = 0;
scaleSpring.target = 1;
rotateSpring.target = 0;
}
});
$effect(() => {
if (isDragging) {
isActive = false;
}
});
// Click outside handler
$effect(() => {
if (typeof window === 'undefined') return;
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
});
</script>
<div
onclick={handleWrapperClick}
bind:this={wrapper}
bind:clientWidth={panelWidth}
class={cn(
'absolute top-6 left-6 z-50 will-change-transform transition-opacity duration-300 flex items-top gap-1.5',
panelWidth === 0 ? 'opacity-0' : 'opacity-100',
)}
style:pointer-events={isDragging ? 'none' : 'auto'}
style:transform="
translate({xSpring.current}px, {ySpring.current}px)
scale({scaleSpring.current})
rotateZ({rotateSpring.current}deg)
"
role="button"
tabindex={0}
onkeydown={handleKeyDown}
aria-label="Font controls"
>
<div
class={cn(
'animate-nudge relative transition-all',
side === 'left' ? 'order-2' : 'order-0',
isActive ? 'opacity-0' : 'opacity-100',
isDragging && 'opacity-40 grayscale-[0.2]',
)}
>
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
</div>
<div
class={cn(
'relative p-2 rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5',
isActive
? 'bg-white/95 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
isDragging && 'opacity-40 grayscale-[0.2]',
)}
style:backdrop-filter="blur(24px)"
>
<div class="relative px-2 py-1">
<Input
bind:value={text}
disabled={isDragging}
onfocusin={handleInputFocus}
class={cn(
isActive
? 'h-8 text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50 text-slate-900'
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm font-medium focus-visible:ring-0 text-slate-900/50',
' placeholder:text-slate-400 selection:bg-indigo-100 flex-1 transition-all duration-350 w-56',
)}
placeholder="Edit label..."
/>
</div>
{#if isActive}
<div
in:slide={{ duration: 250, delay: 50 }}
out:slide={{ duration: 250 }}
class="flex justify-between items-center-safe"
>
<ComboControlV2 control={weightControl} />
<ComboControlV2 control={sizeControl} />
<ComboControlV2 control={heightControl} />
</div>
{/if}
</div>
</div>
<style>
@keyframes nudge {
0%, 100% {
transform: translateY(0) scale(1) rotate(0deg);
}
2% {
transform: translateY(-2px) scale(1.1) rotate(-1deg);
}
4% {
transform: translateY(0) scale(1) rotate(1deg);
}
6% {
transform: translateY(-2px) scale(1.1) rotate(0deg);
}
8% {
transform: translateY(0) scale(1) rotate(0deg);
}
}
.animate-nudge {
animation: nudge 10s ease-in-out infinite;
}
</style>

View File

@@ -0,0 +1,3 @@
import ComparisonSlider from './ComparisonSlider/ComparisonSlider.svelte';
export { ComparisonSlider };