feat(ComparisonSlider): Massively improve the slider and move it to the widgets layer
This commit is contained in:
@@ -1,184 +0,0 @@
|
||||
<!--
|
||||
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 type { LineData } from '$shared/lib';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import CharacterSlot from './components/CharacterSlot.svelte';
|
||||
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;
|
||||
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
fontA,
|
||||
fontB,
|
||||
text = 'The quick brown fox jumps over the lazy dog',
|
||||
weight = 400,
|
||||
}: 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,
|
||||
() => weight,
|
||||
);
|
||||
|
||||
/** 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 && weight && fontA && fontB) {
|
||||
// Using rAF to ensure DOM is ready/stabilized
|
||||
requestAnimationFrame(() => {
|
||||
charComparison.breakIntoLines(container, measureCanvas);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handleResize = () => {
|
||||
if (container && measureCanvas && weight) {
|
||||
charComparison.breakIntoLines(container, measureCanvas);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet renderLine(line: LineData, lineIndex: number)}
|
||||
<div
|
||||
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
||||
style:height="1.2em"
|
||||
>
|
||||
{#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
|
||||
-->
|
||||
<CharacterSlot
|
||||
{char}
|
||||
{proximity}
|
||||
{isPast}
|
||||
{weight}
|
||||
fontAName={fontA.name}
|
||||
fontBName={fontB.name}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- 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"
|
||||
style:height="1.2em"
|
||||
style:display="flex"
|
||||
style:align-items="center"
|
||||
style:justify-content="center"
|
||||
>
|
||||
{@render renderLine(line, lineIndex)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Visual Components -->
|
||||
<SliderLine {sliderPos} isDragging={isDragging} />
|
||||
<Labels {fontA} {fontB} {sliderPos} />
|
||||
</div>
|
||||
@@ -1,54 +0,0 @@
|
||||
<!--
|
||||
Component: CharacterSlot
|
||||
Renders a character with particular styling based on proximity, isPast, weight, fontAName, and fontBName.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
|
||||
interface Props {
|
||||
char: string;
|
||||
proximity: number;
|
||||
isPast: boolean;
|
||||
weight: number;
|
||||
fontAName: string;
|
||||
fontBName: string;
|
||||
}
|
||||
|
||||
let { char, proximity, isPast, weight, fontAName, fontBName }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
'inline-block transition-all duration-300 ease-out will-change-transform',
|
||||
isPast ? 'text-indigo-500' : 'text-neutral-950',
|
||||
)}
|
||||
style:font-family={isPast ? fontBName : fontAName}
|
||||
style:font-weight={weight}
|
||||
style:transform="
|
||||
scale({1 + proximity * 0.2})
|
||||
translateY({-proximity * 12}px)
|
||||
rotateY({proximity * 25 * (isPast ? -1 : 1)}deg)
|
||||
"
|
||||
style:filter="brightness({1 + proximity * 0.2}) contrast({1 + proximity * 0.1})"
|
||||
style:text-shadow={proximity > 0.5 ? '0 0 15px rgba(99,102,241,0.3)' : 'none'}
|
||||
style:will-change={proximity > 0 ? 'transform, font-family, color' : 'auto'}
|
||||
>
|
||||
{char === ' ' ? '\u00A0' : char}
|
||||
</span>
|
||||
|
||||
<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>
|
||||
@@ -1,37 +0,0 @@
|
||||
<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>
|
||||
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
|
||||
interface Props {
|
||||
sliderPos: number;
|
||||
isDragging: boolean;
|
||||
}
|
||||
let { sliderPos, isDragging }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="absolute inset-y-0 pointer-events-none -translate-x-1/2 z-30"
|
||||
style:left="{sliderPos}%"
|
||||
>
|
||||
<!-- Subtle wave glow zone -->
|
||||
<div
|
||||
class={cn(
|
||||
'absolute inset-y-0 w-24 -left-12 bg-linear-to-r from-transparent via-indigo-500/8 to-transparent transition-all duration-300',
|
||||
isDragging ? 'via-indigo-500/12' : '',
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Vertical divider line -->
|
||||
<div
|
||||
class="absolute inset-y-0 w-0.5 bg-linear-to-b from-indigo-400/30 via-indigo-500 to-indigo-400/30 shadow-[0_0_12px_rgba(99,102,241,0.5)] transition-shadow duration-200"
|
||||
class:shadow-[0_0_20px_rgba(99,102,241,0.7)]={isDragging}
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Top knob -->
|
||||
<div
|
||||
class="absolute top-6 left-0 -translate-x-1/2 transition-transform duration-200"
|
||||
class:scale-125={isDragging}
|
||||
>
|
||||
<div class="w-3 h-3 bg-indigo-500 rounded-full shadow-lg ring-2 ring-white"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom knob -->
|
||||
<div
|
||||
class="absolute bottom-6 left-0 -translate-x-1/2 transition-transform duration-200"
|
||||
class:scale-125={isDragging}
|
||||
>
|
||||
<div class="w-3 h-3 bg-indigo-500 rounded-full shadow-lg ring-2 ring-white"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<style>
|
||||
|
||||
div {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
</style>
|
||||
-->
|
||||
Reference in New Issue
Block a user