Files
frontend-svelte/src/widgets/ComparisonSlider/ui/ComparisonSlider/ComparisonSlider.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}