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.
|
- Character-level morphing: Font changes exactly when the slider passes the character's global position.
|
||||||
- Responsive layout with Tailwind breakpoints for font sizing.
|
- Responsive layout with Tailwind breakpoints for font sizing.
|
||||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
- 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">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
|
type CharacterComparison,
|
||||||
|
type LineData,
|
||||||
|
type ResponsiveManager,
|
||||||
createCharacterComparison,
|
createCharacterComparison,
|
||||||
createTypographyControl,
|
createPerspectiveManager,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
import type {
|
import {
|
||||||
LineData,
|
Loader,
|
||||||
ResponsiveManager,
|
PerspectivePlan,
|
||||||
} from '$shared/lib';
|
} from '$shared/ui';
|
||||||
import { Loader } from '$shared/ui';
|
|
||||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { Spring } from 'svelte/motion';
|
import { Spring } from 'svelte/motion';
|
||||||
@@ -31,10 +37,11 @@ import SliderLine from './components/SliderLine.svelte';
|
|||||||
const fontA = $derived(comparisonStore.fontA);
|
const fontA = $derived(comparisonStore.fontA);
|
||||||
const fontB = $derived(comparisonStore.fontB);
|
const fontB = $derived(comparisonStore.fontB);
|
||||||
|
|
||||||
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
const isLoading = $derived(
|
||||||
|
comparisonStore.isLoading || !comparisonStore.isReady,
|
||||||
|
);
|
||||||
|
|
||||||
let container = $state<HTMLElement>();
|
let container = $state<HTMLElement>();
|
||||||
let typographyControls = $state<HTMLDivElement | null>(null);
|
|
||||||
let measureCanvas = $state<HTMLCanvasElement>();
|
let measureCanvas = $state<HTMLCanvasElement>();
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
const typography = $derived(comparisonStore.typography);
|
const typography = $derived(comparisonStore.typography);
|
||||||
@@ -45,7 +52,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
|
|||||||
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
||||||
* Manages line breaking and character state based on fonts and container dimensions.
|
* Manages line breaking and character state based on fonts and container dimensions.
|
||||||
*/
|
*/
|
||||||
const charComparison = createCharacterComparison(
|
const charComparison: CharacterComparison = createCharacterComparison(
|
||||||
() => comparisonStore.text,
|
() => comparisonStore.text,
|
||||||
() => fontA,
|
() => fontA,
|
||||||
() => fontB,
|
() => fontB,
|
||||||
@@ -53,6 +60,22 @@ const charComparison = createCharacterComparison(
|
|||||||
() => typography.renderedSize,
|
() => 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)[]>([]);
|
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
||||||
|
|
||||||
/** Physics-based spring for smooth handle movement */
|
/** Physics-based spring for smooth handle movement */
|
||||||
@@ -64,7 +87,10 @@ const sliderPos = $derived(sliderSpring.current);
|
|||||||
|
|
||||||
/** Updates spring target based on pointer position */
|
/** Updates spring target based on pointer position */
|
||||||
function handleMove(e: PointerEvent) {
|
function handleMove(e: PointerEvent) {
|
||||||
if (!isDragging || !container) return;
|
if (!isDragging || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||||
const percentage = (x / rect.width) * 100;
|
const percentage = (x / rect.width) * 100;
|
||||||
@@ -72,18 +98,15 @@ function handleMove(e: PointerEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startDragging(e: PointerEvent) {
|
function startDragging(e: PointerEvent) {
|
||||||
if (
|
|
||||||
e.target === typographyControls
|
|
||||||
|| typographyControls?.contains(e.target as Node)
|
|
||||||
) {
|
|
||||||
e.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
handleMove(e);
|
handleMove(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePerspective() {
|
||||||
|
perspective.toggle();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the multiplier for slider font size based on the current responsive state
|
* Sets the multiplier for slider font size based on the current responsive state
|
||||||
*/
|
*/
|
||||||
@@ -146,28 +169,28 @@ $effect(() => {
|
|||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isInSettingsMode = $derived(perspective.isBack);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet renderLine(line: LineData, index: number)}
|
{#snippet renderLine(line: LineData, index: number)}
|
||||||
|
{@const pos = sliderPos}
|
||||||
|
{@const element = lineElements[index]}
|
||||||
<div
|
<div
|
||||||
bind:this={lineElements[index]}
|
bind:this={lineElements[index]}
|
||||||
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
||||||
style:height={`${typography.height}em`}
|
style:height={`${typography.height}em`}
|
||||||
style:line-height={`${typography.height}em`}
|
style:line-height={`${typography.height}em`}
|
||||||
>
|
>
|
||||||
{#each line.text.split('') as char, charIndex}
|
{#each line.text.split('') as char, index}
|
||||||
{@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)}
|
{@const { proximity, isPast } = charComparison.getCharState(index, pos, element, container)}
|
||||||
<!--
|
<!--
|
||||||
Single Character Span
|
Single Character Span
|
||||||
- Font Family switches based on `isPast`
|
- Font Family switches based on `isPast`
|
||||||
- Transitions/Transforms provide the "morph" feel
|
- Transitions/Transforms provide the "morph" feel
|
||||||
-->
|
-->
|
||||||
{#if fontA && fontB}
|
{#if fontA && fontB}
|
||||||
<CharacterSlot
|
<CharacterSlot {char} {proximity} {isPast} />
|
||||||
{char}
|
|
||||||
{proximity}
|
|
||||||
{isPast}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -176,7 +199,33 @@ $effect(() => {
|
|||||||
<!-- Hidden canvas used for text measurement by the helper -->
|
<!-- Hidden canvas used for text measurement by the helper -->
|
||||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
||||||
|
|
||||||
<div class="relative">
|
<!-- 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
|
<div
|
||||||
bind:this={container}
|
bind:this={container}
|
||||||
role="slider"
|
role="slider"
|
||||||
@@ -185,31 +234,19 @@ $effect(() => {
|
|||||||
aria-label="Font comparison slider"
|
aria-label="Font comparison slider"
|
||||||
onpointerdown={startDragging}
|
onpointerdown={startDragging}
|
||||||
class="
|
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
|
relative w-full h-full flex justify-center
|
||||||
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
|
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 min-h-72 sm:min-h-96 flex flex-col justify-center
|
select-none touch-none cursor-ew-resize
|
||||||
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}
|
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
relative flex flex-col items-center gap-3 sm:gap-4
|
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]
|
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
|
z-10 pointer-events-none text-center
|
||||||
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
|
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
|
||||||
|
my-auto
|
||||||
"
|
"
|
||||||
style:perspective="1000px"
|
|
||||||
in:fade={{ duration: 300, delay: 300 }}
|
in:fade={{ duration: 300, delay: 300 }}
|
||||||
out:fade={{ duration: 300 }}
|
out:fade={{ duration: 300 }}
|
||||||
>
|
>
|
||||||
@@ -226,9 +263,16 @@ $effect(() => {
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Slider Line - visible in slider mode -->
|
||||||
|
{#if !isInSettingsMode}
|
||||||
<SliderLine {sliderPos} {isDragging} />
|
<SliderLine {sliderPos} {isDragging} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- Since there're slider controls inside we put them outside the main one -->
|
</PerspectivePlan>
|
||||||
<Controls {sliderPos} {isDragging} {typographyControls} {container} />
|
|
||||||
|
<Controls
|
||||||
|
class="absolute inset-y-0 left-0 transition-all duration-150"
|
||||||
|
handleToggle={togglePerspective}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user