feat(ComparisonSlider): add perspective manager and tweak styles
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user