feature/responsive #22
@@ -3,6 +3,10 @@ import {
|
||||
fetchFontsByIds,
|
||||
unifiedFontStore,
|
||||
} from '$entities/Font';
|
||||
import {
|
||||
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||||
createTypographyControlManager,
|
||||
} from '$features/SetupFont';
|
||||
import { createPersistentStore } from '$shared/lib';
|
||||
|
||||
/**
|
||||
@@ -30,6 +34,7 @@ class ComparisonStore {
|
||||
#fontB = $state<UnifiedFont | undefined>();
|
||||
#sampleText = $state('The quick brown fox jumps over the lazy dog');
|
||||
#isRestoring = $state(true);
|
||||
#typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography');
|
||||
|
||||
constructor() {
|
||||
this.restoreFromStorage();
|
||||
@@ -102,6 +107,9 @@ class ComparisonStore {
|
||||
}
|
||||
|
||||
// --- Getters & Setters ---
|
||||
get typography() {
|
||||
return this.#typography;
|
||||
}
|
||||
|
||||
get fontA() {
|
||||
return this.#fontA;
|
||||
@@ -149,6 +157,13 @@ class ComparisonStore {
|
||||
this.restoreFromStorage();
|
||||
}
|
||||
}
|
||||
|
||||
resetAll() {
|
||||
this.#fontA = undefined;
|
||||
this.#fontB = undefined;
|
||||
storage.clear();
|
||||
this.#typography.reset();
|
||||
}
|
||||
}
|
||||
|
||||
export const comparisonStore = new ComparisonStore();
|
||||
|
||||
@@ -14,14 +14,17 @@ import {
|
||||
createCharacterComparison,
|
||||
createTypographyControl,
|
||||
} from '$shared/lib';
|
||||
import type { LineData } from '$shared/lib';
|
||||
import type {
|
||||
LineData,
|
||||
ResponsiveManager,
|
||||
} from '$shared/lib';
|
||||
import { Loader } from '$shared/ui';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import { getContext } from 'svelte';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import { fade } from 'svelte/transition';
|
||||
import CharacterSlot from './components/CharacterSlot.svelte';
|
||||
import ControlsWrapper from './components/ControlsWrapper.svelte';
|
||||
import Labels from './components/Labels.svelte';
|
||||
import Controls from './components/Controls.svelte';
|
||||
import SliderLine from './components/SliderLine.svelte';
|
||||
|
||||
// Pair of fonts to compare
|
||||
@@ -30,31 +33,13 @@ const fontB = $derived(comparisonStore.fontB);
|
||||
|
||||
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
||||
|
||||
let container: HTMLElement | undefined = $state();
|
||||
let controlsWrapperElement = $state<HTMLDivElement | null>(null);
|
||||
let measureCanvas: HTMLCanvasElement | undefined = $state();
|
||||
let container = $state<HTMLElement>();
|
||||
let typographyControls = $state<HTMLDivElement | null>(null);
|
||||
let measureCanvas = $state<HTMLCanvasElement>();
|
||||
let isDragging = $state(false);
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
|
||||
const weightControl = createTypographyControl({
|
||||
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,
|
||||
});
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
/**
|
||||
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
||||
@@ -64,8 +49,8 @@ const charComparison = createCharacterComparison(
|
||||
() => comparisonStore.text,
|
||||
() => fontA,
|
||||
() => fontB,
|
||||
() => weightControl.value,
|
||||
() => sizeControl.value,
|
||||
() => typography.weight,
|
||||
() => typography.renderedSize,
|
||||
);
|
||||
|
||||
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
||||
@@ -88,8 +73,8 @@ function handleMove(e: PointerEvent) {
|
||||
|
||||
function startDragging(e: PointerEvent) {
|
||||
if (
|
||||
e.target === controlsWrapperElement
|
||||
|| controlsWrapperElement?.contains(e.target as Node)
|
||||
e.target === typographyControls
|
||||
|| typographyControls?.contains(e.target as Node)
|
||||
) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
@@ -99,6 +84,30 @@ function startDragging(e: PointerEvent) {
|
||||
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(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
@@ -115,9 +124,9 @@ $effect(() => {
|
||||
$effect(() => {
|
||||
// React on text and typography settings changes
|
||||
const _text = comparisonStore.text;
|
||||
const _weight = weightControl.value;
|
||||
const _size = sizeControl.value;
|
||||
const _height = heightControl.value;
|
||||
const _weight = typography.weight;
|
||||
const _size = typography.renderedSize;
|
||||
const _height = typography.height;
|
||||
|
||||
if (container && measureCanvas && fontA && fontB) {
|
||||
// Using rAF to ensure DOM is ready/stabilized
|
||||
@@ -143,8 +152,8 @@ $effect(() => {
|
||||
<div
|
||||
bind:this={lineElements[index]}
|
||||
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
||||
style:height={`${heightControl.value}em`}
|
||||
style:line-height={`${heightControl.value}em`}
|
||||
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)}
|
||||
@@ -158,8 +167,8 @@ $effect(() => {
|
||||
{char}
|
||||
{proximity}
|
||||
{isPast}
|
||||
weight={weightControl.value}
|
||||
size={sizeControl.value}
|
||||
weight={typography.weight}
|
||||
size={typography.renderedSize}
|
||||
fontAName={fontA.name}
|
||||
fontBName={fontB.name}
|
||||
/>
|
||||
@@ -182,12 +191,12 @@ $effect(() => {
|
||||
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-[300px] sm:min-h-[400px] md:min-h-[500px] flex flex-col justify-center
|
||||
backdrop-blur-lg bg-gradient-to-br from-gray-100/70 via-white/50 to-gray-100/60
|
||||
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-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)]
|
||||
before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-[1px]
|
||||
before:bg-gradient-to-br before:from-black/5 before:via-black/2 before:to-transparent
|
||||
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
|
||||
"
|
||||
>
|
||||
@@ -209,7 +218,7 @@ $effect(() => {
|
||||
{#each charComparison.lines as line, lineIndex}
|
||||
<div
|
||||
class="relative w-full whitespace-nowrap"
|
||||
style:height={`${heightControl.value}em`}
|
||||
style:height={`${typography.height}em`}
|
||||
style:display="flex"
|
||||
style:align-items="center"
|
||||
style:justify-content="center"
|
||||
@@ -222,19 +231,6 @@ $effect(() => {
|
||||
<SliderLine {sliderPos} {isDragging} />
|
||||
{/if}
|
||||
</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 -->
|
||||
<ControlsWrapper
|
||||
bind:wrapper={controlsWrapperElement}
|
||||
{sliderPos}
|
||||
{isDragging}
|
||||
bind:text={comparisonStore.text}
|
||||
containerWidth={container?.clientWidth}
|
||||
{weightControl}
|
||||
{sizeControl}
|
||||
{heightControl}
|
||||
/>
|
||||
{/if}
|
||||
<Controls {sliderPos} {isDragging} {typographyControls} {container} />
|
||||
</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
|
||||
Displays labels for font selection in the comparison slider.
|
||||
Component: SelectComparedFonts
|
||||
Displays selects that change the compared fonts
|
||||
-->
|
||||
<script lang="ts" generics="T extends UnifiedFont">
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontVirtualList,
|
||||
type UnifiedFont,
|
||||
unifiedFontStore,
|
||||
} from '$entities/Font';
|
||||
import { getFontUrl } from '$entities/Font/lib';
|
||||
import FontApplicator from '$entities/Font/ui/FontApplicator/FontApplicator.svelte';
|
||||
import {
|
||||
Content as SelectContent,
|
||||
@@ -19,23 +20,19 @@ import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props<T> {
|
||||
/**
|
||||
* First font to compare
|
||||
*/
|
||||
fontA: T;
|
||||
/**
|
||||
* Second font to compare
|
||||
*/
|
||||
fontB: T;
|
||||
interface Props {
|
||||
/**
|
||||
* Position of the slider
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -51,11 +48,10 @@ function selectFontB(font: UnifiedFont) {
|
||||
</script>
|
||||
|
||||
{#snippet fontSelector(
|
||||
name: string,
|
||||
id: string,
|
||||
url: string,
|
||||
font: UnifiedFont,
|
||||
fonts: UnifiedFont[],
|
||||
selectFont: (font: UnifiedFont) => void,
|
||||
url: string,
|
||||
onSelect: (f: UnifiedFont) => void,
|
||||
align: 'start' | 'end',
|
||||
)}
|
||||
<div
|
||||
@@ -74,15 +70,15 @@ function selectFontB(font: UnifiedFont) {
|
||||
)}
|
||||
>
|
||||
<div class="text-left flex-1 min-w-0">
|
||||
<FontApplicator {name} {id} {url}>
|
||||
{name}
|
||||
<FontApplicator name={font.name} id={font.id} {url}>
|
||||
{font.name}
|
||||
</FontApplicator>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
class={cn(
|
||||
'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"
|
||||
{align}
|
||||
@@ -90,16 +86,20 @@ function selectFontB(font: UnifiedFont) {
|
||||
size="small"
|
||||
>
|
||||
<div class="p-1 sm:p-1.5">
|
||||
<FontVirtualList items={fonts} {weight}>
|
||||
{#snippet children({ item: font })}
|
||||
{@const handleClick = () => selectFont(font)}
|
||||
<FontVirtualList items={fonts} weight={typography.weight}>
|
||||
{#snippet children({ item: fontListItem })}
|
||||
{@const handleClick = () => onSelect(fontListItem)}
|
||||
<SelectItem
|
||||
value={font.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"
|
||||
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"
|
||||
onclick={handleClick}
|
||||
>
|
||||
<FontApplicator name={font.name} id={font.id} url={font.styles.regular!}>
|
||||
{font.name}
|
||||
<FontApplicator
|
||||
name={fontListItem.name}
|
||||
id={fontListItem.id}
|
||||
url={getFontUrl(fontListItem, typography.weight) ?? ''}
|
||||
>
|
||||
{fontListItem.name}
|
||||
</FontApplicator>
|
||||
</SelectItem>
|
||||
{/snippet}
|
||||
@@ -110,7 +110,7 @@ function selectFontB(font: UnifiedFont) {
|
||||
</div>
|
||||
{/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
|
||||
class="flex flex-col gap-1.5 sm:gap-2 transition-all duration-500 items-start"
|
||||
style:opacity={sliderPos < 20 ? 0 : 1}
|
||||
@@ -123,14 +123,9 @@ function selectFontB(font: UnifiedFont) {
|
||||
ch_01
|
||||
</span>
|
||||
</div>
|
||||
{@render fontSelector(
|
||||
fontB.name,
|
||||
fontB.id,
|
||||
fontB.styles.regular!,
|
||||
fontList,
|
||||
selectFontB,
|
||||
'start',
|
||||
)}
|
||||
{#if fontB && fontBUrl}
|
||||
{@render fontSelector(fontB, fontList, fontBUrl, selectFontB, 'start')}
|
||||
{/if}
|
||||
</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-1.5 h-1.5 rounded-full bg-gray-900 shadow-[0_0_6px_rgba(0,0,0,0.4)]"></div>
|
||||
</div>
|
||||
{@render fontSelector(
|
||||
fontA.name,
|
||||
fontA.id,
|
||||
fontA.styles.regular!,
|
||||
fontList,
|
||||
selectFontA,
|
||||
'end',
|
||||
)}
|
||||
{#if fontA && fontAUrl}
|
||||
{@render fontSelector(fontA, fontList, fontAUrl, selectFontA, 'end')}
|
||||
{/if}
|
||||
</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