2026-03-02 22:18:05 +03:00
|
|
|
<!--
|
|
|
|
|
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">
|
2026-04-16 08:44:49 +03:00
|
|
|
import { TypographyMenu } from '$features/SetupFont';
|
|
|
|
|
import { typographySettingsStore } from '$features/SetupFont/model';
|
2026-03-02 22:18:05 +03:00
|
|
|
import {
|
|
|
|
|
type ResponsiveManager,
|
|
|
|
|
debounce,
|
|
|
|
|
} from '$shared/lib';
|
2026-04-23 09:48:32 +03:00
|
|
|
import { cn } from '$shared/lib';
|
2026-04-11 16:26:41 +03:00
|
|
|
import {
|
|
|
|
|
CharacterComparisonEngine,
|
|
|
|
|
} from '$shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte';
|
2026-03-02 22:18:05 +03:00
|
|
|
import { Loader } from '$shared/ui';
|
|
|
|
|
import { getContext } from 'svelte';
|
|
|
|
|
import { Spring } from 'svelte/motion';
|
|
|
|
|
import { fade } from 'svelte/transition';
|
2026-04-20 11:13:54 +03:00
|
|
|
import { getPretextFontString } from '../../lib';
|
2026-03-02 22:18:05 +03:00
|
|
|
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);
|
2026-04-16 08:44:49 +03:00
|
|
|
const typography = $derived(typographySettingsStore);
|
2026-03-02 22:18:05 +03:00
|
|
|
|
|
|
|
|
let container = $state<HTMLElement>();
|
|
|
|
|
|
|
|
|
|
const responsive = getContext<ResponsiveManager>('responsive');
|
|
|
|
|
const isMobile = $derived(responsive?.isMobile ?? false);
|
|
|
|
|
|
|
|
|
|
let isDragging = $state(false);
|
2026-04-17 16:30:41 +03:00
|
|
|
let isTypographyMenuOpen = $state(false);
|
2026-04-20 10:52:28 +03:00
|
|
|
let containerWidth = $state(0);
|
2026-03-02 22:18:05 +03:00
|
|
|
|
2026-04-11 16:26:41 +03:00
|
|
|
// New high-performance layout engine
|
|
|
|
|
const comparisonEngine = new CharacterComparisonEngine();
|
2026-03-02 22:18:05 +03:00
|
|
|
|
2026-04-11 16:26:41 +03:00
|
|
|
let layoutResult = $state<ReturnType<typeof comparisonEngine.layout>>({ lines: [], totalHeight: 0 });
|
2026-03-02 22:18:05 +03:00
|
|
|
|
2026-04-23 12:45:13 +03:00
|
|
|
// Track container width changes (window resize, sidebar toggle, etc.)
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (!container) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const observer = new ResizeObserver(entries => {
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
// Use borderBoxSize if available, fallback to contentRect
|
|
|
|
|
const width = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
|
|
|
|
if (width > 0) {
|
|
|
|
|
containerWidth = width;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
observer.observe(container);
|
|
|
|
|
return () => observer.disconnect();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-02 22:18:05 +03:00
|
|
|
const sliderSpring = new Spring(50, {
|
|
|
|
|
stiffness: 0.2,
|
|
|
|
|
damping: 0.7,
|
|
|
|
|
});
|
|
|
|
|
const sliderPos = $derived(sliderSpring.current);
|
|
|
|
|
|
|
|
|
|
function handleMove(e: PointerEvent) {
|
2026-04-17 13:05:36 +03:00
|
|
|
if (!isDragging || !container) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-02 22:18:05 +03:00
|
|
|
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();
|
2026-04-17 16:30:41 +03:00
|
|
|
// Close typography menu popover
|
|
|
|
|
isTypographyMenuOpen = false;
|
2026-03-02 22:18:05 +03:00
|
|
|
isDragging = true;
|
|
|
|
|
handleMove(e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const storeSliderPosition = debounce((value: number) => {
|
|
|
|
|
comparisonStore.sliderPosition = value;
|
|
|
|
|
}, 100);
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
storeSliderPosition(sliderPos);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
2026-04-17 13:05:36 +03:00
|
|
|
if (!responsive) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-02 22:18:05 +03:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-23 12:45:13 +03:00
|
|
|
// Layout effect — depends on content, settings AND containerWidth
|
2026-03-02 22:18:05 +03:00
|
|
|
$effect(() => {
|
|
|
|
|
const _text = comparisonStore.text;
|
|
|
|
|
const _weight = typography.weight;
|
|
|
|
|
const _size = typography.renderedSize;
|
|
|
|
|
const _height = typography.height;
|
2026-04-20 10:52:28 +03:00
|
|
|
const _spacing = typography.spacing;
|
2026-04-23 12:45:13 +03:00
|
|
|
const _width = containerWidth;
|
|
|
|
|
const _isMobile = isMobile;
|
2026-04-11 16:26:41 +03:00
|
|
|
|
2026-04-23 12:45:13 +03:00
|
|
|
if (container && fontA && fontB && _width > 0) {
|
2026-04-11 16:26:41 +03:00
|
|
|
// PRETEXT API strings: "weight sizepx family"
|
2026-04-20 11:13:54 +03:00
|
|
|
const fontAStr = getPretextFontString(_weight, _size, fontA.name);
|
|
|
|
|
const fontBStr = getPretextFontString(_weight, _size, fontB.name);
|
2026-04-11 16:26:41 +03:00
|
|
|
|
2026-04-23 12:45:13 +03:00
|
|
|
const padding = _isMobile ? 48 : 96;
|
|
|
|
|
const availableWidth = Math.max(0, _width - padding);
|
2026-04-20 10:52:28 +03:00
|
|
|
const lineHeight = _size * _height;
|
2026-04-11 16:26:41 +03:00
|
|
|
|
|
|
|
|
layoutResult = comparisonEngine.layout(
|
|
|
|
|
_text,
|
|
|
|
|
fontAStr,
|
|
|
|
|
fontBStr,
|
|
|
|
|
availableWidth,
|
|
|
|
|
lineHeight,
|
2026-04-20 10:52:28 +03:00
|
|
|
_spacing,
|
|
|
|
|
_size,
|
2026-04-11 16:26:41 +03:00
|
|
|
);
|
2026-03-02 22:18:05 +03:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
-->
|
2026-04-23 09:48:32 +03:00
|
|
|
<div class={cn('flex-1 relative flex items-center justify-center p-0 overflow-hidden bg-surface dark:bg-dark-bg', className)}>
|
2026-04-16 08:44:49 +03:00
|
|
|
<!-- Paper surface -->
|
2026-03-02 22:18:05 +03:00
|
|
|
<div
|
2026-04-23 09:48:32 +03:00
|
|
|
class={cn(
|
2026-03-02 22:18:05 +03:00
|
|
|
'w-full h-full flex flex-col items-center justify-center relative',
|
2026-03-04 16:51:49 +03:00
|
|
|
'bg-paper dark:bg-dark-card',
|
2026-03-02 22:18:05 +03:00
|
|
|
'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
|
2026-03-04 16:51:49 +03:00
|
|
|
class="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05] text-swiss-black dark:text-swiss-white"
|
2026-03-02 22:18:05 +03:00
|
|
|
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
|
2026-04-17 16:30:41 +03:00
|
|
|
select-none touch-none outline-none cursor-ew-resize
|
2026-03-02 22:18:05 +03:00
|
|
|
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
|
|
|
|
|
"
|
|
|
|
|
>
|
2026-04-20 10:52:28 +03:00
|
|
|
{#each layoutResult.lines as line}
|
|
|
|
|
{@const lineStates = comparisonEngine.getLineCharStates(line, sliderPos, containerWidth)}
|
2026-04-11 16:26:41 +03:00
|
|
|
<Line chars={line.chars}>
|
2026-03-02 22:18:05 +03:00
|
|
|
{#snippet character({ char, index })}
|
2026-04-20 10:52:28 +03:00
|
|
|
<Character
|
|
|
|
|
{char}
|
|
|
|
|
proximity={lineStates[index]?.proximity ?? 0}
|
|
|
|
|
isPast={lineStates[index]?.isPast ?? false}
|
|
|
|
|
/>
|
2026-03-02 22:18:05 +03:00
|
|
|
{/snippet}
|
|
|
|
|
</Line>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Thumb {sliderPos} {isDragging} />
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-16 08:44:49 +03:00
|
|
|
|
|
|
|
|
<TypographyMenu
|
2026-04-17 16:30:41 +03:00
|
|
|
bind:open={isTypographyMenuOpen}
|
2026-04-23 09:48:32 +03:00
|
|
|
class={cn(
|
2026-04-23 09:42:59 +03:00
|
|
|
'absolute z-10',
|
2026-04-17 14:39:25 +03:00
|
|
|
responsive.isMobileOrTablet
|
2026-04-23 12:45:13 +03:00
|
|
|
? 'bottom-0 right-0 -translate-1/2'
|
|
|
|
|
: 'bottom-2.5 left-1/2 -translate-x-1/2',
|
2026-04-16 08:44:49 +03:00
|
|
|
)}
|
|
|
|
|
/>
|
2026-03-02 22:18:05 +03:00
|
|
|
</div>
|