feat(ComparisonSlider): add separate typographyManager instance into comparisonStore and use its controls in the slider. Improve mobile usability using Drawer for all the settings
This commit is contained in:
@@ -3,6 +3,10 @@ import {
|
|||||||
fetchFontsByIds,
|
fetchFontsByIds,
|
||||||
unifiedFontStore,
|
unifiedFontStore,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
|
import {
|
||||||
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||||
|
createTypographyControlManager,
|
||||||
|
} from '$features/SetupFont';
|
||||||
import { createPersistentStore } from '$shared/lib';
|
import { createPersistentStore } from '$shared/lib';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +34,7 @@ class ComparisonStore {
|
|||||||
#fontB = $state<UnifiedFont | undefined>();
|
#fontB = $state<UnifiedFont | undefined>();
|
||||||
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
||||||
#isRestoring = $state(true);
|
#isRestoring = $state(true);
|
||||||
|
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.restoreFromStorage();
|
this.restoreFromStorage();
|
||||||
@@ -102,6 +107,9 @@ class ComparisonStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Getters & Setters ---
|
// --- Getters & Setters ---
|
||||||
|
get typography() {
|
||||||
|
return this.#typography;
|
||||||
|
}
|
||||||
|
|
||||||
get fontA() {
|
get fontA() {
|
||||||
return this.#fontA;
|
return this.#fontA;
|
||||||
@@ -149,6 +157,13 @@ class ComparisonStore {
|
|||||||
this.restoreFromStorage();
|
this.restoreFromStorage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetAll() {
|
||||||
|
this.#fontA = undefined;
|
||||||
|
this.#fontB = undefined;
|
||||||
|
storage.clear();
|
||||||
|
this.#typography.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const comparisonStore = new ComparisonStore();
|
export const comparisonStore = new ComparisonStore();
|
||||||
|
|||||||
@@ -14,14 +14,17 @@ import {
|
|||||||
createCharacterComparison,
|
createCharacterComparison,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
import type { LineData } from '$shared/lib';
|
import type {
|
||||||
|
LineData,
|
||||||
|
ResponsiveManager,
|
||||||
|
} from '$shared/lib';
|
||||||
import { Loader } 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 { Spring } from 'svelte/motion';
|
import { Spring } from 'svelte/motion';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import CharacterSlot from './components/CharacterSlot.svelte';
|
import CharacterSlot from './components/CharacterSlot.svelte';
|
||||||
import ControlsWrapper from './components/ControlsWrapper.svelte';
|
import Controls from './components/Controls.svelte';
|
||||||
import Labels from './components/Labels.svelte';
|
|
||||||
import SliderLine from './components/SliderLine.svelte';
|
import SliderLine from './components/SliderLine.svelte';
|
||||||
|
|
||||||
// Pair of fonts to compare
|
// Pair of fonts to compare
|
||||||
@@ -30,31 +33,13 @@ const fontB = $derived(comparisonStore.fontB);
|
|||||||
|
|
||||||
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
||||||
|
|
||||||
let container: HTMLElement | undefined = $state();
|
let container = $state<HTMLElement>();
|
||||||
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
|
let typographyControls = $state<HTMLDivElement | null>(null);
|
||||||
let measureCanvas: HTMLCanvasElement | undefined = $state();
|
let measureCanvas = $state<HTMLCanvasElement>();
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
|
const typography = $derived(comparisonStore.typography);
|
||||||
|
|
||||||
const weightControl = createTypographyControl({
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
min: 100,
|
|
||||||
max: 700,
|
|
||||||
step: 100,
|
|
||||||
value: 400,
|
|
||||||
});
|
|
||||||
|
|
||||||
const heightControl = createTypographyControl({
|
|
||||||
min: 1,
|
|
||||||
max: 2,
|
|
||||||
step: 0.05,
|
|
||||||
value: 1.2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const sizeControl = createTypographyControl({
|
|
||||||
min: 1,
|
|
||||||
max: 112,
|
|
||||||
step: 1,
|
|
||||||
value: 64,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
||||||
@@ -64,8 +49,8 @@ const charComparison = createCharacterComparison(
|
|||||||
() => comparisonStore.text,
|
() => comparisonStore.text,
|
||||||
() => fontA,
|
() => fontA,
|
||||||
() => fontB,
|
() => fontB,
|
||||||
() => weightControl.value,
|
() => typography.weight,
|
||||||
() => sizeControl.value,
|
() => typography.renderedSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
||||||
@@ -88,8 +73,8 @@ function handleMove(e: PointerEvent) {
|
|||||||
|
|
||||||
function startDragging(e: PointerEvent) {
|
function startDragging(e: PointerEvent) {
|
||||||
if (
|
if (
|
||||||
e.target === controlsWrapperElement
|
e.target === typographyControls
|
||||||
|| controlsWrapperElement?.contains(e.target as Node)
|
|| typographyControls?.contains(e.target as Node)
|
||||||
) {
|
) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
return;
|
return;
|
||||||
@@ -99,6 +84,30 @@ function startDragging(e: PointerEvent) {
|
|||||||
handleMove(e);
|
handleMove(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the multiplier for slider font size based on the current responsive state
|
||||||
|
*/
|
||||||
|
$effect(() => {
|
||||||
|
if (!responsive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
window.addEventListener('pointermove', handleMove);
|
window.addEventListener('pointermove', handleMove);
|
||||||
@@ -115,9 +124,9 @@ $effect(() => {
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
// React on text and typography settings changes
|
// React on text and typography settings changes
|
||||||
const _text = comparisonStore.text;
|
const _text = comparisonStore.text;
|
||||||
const _weight = weightControl.value;
|
const _weight = typography.weight;
|
||||||
const _size = sizeControl.value;
|
const _size = typography.renderedSize;
|
||||||
const _height = heightControl.value;
|
const _height = typography.height;
|
||||||
|
|
||||||
if (container && measureCanvas && fontA && fontB) {
|
if (container && measureCanvas && fontA && fontB) {
|
||||||
// Using rAF to ensure DOM is ready/stabilized
|
// Using rAF to ensure DOM is ready/stabilized
|
||||||
@@ -143,8 +152,8 @@ $effect(() => {
|
|||||||
<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={`${heightControl.value}em`}
|
style:height={`${typography.height}em`}
|
||||||
style:line-height={`${heightControl.value}em`}
|
style:line-height={`${typography.height}em`}
|
||||||
>
|
>
|
||||||
{#each line.text.split('') as char, charIndex}
|
{#each line.text.split('') as char, charIndex}
|
||||||
{@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)}
|
{@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)}
|
||||||
@@ -158,8 +167,8 @@ $effect(() => {
|
|||||||
{char}
|
{char}
|
||||||
{proximity}
|
{proximity}
|
||||||
{isPast}
|
{isPast}
|
||||||
weight={weightControl.value}
|
weight={typography.weight}
|
||||||
size={sizeControl.value}
|
size={typography.renderedSize}
|
||||||
fontAName={fontA.name}
|
fontAName={fontA.name}
|
||||||
fontBName={fontB.name}
|
fontBName={fontB.name}
|
||||||
/>
|
/>
|
||||||
@@ -182,12 +191,12 @@ $effect(() => {
|
|||||||
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
|
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]
|
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
|
||||||
select-none touch-none cursor-ew-resize min-h-[300px] sm:min-h-[400px] md:min-h-[500px] flex flex-col justify-center
|
select-none touch-none cursor-ew-resize min-h-72 sm:min-h-96 flex flex-col justify-center
|
||||||
backdrop-blur-lg bg-gradient-to-br from-gray-100/70 via-white/50 to-gray-100/60
|
backdrop-blur-lg bg-linear-to-br from-gray-100/70 via-white/50 to-gray-100/60
|
||||||
border border-gray-300/40
|
border border-gray-300/40
|
||||||
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)]
|
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-[1px]
|
before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
|
||||||
before:bg-gradient-to-br before:from-black/5 before:via-black/2 before:to-transparent
|
before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
|
||||||
before:-z-10 before:blur-sm
|
before:-z-10 before:blur-sm
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -209,7 +218,7 @@ $effect(() => {
|
|||||||
{#each charComparison.lines as line, lineIndex}
|
{#each charComparison.lines as line, lineIndex}
|
||||||
<div
|
<div
|
||||||
class="relative w-full whitespace-nowrap"
|
class="relative w-full whitespace-nowrap"
|
||||||
style:height={`${heightControl.value}em`}
|
style:height={`${typography.height}em`}
|
||||||
style:display="flex"
|
style:display="flex"
|
||||||
style:align-items="center"
|
style:align-items="center"
|
||||||
style:justify-content="center"
|
style:justify-content="center"
|
||||||
@@ -222,19 +231,6 @@ $effect(() => {
|
|||||||
<SliderLine {sliderPos} {isDragging} />
|
<SliderLine {sliderPos} {isDragging} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if fontA && fontB && !isLoading}
|
|
||||||
<Labels {fontA} {fontB} {sliderPos} weight={weightControl.value} />
|
|
||||||
<!-- Since there're slider controls inside we put them outside the main one -->
|
<!-- Since there're slider controls inside we put them outside the main one -->
|
||||||
<ControlsWrapper
|
<Controls {sliderPos} {isDragging} {typographyControls} {container} />
|
||||||
bind:wrapper={controlsWrapperElement}
|
|
||||||
{sliderPos}
|
|
||||||
{isDragging}
|
|
||||||
bind:text={comparisonStore.text}
|
|
||||||
containerWidth={container?.clientWidth}
|
|
||||||
{weightControl}
|
|
||||||
{sizeControl}
|
|
||||||
{heightControl}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
IconButton,
|
||||||
|
} from '$shared/ui';
|
||||||
|
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import { comparisonStore } from '../../../model';
|
||||||
|
import SelectComparedFonts from './SelectComparedFonts.svelte';
|
||||||
|
import TypographyControls from './TypographyControls.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sliderPos: number;
|
||||||
|
isDragging: boolean;
|
||||||
|
typographyControls?: HTMLDivElement | null;
|
||||||
|
container: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { sliderPos, isDragging, typographyControls = $bindable<HTMLDivElement | null>(null), container }: Props = $props();
|
||||||
|
|
||||||
|
const fontA = $derived(comparisonStore.fontA);
|
||||||
|
const fontB = $derived(comparisonStore.fontB);
|
||||||
|
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
||||||
|
|
||||||
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if responsive.isMobile}
|
||||||
|
<Drawer>
|
||||||
|
{#snippet trigger({ isOpen, onClick })}
|
||||||
|
<IconButton class="absolute right-3 top-3" onclick={onClick}>
|
||||||
|
{#snippet icon({ className })}
|
||||||
|
<SlidersIcon class={className} />
|
||||||
|
{/snippet}
|
||||||
|
</IconButton>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet content({ isOpen })}
|
||||||
|
<div class="px-2 py-4">
|
||||||
|
<SelectComparedFonts {sliderPos} />
|
||||||
|
</div>
|
||||||
|
<TypographyControls
|
||||||
|
{sliderPos}
|
||||||
|
{isDragging}
|
||||||
|
isActive={isOpen}
|
||||||
|
bind:wrapper={typographyControls}
|
||||||
|
containerWidth={container?.clientWidth}
|
||||||
|
staticPosition
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
</Drawer>
|
||||||
|
{:else}
|
||||||
|
{#if !isLoading}
|
||||||
|
<div class="absolute top-3 sm:top-6 left-3 sm:left-6">
|
||||||
|
<TypographyControls
|
||||||
|
{sliderPos}
|
||||||
|
{isDragging}
|
||||||
|
bind:wrapper={typographyControls}
|
||||||
|
containerWidth={container?.clientWidth}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !isLoading}
|
||||||
|
<div class="absolute bottom-3 sm:bottom-6 md:bottom-8 inset-x-3 sm:inset-x-6 md:inset-x-12">
|
||||||
|
<SelectComparedFonts {sliderPos} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
<!--
|
|
||||||
Component: ControlsWrapper
|
|
||||||
Wrapper for the controls of the slider.
|
|
||||||
- Input to change the text
|
|
||||||
- Three combo controls with inputs and sliders for font-weight, font-size, and line-height
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { TypographyControl } from '$shared/lib';
|
|
||||||
import { Input } from '$shared/shadcn/ui/input';
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|
||||||
import { ComboControlV2 } from '$shared/ui';
|
|
||||||
import { ExpandableWrapper } from '$shared/ui';
|
|
||||||
import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
|
|
||||||
import { Spring } from 'svelte/motion';
|
|
||||||
import { fade } from 'svelte/transition';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Ref
|
|
||||||
*/
|
|
||||||
wrapper?: HTMLDivElement | null;
|
|
||||||
/**
|
|
||||||
* Slider position
|
|
||||||
*/
|
|
||||||
sliderPos: number;
|
|
||||||
/**
|
|
||||||
* Whether slider is being dragged
|
|
||||||
*/
|
|
||||||
isDragging: boolean;
|
|
||||||
/**
|
|
||||||
* Text to display
|
|
||||||
*/
|
|
||||||
text: string;
|
|
||||||
/**
|
|
||||||
* Container width
|
|
||||||
*/
|
|
||||||
containerWidth: number;
|
|
||||||
/**
|
|
||||||
* Weight control
|
|
||||||
*/
|
|
||||||
weightControl: TypographyControl;
|
|
||||||
/**
|
|
||||||
* Size control
|
|
||||||
*/
|
|
||||||
sizeControl: TypographyControl;
|
|
||||||
/**
|
|
||||||
* Height control
|
|
||||||
*/
|
|
||||||
heightControl: TypographyControl;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
sliderPos,
|
|
||||||
isDragging,
|
|
||||||
wrapper = $bindable(null),
|
|
||||||
text = $bindable(),
|
|
||||||
containerWidth = 0,
|
|
||||||
weightControl,
|
|
||||||
sizeControl,
|
|
||||||
heightControl,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let panelWidth = $derived(wrapper?.clientWidth ?? 0);
|
|
||||||
const margin = 24;
|
|
||||||
let side = $state<'left' | 'right'>('left');
|
|
||||||
// Unified active state for the entire wrapper
|
|
||||||
let isActive = $state(false);
|
|
||||||
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
const xSpring = new Spring(0, {
|
|
||||||
stiffness: 0.14, // Lower is slower
|
|
||||||
damping: 0.5, // Settle
|
|
||||||
});
|
|
||||||
|
|
||||||
const rotateSpring = new Spring(0, {
|
|
||||||
stiffness: 0.12,
|
|
||||||
damping: 0.55,
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleInputFocus() {
|
|
||||||
isActive = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Movement Logic
|
|
||||||
$effect(() => {
|
|
||||||
if (containerWidth === 0 || panelWidth === 0) return;
|
|
||||||
const sliderX = (sliderPos / 100) * containerWidth;
|
|
||||||
const buffer = 40;
|
|
||||||
const leftTrigger = margin + panelWidth + buffer;
|
|
||||||
const rightTrigger = containerWidth - (margin + panelWidth + buffer);
|
|
||||||
|
|
||||||
if (side === 'left' && sliderX < leftTrigger) {
|
|
||||||
side = 'right';
|
|
||||||
} else if (side === 'right' && sliderX > rightTrigger) {
|
|
||||||
side = 'left';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0;
|
|
||||||
if (containerWidth > 0 && panelWidth > 0) {
|
|
||||||
// On side change set the position and the rotation
|
|
||||||
xSpring.target = targetX;
|
|
||||||
rotateSpring.target = side === 'right' ? 3.5 : -3.5;
|
|
||||||
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
rotateSpring.target = 0;
|
|
||||||
}, 600);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="absolute top-6 left-6 z-50 will-change-transform"
|
|
||||||
style:transform="
|
|
||||||
translateX({xSpring.current}px)
|
|
||||||
rotateZ({rotateSpring.current}deg)
|
|
||||||
"
|
|
||||||
in:fade={{ duration: 300, delay: 300 }}
|
|
||||||
out:fade={{ duration: 300, delay: 300 }}
|
|
||||||
>
|
|
||||||
<ExpandableWrapper
|
|
||||||
bind:element={wrapper}
|
|
||||||
bind:expanded={isActive}
|
|
||||||
disabled={isDragging}
|
|
||||||
aria-label="Font controls"
|
|
||||||
rotation={side === 'right' ? 'counterclockwise' : 'clockwise'}
|
|
||||||
class={cn(
|
|
||||||
'transition-opacity flex items-top gap-1.5',
|
|
||||||
panelWidth === 0 ? 'opacity-0' : 'opacity-100',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{#snippet badge()}
|
|
||||||
<div
|
|
||||||
class={cn(
|
|
||||||
'animate-nudge relative transition-all',
|
|
||||||
side === 'left' ? 'order-2' : 'order-0',
|
|
||||||
isActive ? 'opacity-0' : 'opacity-100',
|
|
||||||
isDragging && 'opacity-80 grayscale-[0.2]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet visibleContent()}
|
|
||||||
<div class="relative px-2 py-1">
|
|
||||||
<Input
|
|
||||||
bind:value={text}
|
|
||||||
disabled={isDragging}
|
|
||||||
onfocusin={handleInputFocus}
|
|
||||||
class={cn(
|
|
||||||
isActive
|
|
||||||
? 'h-7 sm:h-8 text-[11px] sm:text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50 text-slate-900'
|
|
||||||
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm sm:text-base font-medium focus-visible:ring-0 text-slate-900/50',
|
|
||||||
' placeholder:text-slate-400 selection:bg-indigo-100 flex-1 transition-all duration-350 w-44 sm:w-56',
|
|
||||||
)}
|
|
||||||
placeholder="The quick brown fox..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#snippet hiddenContent()}
|
|
||||||
<div class="flex flex-col sm:flex-row justify-between items-center-safe gap-2 sm:gap-0">
|
|
||||||
<ComboControlV2 control={weightControl} />
|
|
||||||
<ComboControlV2 control={sizeControl} />
|
|
||||||
<ComboControlV2 control={heightControl} />
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</ExpandableWrapper>
|
|
||||||
</div>
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
<!--
|
<!--
|
||||||
Component: Labels
|
Component: SelectComparedFonts
|
||||||
Displays labels for font selection in the comparison slider.
|
Displays selects that change the compared fonts
|
||||||
-->
|
-->
|
||||||
<script lang="ts" generics="T extends UnifiedFont">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
FontVirtualList,
|
FontVirtualList,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
unifiedFontStore,
|
unifiedFontStore,
|
||||||
} from '$entities/Font';
|
} from '$entities/Font';
|
||||||
|
import { getFontUrl } from '$entities/Font/lib';
|
||||||
import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte';
|
import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte';
|
||||||
import {
|
import {
|
||||||
Content as SelectContent,
|
Content as SelectContent,
|
||||||
@@ -19,23 +20,19 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
|||||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
interface Props<T> {
|
interface Props {
|
||||||
/**
|
|
||||||
* First font to compare
|
|
||||||
*/
|
|
||||||
fontA: T;
|
|
||||||
/**
|
|
||||||
* Second font to compare
|
|
||||||
*/
|
|
||||||
fontB: T;
|
|
||||||
/**
|
/**
|
||||||
* Position of the slider
|
* Position of the slider
|
||||||
*/
|
*/
|
||||||
sliderPos: number;
|
sliderPos: number;
|
||||||
|
|
||||||
weight: number;
|
|
||||||
}
|
}
|
||||||
let { fontA, fontB, sliderPos, weight }: Props<T> = $props();
|
let { sliderPos }: Props = $props();
|
||||||
|
|
||||||
|
const typography = $derived(comparisonStore.typography);
|
||||||
|
const fontA = $derived(comparisonStore.fontA);
|
||||||
|
const fontAUrl = $derived(fontA && getFontUrl(fontA, typography.weight));
|
||||||
|
const fontB = $derived(comparisonStore.fontB);
|
||||||
|
const fontBUrl = $derived(fontB && getFontUrl(fontB, typography.weight));
|
||||||
|
|
||||||
const fontList = $derived(unifiedFontStore.fonts);
|
const fontList = $derived(unifiedFontStore.fonts);
|
||||||
|
|
||||||
@@ -51,11 +48,10 @@ function selectFontB(font: UnifiedFont) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet fontSelector(
|
{#snippet fontSelector(
|
||||||
name: string,
|
font: UnifiedFont,
|
||||||
id: string,
|
|
||||||
url: string,
|
|
||||||
fonts: UnifiedFont[],
|
fonts: UnifiedFont[],
|
||||||
selectFont: (font: UnifiedFont) => void,
|
url: string,
|
||||||
|
onSelect: (f: UnifiedFont) => void,
|
||||||
align: 'start' | 'end',
|
align: 'start' | 'end',
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@@ -74,15 +70,15 @@ function selectFontB(font: UnifiedFont) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div class="text-left flex-1 min-w-0">
|
<div class="text-left flex-1 min-w-0">
|
||||||
<FontApplicator {name} {id} {url}>
|
<FontApplicator name={font.name} id={font.id} {url}>
|
||||||
{name}
|
{font.name}
|
||||||
</FontApplicator>
|
</FontApplicator>
|
||||||
</div>
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
class={cn(
|
class={cn(
|
||||||
'bg-white/95 backdrop-blur-xl border border-gray-300/50 shadow-xl',
|
'bg-white/95 backdrop-blur-xl border border-gray-300/50 shadow-xl',
|
||||||
'w-44 sm:w-52 max-h-[240px] sm:max-h-[280px] overflow-hidden rounded-lg',
|
'w-44 sm:w-52 max-h-60 sm:max-h-64 overflow-hidden rounded-lg',
|
||||||
)}
|
)}
|
||||||
side="top"
|
side="top"
|
||||||
{align}
|
{align}
|
||||||
@@ -90,16 +86,20 @@ function selectFontB(font: UnifiedFont) {
|
|||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
<div class="p-1 sm:p-1.5">
|
<div class="p-1 sm:p-1.5">
|
||||||
<FontVirtualList items={fonts} {weight}>
|
<FontVirtualList items={fonts} weight={typography.weight}>
|
||||||
{#snippet children({ item: font })}
|
{#snippet children({ item: fontListItem })}
|
||||||
{@const handleClick = () => selectFont(font)}
|
{@const handleClick = () => onSelect(fontListItem)}
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value={font.id}
|
value={fontListItem.id}
|
||||||
class="data-[highlighted]:bg-gray-100 font-mono text-[10px] sm:text-[11px] px-2 sm:px-3 py-2 sm:py-2.5 rounded-md cursor-pointer transition-colors"
|
class="data-highlighted:bg-gray-100 font-mono text-[10px] sm:text-[11px] px-2 sm:px-3 py-2 sm:py-2.5 rounded-md cursor-pointer transition-colors"
|
||||||
onclick={handleClick}
|
onclick={handleClick}
|
||||||
>
|
>
|
||||||
<FontApplicator name={font.name} id={font.id} url={font.styles.regular!}>
|
<FontApplicator
|
||||||
{font.name}
|
name={fontListItem.name}
|
||||||
|
id={fontListItem.id}
|
||||||
|
url={getFontUrl(fontListItem, typography.weight) ?? ''}
|
||||||
|
>
|
||||||
|
{fontListItem.name}
|
||||||
</FontApplicator>
|
</FontApplicator>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -110,7 +110,7 @@ function selectFontB(font: UnifiedFont) {
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="absolute bottom-4 sm:bottom-6 md:bottom-8 inset-x-4 sm:inset-x-6 md:inset-x-12 flex justify-between items-end pointer-events-none z-20">
|
<div class="flex justify-between items-end pointer-events-none z-20">
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-1.5 sm:gap-2 transition-all duration-500 items-start"
|
class="flex flex-col gap-1.5 sm:gap-2 transition-all duration-500 items-start"
|
||||||
style:opacity={sliderPos < 20 ? 0 : 1}
|
style:opacity={sliderPos < 20 ? 0 : 1}
|
||||||
@@ -123,14 +123,9 @@ function selectFontB(font: UnifiedFont) {
|
|||||||
ch_01
|
ch_01
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{@render fontSelector(
|
{#if fontB && fontBUrl}
|
||||||
fontB.name,
|
{@render fontSelector(fontB, fontList, fontBUrl, selectFontB, 'start')}
|
||||||
fontB.id,
|
{/if}
|
||||||
fontB.styles.regular!,
|
|
||||||
fontList,
|
|
||||||
selectFontB,
|
|
||||||
'start',
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -145,13 +140,8 @@ function selectFontB(font: UnifiedFont) {
|
|||||||
<div class="w-px h-2 sm:h-2.5 bg-gray-300/60"></div>
|
<div class="w-px h-2 sm:h-2.5 bg-gray-300/60"></div>
|
||||||
<div class="w-1.5 h-1.5 rounded-full bg-gray-900 shadow-[0_0_6px_rgba(0,0,0,0.4)]"></div>
|
<div class="w-1.5 h-1.5 rounded-full bg-gray-900 shadow-[0_0_6px_rgba(0,0,0,0.4)]"></div>
|
||||||
</div>
|
</div>
|
||||||
{@render fontSelector(
|
{#if fontA && fontAUrl}
|
||||||
fontA.name,
|
{@render fontSelector(fontA, fontList, fontAUrl, selectFontA, 'end')}
|
||||||
fontA.id,
|
{/if}
|
||||||
fontA.styles.regular!,
|
|
||||||
fontList,
|
|
||||||
selectFontA,
|
|
||||||
'end',
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<!--
|
||||||
|
Component: TypographyControls
|
||||||
|
Wrapper for the controls of the slider.
|
||||||
|
- Input to change the text
|
||||||
|
- Three combo controls with inputs and sliders for font-weight, font-size, and line-height
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
|
import {
|
||||||
|
ComboControlV2,
|
||||||
|
ExpandableWrapper,
|
||||||
|
Input,
|
||||||
|
} from '$shared/ui';
|
||||||
|
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||||
|
import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
|
||||||
|
import { Spring } from 'svelte/motion';
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Ref
|
||||||
|
*/
|
||||||
|
wrapper?: HTMLDivElement | null;
|
||||||
|
/**
|
||||||
|
* Slider position
|
||||||
|
*/
|
||||||
|
sliderPos: number;
|
||||||
|
/**
|
||||||
|
* Whether slider is being dragged
|
||||||
|
*/
|
||||||
|
isDragging: boolean;
|
||||||
|
/** */
|
||||||
|
isActive?: boolean;
|
||||||
|
/**
|
||||||
|
* Container width
|
||||||
|
*/
|
||||||
|
containerWidth: number;
|
||||||
|
/**
|
||||||
|
* Reduced animations flag
|
||||||
|
*/
|
||||||
|
staticPosition?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
sliderPos,
|
||||||
|
isDragging,
|
||||||
|
isActive = $bindable(false),
|
||||||
|
wrapper = $bindable(null),
|
||||||
|
containerWidth = 0,
|
||||||
|
staticPosition = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const typography = $derived(comparisonStore.typography);
|
||||||
|
const panelWidth = $derived(wrapper?.clientWidth ?? 0);
|
||||||
|
const margin = 24;
|
||||||
|
let side = $state<'left' | 'right'>('left');
|
||||||
|
// Unified active state for the entire wrapper
|
||||||
|
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const xSpring = new Spring(0, {
|
||||||
|
stiffness: 0.14, // Lower is slower
|
||||||
|
damping: 0.5, // Settle
|
||||||
|
});
|
||||||
|
|
||||||
|
const rotateSpring = new Spring(0, {
|
||||||
|
stiffness: 0.12,
|
||||||
|
damping: 0.55,
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleInputFocus() {
|
||||||
|
isActive = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Movement Logic
|
||||||
|
$effect(() => {
|
||||||
|
if (containerWidth === 0 || panelWidth === 0 || staticPosition) return;
|
||||||
|
const sliderX = (sliderPos / 100) * containerWidth;
|
||||||
|
const buffer = 40;
|
||||||
|
const leftTrigger = margin + panelWidth + buffer;
|
||||||
|
const rightTrigger = containerWidth - (margin + panelWidth + buffer);
|
||||||
|
|
||||||
|
if (side === 'left' && sliderX < leftTrigger) {
|
||||||
|
side = 'right';
|
||||||
|
} else if (side === 'right' && sliderX > rightTrigger) {
|
||||||
|
side = 'left';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const targetX = side === 'right' ? containerWidth - panelWidth - margin * 2 : 0;
|
||||||
|
if (containerWidth > 0 && panelWidth > 0) {
|
||||||
|
// On side change set the position and the rotation
|
||||||
|
xSpring.target = targetX;
|
||||||
|
rotateSpring.target = side === 'right' ? 3.5 : -3.5;
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
rotateSpring.target = 0;
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="z-50 will-change-transform"
|
||||||
|
style:transform="
|
||||||
|
translateX({xSpring.current}px)
|
||||||
|
rotateZ({rotateSpring.current}deg)
|
||||||
|
"
|
||||||
|
in:fade={{ duration: 300, delay: 300 }}
|
||||||
|
out:fade={{ duration: 300, delay: 300 }}
|
||||||
|
>
|
||||||
|
{#if staticPosition}
|
||||||
|
<div class="flex flex-col gap-6 px-2 py-4">
|
||||||
|
<Input
|
||||||
|
class="p-6"
|
||||||
|
bind:value={comparisonStore.text}
|
||||||
|
disabled={isDragging}
|
||||||
|
onfocusin={handleInputFocus}
|
||||||
|
placeholder="The quick brown fox..."
|
||||||
|
/>
|
||||||
|
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
|
||||||
|
<div class="flex flex-col justify-between items-center-safe gap-6">
|
||||||
|
<ComboControlV2 control={typography.weightControl} orientation="horizontal" />
|
||||||
|
<ComboControlV2 control={typography.sizeControl} orientation="horizontal" />
|
||||||
|
<ComboControlV2 control={typography.heightControl} orientation="horizontal" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ExpandableWrapper
|
||||||
|
bind:element={wrapper}
|
||||||
|
bind:expanded={isActive}
|
||||||
|
disabled={isDragging}
|
||||||
|
aria-label="Font controls"
|
||||||
|
rotation={side === 'right' ? 'counterclockwise' : 'clockwise'}
|
||||||
|
class={cn(
|
||||||
|
'transition-opacity flex items-top gap-1.5',
|
||||||
|
panelWidth === 0 ? 'opacity-0' : 'opacity-100',
|
||||||
|
)}
|
||||||
|
containerClassName={cn(!isActive && 'p-2 sm:p-0')}
|
||||||
|
>
|
||||||
|
{#snippet badge()}
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'animate-nudge relative transition-all',
|
||||||
|
side === 'left' ? 'order-2' : 'order-0',
|
||||||
|
isActive ? 'opacity-0' : 'opacity-100',
|
||||||
|
isDragging && 'opacity-80 grayscale-[0.2]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AArrowUP class={cn('size-3', 'stroke-indigo-600')} />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet visibleContent()}
|
||||||
|
<div class="relative">
|
||||||
|
<!--
|
||||||
|
<Input
|
||||||
|
bind:value={comparisonStore.text}
|
||||||
|
disabled={isDragging}
|
||||||
|
onfocusin={handleInputFocus}
|
||||||
|
class={cn(
|
||||||
|
isActive
|
||||||
|
? 'h-7 sm:h-8 text-[11px] sm:text-xs text-center bg-white/40 border-none rounded-lg focus-visible:ring-indigo-500/50 text-slate-900'
|
||||||
|
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm sm:text-base font-medium focus-visible:ring-0 text-slate-900/50',
|
||||||
|
' placeholder:text-slate-400 selection:bg-indigo-100 flex-1 transition-all duration-350 w-44 sm:w-56',
|
||||||
|
)}
|
||||||
|
placeholder="The quick brown fox..."
|
||||||
|
/>
|
||||||
|
-->
|
||||||
|
<Input
|
||||||
|
class={cn(
|
||||||
|
'pl-1 sm:pl-3 pr-1 sm:pr-3',
|
||||||
|
'h-6 sm:h-8 md:h-10',
|
||||||
|
'rounded-lg',
|
||||||
|
isActive
|
||||||
|
? 'h-7 sm:h-8 text-[0.825rem]'
|
||||||
|
: 'bg-transparent shadow-none border-none p-0 h-auto text-sm sm:text-base font-medium focus-visible:ring-0 text-slate-900/50',
|
||||||
|
)}
|
||||||
|
bind:value={comparisonStore.text}
|
||||||
|
disabled={isDragging}
|
||||||
|
onfocusin={handleInputFocus}
|
||||||
|
placeholder="The quick brown fox..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet hiddenContent()}
|
||||||
|
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
|
||||||
|
<div class="flex flex-row justify-between items-center-safe gap-2 sm:gap-0">
|
||||||
|
<ComboControlV2 control={typography.weightControl} />
|
||||||
|
<ComboControlV2 control={typography.sizeControl} />
|
||||||
|
<ComboControlV2 control={typography.heightControl} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ExpandableWrapper>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user