236 lines
7.9 KiB
Svelte
236 lines
7.9 KiB
Svelte
<!--
|
|
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">
|
|
import { displayedFontsStore } from '$features/DisplayFont';
|
|
import {
|
|
createCharacterComparison,
|
|
createTypographyControl,
|
|
} from '$shared/lib';
|
|
import type { LineData } from '$shared/lib';
|
|
import { cubicOut } from 'svelte/easing';
|
|
import { Spring } from 'svelte/motion';
|
|
import { fly } from 'svelte/transition';
|
|
import CharacterSlot from './components/CharacterSlot.svelte';
|
|
import ControlsWrapper from './components/ControlsWrapper.svelte';
|
|
import Labels from './components/Labels.svelte';
|
|
import SliderLine from './components/SliderLine.svelte';
|
|
|
|
// Displayed text
|
|
let text = $state('The quick brown fox jumps over the lazy dog...');
|
|
// Pair of fonts to compare
|
|
const fontA = $derived(displayedFontsStore.fontA);
|
|
const fontB = $derived(displayedFontsStore.fontB);
|
|
|
|
let container: HTMLElement | undefined = $state();
|
|
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
|
|
let measureCanvas: HTMLCanvasElement | undefined = $state();
|
|
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.
|
|
* Manages line breaking and character state based on fonts and container dimensions.
|
|
*/
|
|
const charComparison = createCharacterComparison(
|
|
() => text,
|
|
() => fontA,
|
|
() => fontB,
|
|
() => weightControl.value,
|
|
() => sizeControl.value,
|
|
);
|
|
|
|
/** 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) {
|
|
if (e.target === controlsWrapperElement || controlsWrapperElement?.contains(e.target as Node)) {
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
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(() => {
|
|
// 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
|
|
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>
|
|
|
|
{#snippet renderLine(line: LineData, lineIndex: number)}
|
|
<div
|
|
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
|
style:height={`${heightControl.value}em`}
|
|
style:line-height={`${heightControl.value}em`}
|
|
>
|
|
{#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
|
|
-->
|
|
{#if fontA && fontB}
|
|
<CharacterSlot
|
|
{char}
|
|
{proximity}
|
|
{isPast}
|
|
weight={weightControl.value}
|
|
size={sizeControl.value}
|
|
fontAName={fontA.name}
|
|
fontBName={fontB.name}
|
|
/>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#if fontA && fontB}
|
|
<!-- Hidden canvas used for text measurement by the helper -->
|
|
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
|
|
|
<div class="relative">
|
|
<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-indigo-50 rounded-[2.5rem] border border-slate-100 shadow-2xl
|
|
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;'}
|
|
in:fly={{ y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 }}
|
|
>
|
|
<!-- 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={`${heightControl.value}em`}
|
|
style:display="flex"
|
|
style:align-items="center"
|
|
style:justify-content="center"
|
|
>
|
|
{@render renderLine(line, lineIndex)}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<SliderLine {sliderPos} {isDragging} />
|
|
</div>
|
|
|
|
<Labels fontA={fontA} fontB={fontB} {sliderPos} />
|
|
<!-- 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>
|
|
{/if}
|