feat(ComparisonSlider): add perspective manager and tweak styles

This commit is contained in:
Ilia Mashkov
2026-02-15 23:15:50 +03:00
parent 858daff860
commit 6ba37c9e4a

View File

@@ -8,17 +8,23 @@
- 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.
Modes:
- Slider mode: Text centered in 1st plan, controls hidden
- Settings mode: Text moves to left (2nd plan), controls appear on right (1st plan)
-->
<script lang="ts">
import {
type CharacterComparison,
type LineData,
type ResponsiveManager,
createCharacterComparison,
createTypographyControl,
createPerspectiveManager,
} from '$shared/lib';
import type {
LineData,
ResponsiveManager,
} from '$shared/lib';
import { Loader } from '$shared/ui';
import {
Loader,
PerspectivePlan,
} from '$shared/ui';
import { comparisonStore } from '$widgets/ComparisonSlider/model';
import { getContext } from 'svelte';
import { Spring } from 'svelte/motion';
@@ -31,10 +37,11 @@ import SliderLine from './components/SliderLine.svelte';
const fontA = $derived(comparisonStore.fontA);
const fontB = $derived(comparisonStore.fontB);
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
const isLoading = $derived(
comparisonStore.isLoading || !comparisonStore.isReady,
);
let container = $state<HTMLElement>();
let typographyControls = $state<HTMLDivElement | null>(null);
let measureCanvas = $state<HTMLCanvasElement>();
let isDragging = $state(false);
const typography = $derived(comparisonStore.typography);
@@ -45,7 +52,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
* 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(
const charComparison: CharacterComparison = createCharacterComparison(
() => comparisonStore.text,
() => fontA,
() => fontB,
@@ -53,6 +60,22 @@ const charComparison = createCharacterComparison(
() => typography.renderedSize,
);
/**
* Perspective manager for back/front state toggling:
* - Front (slider mode): Text fully visible, interactive
* - Back (settings mode): Text blurred, scaled down, shifted left, controls visible
*
* Uses simple boolean flag for smooth transitions between states.
*/
const perspective = createPerspectiveManager({
parallaxIntensity: 0, // Disabled to not interfere with slider
horizontalOffset: 0, // Text shifts left when in back position
scaleStep: 0.5,
blurStep: 2,
depthStep: 100,
opacityStep: 0.3,
});
let lineElements = $state<(HTMLElement | undefined)[]>([]);
/** Physics-based spring for smooth handle movement */
@@ -64,7 +87,10 @@ const sliderPos = $derived(sliderSpring.current);
/** Updates spring target based on pointer position */
function handleMove(e: PointerEvent) {
if (!isDragging || !container) return;
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;
@@ -72,18 +98,15 @@ function handleMove(e: PointerEvent) {
}
function startDragging(e: PointerEvent) {
if (
e.target === typographyControls
|| typographyControls?.contains(e.target as Node)
) {
e.stopPropagation();
return;
}
e.preventDefault();
isDragging = true;
handleMove(e);
}
function togglePerspective() {
perspective.toggle();
}
/**
* Sets the multiplier for slider font size based on the current responsive state
*/
@@ -146,28 +169,28 @@ $effect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
const isInSettingsMode = $derived(perspective.isBack);
</script>
{#snippet renderLine(line: LineData, index: number)}
{@const pos = sliderPos}
{@const element = lineElements[index]}
<div
bind:this={lineElements[index]}
class="relative flex w-full justify-center items-center whitespace-nowrap"
style:height={`${typography.height}em`}
style:line-height={`${typography.height}em`}
>
{#each line.text.split('') as char, charIndex}
{@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)}
{#each line.text.split('') as char, index}
{@const { proximity, isPast } = charComparison.getCharState(index, pos, element, container)}
<!--
Single Character Span
- Font Family switches based on `isPast`
- Transitions/Transforms provide the "morph" feel
-->
{#if fontA && fontB}
<CharacterSlot
{char}
{proximity}
{isPast}
/>
<CharacterSlot {char} {proximity} {isPast} />
{/if}
{/each}
</div>
@@ -176,59 +199,80 @@ $effect(() => {
<!-- 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-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24 overflow-hidden
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
select-none touch-none cursor-ew-resize min-h-72 sm:min-h-96 flex flex-col justify-center
backdrop-blur-lg bg-linear-to-br from-gray-100/70 via-white/50 to-gray-100/60
border border-border-muted
shadow-[inset_0_4px_12px_0_rgba(0,0,0,0.12),inset_0_2px_4px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(255,255,255,0.8)]
before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
before:-z-10 before:blur-sm
"
>
<!-- Text Rendering Container -->
{#if isLoading}
<div out:fade={{ duration: 300 }}>
<Loader size={24} />
</div>
{:else}
<!-- Main container with perspective and fixed height -->
<div
class="
relative w-full flex justify-center items-center
perspective-distant perspective-origin-center transform-3d
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
min-h-72 sm:min-h-96
backdrop-blur-lg bg-linear-to-br from-gray-200/40 via-white/80 to-gray-100/60
border border-border-muted
shadow-[inset_2px_0_8px_rgba(0,0,0,0.05)]
before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
before:-z-10 before:blur-sm
overflow-hidden
[perspective:1500px] perspective-origin-center transform-3d
"
>
{#if isLoading}
<div out:fade={{ duration: 300 }}>
<Loader size={24} />
</div>
{:else}
<!-- Text Plan -->
<PerspectivePlan
manager={perspective}
class="absolute inset-0 flex justify-center origin-right w-full h-full"
>
<div
bind:this={container}
role="slider"
tabindex="0"
aria-valuenow={Math.round(sliderPos)}
aria-label="Font comparison slider"
onpointerdown={startDragging}
class="
relative flex flex-col items-center gap-3 sm:gap-4
text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold leading-[1.15]
z-10 pointer-events-none text-center
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
relative w-full h-full flex justify-center
py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24
select-none touch-none cursor-ew-resize
"
style:perspective="1000px"
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300 }}
>
{#each charComparison.lines as line, lineIndex}
<div
class="relative w-full whitespace-nowrap"
style:height={`${typography.height}em`}
style:display="flex"
style:align-items="center"
style:justify-content="center"
>
{@render renderLine(line, lineIndex)}
</div>
{/each}
</div>
<div
class="
relative flex flex-col items-center gap-3 sm:gap-4
text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold leading-[1.15]
z-10 pointer-events-none text-center
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
my-auto
"
in:fade={{ duration: 300, delay: 300 }}
out:fade={{ duration: 300 }}
>
{#each charComparison.lines as line, lineIndex}
<div
class="relative w-full whitespace-nowrap"
style:height={`${typography.height}em`}
style:display="flex"
style:align-items="center"
style:justify-content="center"
>
{@render renderLine(line, lineIndex)}
</div>
{/each}
</div>
<SliderLine {sliderPos} {isDragging} />
{/if}
</div>
<!-- Since there're slider controls inside we put them outside the main one -->
<Controls {sliderPos} {isDragging} {typographyControls} {container} />
<!-- Slider Line - visible in slider mode -->
{#if !isInSettingsMode}
<SliderLine {sliderPos} {isDragging} />
{/if}
</div>
</PerspectivePlan>
<Controls
class="absolute inset-y-0 left-0 transition-all duration-150"
handleToggle={togglePerspective}
/>
{/if}
</div>