feat(ComparisonView): add redesigned font comparison widget
This commit is contained in:
236
src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte
Normal file
236
src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte
Normal file
@@ -0,0 +1,236 @@
|
||||
<!--
|
||||
Component: ComparisonSlider
|
||||
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 {
|
||||
type CharacterComparison,
|
||||
type ResponsiveManager,
|
||||
createCharacterComparison,
|
||||
debounce,
|
||||
} from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Loader } from '$shared/ui';
|
||||
import { getContext } from 'svelte';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { comparisonStore } from '../../model';
|
||||
import Character from '../Character/Character.svelte';
|
||||
import Line from '../Line/Line.svelte';
|
||||
import Thumb from '../Thumb/Thumb.svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Sidebar open state
|
||||
* @default false
|
||||
*/
|
||||
isSidebarOpen?: boolean;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { isSidebarOpen = false, class: className }: Props = $props();
|
||||
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
|
||||
let container = $state<HTMLElement>();
|
||||
let measureCanvas = $state<HTMLCanvasElement>();
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
const isMobile = $derived(responsive?.isMobile ?? false);
|
||||
|
||||
let isDragging = $state(false);
|
||||
|
||||
const charComparison: CharacterComparison = createCharacterComparison(
|
||||
() => comparisonStore.text,
|
||||
() => fontA,
|
||||
() => fontB,
|
||||
() => typography.weight,
|
||||
() => typography.renderedSize,
|
||||
);
|
||||
|
||||
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
||||
|
||||
const sliderSpring = new Spring(50, {
|
||||
stiffness: 0.2,
|
||||
damping: 0.7,
|
||||
});
|
||||
const sliderPos = $derived(sliderSpring.current);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const storeSliderPosition = debounce((value: number) => {
|
||||
comparisonStore.sliderPosition = value;
|
||||
}, 100);
|
||||
|
||||
$effect(() => {
|
||||
storeSliderPosition(sliderPos);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!responsive) return;
|
||||
switch (true) {
|
||||
case responsive.isMobile:
|
||||
typography.multiplier = 0.5;
|
||||
break;
|
||||
case responsive.isTablet:
|
||||
typography.multiplier = 0.75;
|
||||
break;
|
||||
case responsive.isDesktop:
|
||||
typography.multiplier = 1;
|
||||
break;
|
||||
default:
|
||||
typography.multiplier = 1;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
const stop = () => (isDragging = false);
|
||||
window.addEventListener('pointerup', stop);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', stop);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const _text = comparisonStore.text;
|
||||
const _weight = typography.weight;
|
||||
const _size = typography.renderedSize;
|
||||
const _height = typography.height;
|
||||
if (container && measureCanvas && fontA && fontB) {
|
||||
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);
|
||||
});
|
||||
|
||||
// Dynamic backgroundSize based on isMobile — can't express this in Tailwind.
|
||||
// Color is set to currentColor so it respects dark mode via text color.
|
||||
const gridStyle = $derived(
|
||||
`background-image: linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px); `
|
||||
+ `background-size: ${isMobile ? '10px 10px' : '20px 20px'};`,
|
||||
);
|
||||
|
||||
// Replaces motion.div animate={{ scale: isSidebarOpen && !isMobile ? 0.94 :1 }}
|
||||
const scaleClass = $derived(
|
||||
isSidebarOpen && !isMobile
|
||||
? 'scale-[0.94]'
|
||||
: 'scale-100',
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- Hidden measurement canvas -->
|
||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
||||
|
||||
<!--
|
||||
Outer flex container — fills parent.
|
||||
The paper div inside scales down when the sidebar opens on desktop.
|
||||
-->
|
||||
<div class={cn('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}>
|
||||
<!--
|
||||
Paper surface.
|
||||
Replaces the old glassmorphism card with a clean white/dark sheet.
|
||||
Scale transition replaces motion.div spring — CSS transition-transform
|
||||
is smooth enough here; a JS spring would add ~4kb for minimal gain.
|
||||
-->
|
||||
<div
|
||||
class={cn(
|
||||
'w-full h-full flex flex-col items-center justify-center relative',
|
||||
'bg-white dark:bg-[#1e1e1e]',
|
||||
'shadow-2xl shadow-black/5 dark:shadow-black/20',
|
||||
'transition-transform duration-300 ease-out',
|
||||
scaleClass,
|
||||
)}
|
||||
>
|
||||
<!-- Subtle grid overlay — pointer-events-none, very low opacity -->
|
||||
<div
|
||||
class="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05] text-black dark:text-white"
|
||||
style={gridStyle}
|
||||
aria-hidden="true"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Slider interaction area -->
|
||||
<div class="w-full h-full flex items-center justify-center p-4 md:p-8 overflow-hidden">
|
||||
{#if isLoading}
|
||||
<div out:fade={{ duration: 300 }}>
|
||||
<Loader size={24} />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
bind:this={container}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={Math.round(sliderPos)}
|
||||
aria-label="Font comparison slider"
|
||||
onpointerdown={startDragging}
|
||||
class="
|
||||
relative w-full max-w-6xl h-full
|
||||
flex flex-col justify-center
|
||||
select-none touch-none cursor-ew-resize
|
||||
py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24
|
||||
"
|
||||
in:fade={{ duration: 300, delay: 300 }}
|
||||
out:fade={{ duration: 300 }}
|
||||
>
|
||||
<!-- Character lines -->
|
||||
<div
|
||||
class="
|
||||
relative flex flex-col items-center gap-3 sm:gap-4
|
||||
z-10 pointer-events-none text-center
|
||||
my-auto
|
||||
"
|
||||
>
|
||||
{#each charComparison.lines as line, lineIndex}
|
||||
<Line bind:element={lineElements[lineIndex]} text={line.text}>
|
||||
{#snippet character({ char, index })}
|
||||
{@const { proximity, isPast } = charComparison.getCharState(index, sliderPos, lineElements[lineIndex], container)}
|
||||
<Character {char} {proximity} {isPast} />
|
||||
{/snippet}
|
||||
</Line>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Thumb {sliderPos} {isDragging} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user