Files
frontend-svelte/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte
T

281 lines
9.4 KiB
Svelte
Raw Normal View History

<!--
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 { TypographyMenu } from '$features/SetupFont';
import { typographySettingsStore } from '$features/SetupFont/model';
import {
type ResponsiveManager,
debounce,
} from '$shared/lib';
import {
CharacterComparisonEngine,
} from '$shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte';
import { Loader } from '$shared/ui';
import clsx from 'clsx';
import { getContext } from 'svelte';
import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition';
import { getPretextFontString } from '../../lib';
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(typographySettingsStore);
let container = $state<HTMLElement>();
const responsive = getContext<ResponsiveManager>('responsive');
const isMobile = $derived(responsive?.isMobile ?? false);
let isDragging = $state(false);
let isTypographyMenuOpen = $state(false);
let containerWidth = $state(0);
// New high-performance layout engine
const comparisonEngine = new CharacterComparisonEngine();
let layoutResult = $state<ReturnType<typeof comparisonEngine.layout>>({ lines: [], totalHeight: 0 });
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();
// Close typography menu popover
isTypographyMenuOpen = false;
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;
const _spacing = typography.spacing;
if (container && fontA && fontB) {
// PRETEXT API strings: "weight sizepx family"
const fontAStr = getPretextFontString(_weight, _size, fontA.name);
const fontBStr = getPretextFontString(_weight, _size, fontB.name);
// Use offsetWidth to avoid transform scaling issues
const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
const availableWidth = width - padding;
const lineHeight = _size * _height;
containerWidth = width;
layoutResult = comparisonEngine.layout(
_text,
fontAStr,
fontBStr,
availableWidth,
lineHeight,
_spacing,
_size,
);
}
});
$effect(() => {
if (typeof window === 'undefined') {
return;
}
const handleResize = () => {
if (container && fontA && fontB) {
const width = container.offsetWidth;
const padding = isMobile ? 48 : 96;
containerWidth = width;
layoutResult = comparisonEngine.layout(
comparisonStore.text,
getPretextFontString(typography.weight, typography.renderedSize, fontA.name),
getPretextFontString(typography.weight, typography.renderedSize, fontB.name),
width - padding,
typography.renderedSize * typography.height,
typography.spacing,
typography.renderedSize,
);
}
};
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>
<!--
Outer flex container — fills parent.
The paper div inside scales down when the sidebar opens on desktop.
-->
<div class={clsx('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}>
<!-- Paper surface -->
<div
class={clsx(
'w-full h-full flex flex-col items-center justify-center relative',
'bg-paper dark:bg-dark-card',
'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-swiss-black dark:text-swiss-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 outline-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 layoutResult.lines as line}
{@const lineStates = comparisonEngine.getLineCharStates(line, sliderPos, containerWidth)}
<Line chars={line.chars}>
{#snippet character({ char, index })}
<Character
{char}
proximity={lineStates[index]?.proximity ?? 0}
isPast={lineStates[index]?.isPast ?? false}
/>
{/snippet}
</Line>
{/each}
</div>
<Thumb {sliderPos} {isDragging} />
</div>
{/if}
</div>
</div>
<TypographyMenu
bind:open={isTypographyMenuOpen}
class={clsx(
'absolute z-50',
responsive.isMobileOrTablet
? 'bottom-4 right-4 -translate-1/2'
: 'bottom-5 left-1/2 right-[unset] -translate-x-1/2',
)}
/>
</div>