Files
frontend-svelte/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte
T
Ilia Mashkov 6153769317 refactor(comparison): switch to 3-section render model via DualFontLayout
Rewrite Line.svelte to render leftText / windowChars / rightText regions
from a LineRenderModel. Bulk regions render as native shaped text runs so
the browser applies kerning and ligatures; per-char DOM is reserved for
the N-char crossfade window straddling the slider.

Slim Character.svelte: drop the unused proximity prop and the redundant
font-size/font-weight/letter-spacing styles now inherited from the line
container.

Switch SliderArea.svelte to instantiate DualFontLayout and derive each
line's render model via computeLineRenderModel(line, sliderPos,
containerWidth, WINDOW_SIZE).
2026-05-30 22:29:43 +03:00

359 lines
11 KiB
Svelte

<!--
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 ComparisonResult,
DualFontLayout,
MULTIPLIER_L,
MULTIPLIER_M,
MULTIPLIER_S,
computeLineRenderModel,
} from '$entities/Font';
import { TypographyMenu } from '$features/AdjustTypography';
import { typographySettingsStore } from '$features/AdjustTypography/model';
import {
type ResponsiveManager,
debounce,
} from '$shared/lib';
import { cn } from '$shared/lib';
import { Loader } from '$shared/ui';
import { getContext } from 'svelte';
import { Spring } from 'svelte/motion';
import { fade } from 'svelte/transition';
import {
ensureCanvasFonts,
getPretextFontString,
} from '../../lib';
import { comparisonStore } from '../../model';
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();
/**
* Spring tuning for the comparison slider thumb. Lower stiffness = slower
* follow; higher damping = less overshoot.
*/
const SLIDER_SPRING_CONFIG = { stiffness: 0.2, damping: 0.7 } as const;
/**
* Debounce wait before persisting the slider position to the store.
* High frequency during drag → batched writes.
*/
const SLIDER_PERSIST_DEBOUNCE_MS = 100;
/**
* Horizontal layout padding subtracted from container width before laying
* out the comparison text. Different per breakpoint to match the gutters
* around the slider track.
*/
const SLIDER_PADDING_MOBILE_PX = 48;
const SLIDER_PADDING_DESKTOP_PX = 96;
/**
* Position bounds (percent of container width).
*/
const SLIDER_MIN = 0;
const SLIDER_MAX = 100;
/**
* Fine and coarse keyboard step sizes. Shift / Page keys use the coarse
* step; bare arrow keys use the fine step.
*/
const SLIDER_STEP_FINE = 1;
const SLIDER_STEP_COARSE = 10;
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);
const layout = new DualFontLayout();
let layoutResult = $state<ComparisonResult>({ lines: [], totalHeight: 0 });
/**
* N-window size for the per-char crossfade zone around the slider split.
* Tuned so chars complete their 100ms opacity crossfade before exiting the window.
*/
const WINDOW_SIZE = 5;
// 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();
});
const sliderSpring = new Spring(50, SLIDER_SPRING_CONFIG);
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);
}
/**
* Keyboard control for the comparison slider. Implements the standard
* ARIA slider keyboard contract: arrows step the position, Shift+arrow
* and PageUp/PageDown jump by the coarse step, Home/End snap to bounds.
*/
function handleKeydown(e: KeyboardEvent) {
const coarse = e.shiftKey;
const step = coarse ? SLIDER_STEP_COARSE : SLIDER_STEP_FINE;
const current = sliderSpring.target;
let next = current;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowDown':
next = current - step;
break;
case 'ArrowRight':
case 'ArrowUp':
next = current + step;
break;
case 'PageDown':
next = current - SLIDER_STEP_COARSE;
break;
case 'PageUp':
next = current + SLIDER_STEP_COARSE;
break;
case 'Home':
next = SLIDER_MIN;
break;
case 'End':
next = SLIDER_MAX;
break;
default:
return;
}
e.preventDefault();
sliderSpring.target = Math.max(SLIDER_MIN, Math.min(SLIDER_MAX, next));
}
const storeSliderPosition = debounce((value: number) => {
comparisonStore.sliderPosition = value;
}, SLIDER_PERSIST_DEBOUNCE_MS);
$effect(() => {
storeSliderPosition(sliderPos);
});
$effect(() => {
if (!responsive) {
return;
}
switch (true) {
case responsive.isMobile:
typography.multiplier = MULTIPLIER_S;
break;
case responsive.isTablet:
typography.multiplier = MULTIPLIER_M;
break;
case responsive.isDesktop:
typography.multiplier = MULTIPLIER_L;
break;
default:
typography.multiplier = MULTIPLIER_L;
}
});
$effect(() => {
if (isDragging) {
window.addEventListener('pointermove', handleMove);
const stop = () => (isDragging = false);
window.addEventListener('pointerup', stop);
return () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', stop);
};
}
});
// Layout effect — depends on content, settings AND containerWidth.
// Awaits font loading into the canvas measurement context before invoking
// the engine; otherwise pretext caches fallback-font widths globally per
// font string, and the morph boundary drifts from the thumb visually.
$effect(() => {
const _text = comparisonStore.text;
const _weight = typography.weight;
const _size = typography.renderedSize;
const _height = typography.height;
const _spacing = typography.spacing;
const _width = containerWidth;
const _isMobile = isMobile;
if (!container || !fontA || !fontB || _width <= 0) {
return;
}
const fontAStr = getPretextFontString(_weight, _size, fontA.name);
const fontBStr = getPretextFontString(_weight, _size, fontB.name);
const padding = _isMobile ? SLIDER_PADDING_MOBILE_PX : SLIDER_PADDING_DESKTOP_PX;
const availableWidth = Math.max(0, _width - padding);
const lineHeight = _size * _height;
let cancelled = false;
ensureCanvasFonts([fontAStr, fontBStr]).then(() => {
if (cancelled) {
return;
}
layoutResult = layout.layout(
_text,
fontAStr,
fontBStr,
availableWidth,
lineHeight,
_spacing,
_size,
);
});
return () => {
cancelled = true;
};
});
</script>
<!--
Outer flex container — fills parent.
Pads in when the sidebar opens on desktop, insetting the paper evenly.
-->
<div
class={cn(
'flex-1 relative flex-center overflow-hidden surface-canvas',
'transition-[padding] duration-slow ease-out',
isSidebarOpen && !isMobile ? 'p-6' : 'p-0',
className,
)}
>
<!-- Paper surface -->
<div
class={cn(
'w-full h-full flex flex-col flex-center relative',
'bg-paper dark:bg-dark-card',
'shadow-floating-panel dark:shadow-floating-panel-dark',
)}
>
<!-- Subtle dotted-grid overlay — purely decorative. -->
<div
class="absolute inset-0 pointer-events-none bg-grid-sm md:bg-grid"
aria-hidden="true"
>
</div>
<!-- Slider interaction area -->
<div class="w-full h-full flex items-center justify-center p-4 md:p-12 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-valuemin={SLIDER_MIN}
aria-valuemax={SLIDER_MAX}
aria-orientation="horizontal"
aria-label="Font comparison slider"
onpointerdown={startDragging}
onkeydown={handleKeydown}
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, lineIdx (lineIdx)}
{@const model = computeLineRenderModel(line, sliderPos, containerWidth, WINDOW_SIZE)}
<Line {model} />
{/each}
</div>
<Thumb {sliderPos} {isDragging} />
</div>
{/if}
</div>
</div>
<TypographyMenu
bind:open={isTypographyMenuOpen}
class={cn(
'absolute z-10',
responsive.isMobileOrTablet
? 'bottom-0 right-0 -translate-1/2'
: 'bottom-2.5 left-1/2 -translate-x-1/2',
)}
/>
</div>