Compare commits
18 Commits
2f45dc3620
...
940e20515b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
940e20515b | ||
|
|
f15114a78b | ||
|
|
6ba37c9e4a | ||
|
|
858daff860 | ||
|
|
b7f54b503c | ||
|
|
17de544bdb | ||
|
|
a0ac52a348 | ||
|
|
99966d2de9 | ||
|
|
72334a3d05 | ||
|
|
8780b6932c | ||
|
|
5d2c05e192 | ||
|
|
1031b96ec5 | ||
|
|
4fdc99a15a | ||
|
|
9e74a2c2c6 | ||
|
|
aa3f467821 | ||
|
|
6001f50cf5 | ||
|
|
c2d0992015 | ||
|
|
bc56265717 |
@@ -3,7 +3,7 @@
|
||||
- Renders a virtualized list of fonts
|
||||
- Handles font registration with the manager
|
||||
-->
|
||||
<script lang="ts" generics="T extends UnifiedFont">
|
||||
<script lang="ts">
|
||||
import {
|
||||
Skeleton,
|
||||
VirtualList,
|
||||
@@ -11,26 +11,23 @@ import {
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { getFontUrl } from '../../lib';
|
||||
import type { FontConfigRequest } from '../../model';
|
||||
import {
|
||||
type FontConfigRequest,
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
unifiedFontStore,
|
||||
} from '../../model';
|
||||
|
||||
interface Props extends
|
||||
Omit<
|
||||
ComponentProps<typeof VirtualList<T>>,
|
||||
'onVisibleItemsChange'
|
||||
ComponentProps<typeof VirtualList<UnifiedFont>>,
|
||||
'items' | 'total' | 'isLoading' | 'onVisibleItemsChange' | 'onNearBottom'
|
||||
>
|
||||
{
|
||||
/**
|
||||
* Callback for when visible items change
|
||||
*/
|
||||
onVisibleItemsChange?: (items: T[]) => void;
|
||||
/**
|
||||
* Callback for when near bottom is reached
|
||||
*/
|
||||
onNearBottom?: (lastVisibleIndex: number) => void;
|
||||
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
|
||||
/**
|
||||
* Weight of the font
|
||||
*/
|
||||
@@ -38,23 +35,20 @@ interface Props extends
|
||||
* Weight of the font
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Whether the list is in a loading state
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
children,
|
||||
onVisibleItemsChange,
|
||||
onNearBottom,
|
||||
weight,
|
||||
isLoading = false,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
function handleInternalVisibleChange(visibleItems: T[]) {
|
||||
const isLoading = $derived(
|
||||
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
|
||||
);
|
||||
|
||||
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||
const configs: FontConfigRequest[] = [];
|
||||
|
||||
visibleItems.forEach(item => {
|
||||
@@ -77,9 +71,32 @@ function handleInternalVisibleChange(visibleItems: T[]) {
|
||||
// onVisibleItemsChange?.(visibleItems);
|
||||
}
|
||||
|
||||
function handleNearBottom(lastVisibleIndex: number) {
|
||||
// Forward the call to any external listener
|
||||
onNearBottom?.(lastVisibleIndex);
|
||||
/**
|
||||
* Load more fonts by moving to the next page
|
||||
*/
|
||||
function loadMore() {
|
||||
if (
|
||||
!unifiedFontStore.pagination.hasMore
|
||||
|| unifiedFontStore.isFetching
|
||||
) {
|
||||
return;
|
||||
}
|
||||
unifiedFontStore.nextPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll near bottom - auto-load next page
|
||||
*
|
||||
* Triggered by VirtualList when the user scrolls within 5 items of the end
|
||||
* of the loaded items. Only fetches if there are more pages available.
|
||||
*/
|
||||
function handleNearBottom(_lastVisibleIndex: number) {
|
||||
const { hasMore } = unifiedFontStore.pagination;
|
||||
|
||||
// VirtualList already checks if we're near the bottom of loaded items
|
||||
if (hasMore && !unifiedFontStore.isFetching) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -99,10 +116,11 @@ function handleNearBottom(lastVisibleIndex: number) {
|
||||
</div>
|
||||
{:else}
|
||||
<VirtualList
|
||||
{items}
|
||||
{...rest}
|
||||
items={unifiedFontStore.fonts}
|
||||
total={unifiedFontStore.pagination.total}
|
||||
onVisibleItemsChange={handleInternalVisibleChange}
|
||||
onNearBottom={handleNearBottom}
|
||||
{...rest}
|
||||
>
|
||||
{#snippet children(scope)}
|
||||
{@render children(scope)}
|
||||
|
||||
@@ -276,3 +276,5 @@ export function createCharacterComparison<
|
||||
getCharState,
|
||||
};
|
||||
}
|
||||
|
||||
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Spring } from 'svelte/motion';
|
||||
|
||||
export interface PerspectiveConfig {
|
||||
/**
|
||||
* How many px to move back per level
|
||||
*/
|
||||
depthStep?: number;
|
||||
/**
|
||||
* Scale reduction per level
|
||||
*/
|
||||
scaleStep?: number;
|
||||
/**
|
||||
* Blur amount per level
|
||||
*/
|
||||
blurStep?: number;
|
||||
/**
|
||||
* Opacity reduction per level
|
||||
*/
|
||||
opacityStep?: number;
|
||||
/**
|
||||
* Parallax intensity per level
|
||||
*/
|
||||
parallaxIntensity?: number;
|
||||
/**
|
||||
* Horizontal offset for each plan (x-axis positioning)
|
||||
* Positive = right, Negative = left
|
||||
*/
|
||||
horizontalOffset?: number;
|
||||
/**
|
||||
* Layout mode: 'center' (default) or 'split' for Swiss-style side-by-side
|
||||
*/
|
||||
layoutMode?: 'center' | 'split';
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages perspective state with a simple boolean flag.
|
||||
*
|
||||
* Drastically simplified from the complex camera/index system.
|
||||
* Just manages whether content is in "back" or "front" state.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const perspective = createPerspectiveManager({
|
||||
* depthStep: 100,
|
||||
* scaleStep: 0.5,
|
||||
* blurStep: 4,
|
||||
* });
|
||||
*
|
||||
* // Toggle back/front
|
||||
* perspective.toggle();
|
||||
*
|
||||
* // Check state
|
||||
* const isBack = perspective.isBack; // reactive boolean
|
||||
* ```
|
||||
*/
|
||||
export class PerspectiveManager {
|
||||
/**
|
||||
* Spring for smooth back/front transitions
|
||||
*/
|
||||
spring = new Spring(0, {
|
||||
stiffness: 0.2,
|
||||
damping: 0.8,
|
||||
});
|
||||
|
||||
/**
|
||||
* Reactive boolean: true when in back position (blurred, scaled down)
|
||||
*/
|
||||
isBack = $derived(this.spring.current > 0.5);
|
||||
|
||||
/**
|
||||
* Reactive boolean: true when in front position (fully visible, interactive)
|
||||
*/
|
||||
isFront = $derived(this.spring.current < 0.5);
|
||||
|
||||
/**
|
||||
* Configuration values for style computation
|
||||
*/
|
||||
private config: Required<PerspectiveConfig>;
|
||||
|
||||
constructor(config: PerspectiveConfig = {}) {
|
||||
this.config = {
|
||||
depthStep: config.depthStep ?? 100,
|
||||
scaleStep: config.scaleStep ?? 0.5,
|
||||
blurStep: config.blurStep ?? 4,
|
||||
opacityStep: config.opacityStep ?? 0.5,
|
||||
parallaxIntensity: config.parallaxIntensity ?? 0,
|
||||
horizontalOffset: config.horizontalOffset ?? 0,
|
||||
layoutMode: config.layoutMode ?? 'center',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between front (0) and back (1) positions.
|
||||
* Smooth spring animation handles the transition.
|
||||
*/
|
||||
toggle = () => {
|
||||
const target = this.spring.current < 0.5 ? 1 : 0;
|
||||
this.spring.target = target;
|
||||
};
|
||||
|
||||
/**
|
||||
* Force to back position
|
||||
*/
|
||||
setBack = () => {
|
||||
this.spring.target = 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Force to front position
|
||||
*/
|
||||
setFront = () => {
|
||||
this.spring.target = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get configuration for style computation
|
||||
* @internal
|
||||
*/
|
||||
getConfig = () => this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a PerspectiveManager instance.
|
||||
*
|
||||
* @param config - Configuration options
|
||||
* @returns Configured PerspectiveManager instance
|
||||
*/
|
||||
export function createPerspectiveManager(config: PerspectiveConfig = {}) {
|
||||
return new PerspectiveManager(config);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
} from './createEntityStore/createEntityStore.svelte';
|
||||
|
||||
export {
|
||||
type CharacterComparison,
|
||||
createCharacterComparison,
|
||||
type LineData,
|
||||
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||
@@ -48,3 +49,8 @@ export {
|
||||
getLenisContext,
|
||||
setLenisContext,
|
||||
} from './createScrollContext/createScrollContext.svelte';
|
||||
|
||||
export {
|
||||
createPerspectiveManager,
|
||||
type PerspectiveManager,
|
||||
} from './createPerspectiveManager/createPerspectiveManager.svelte';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
type CharacterComparison,
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
createCharacterComparison,
|
||||
@@ -7,6 +8,7 @@ export {
|
||||
createFilter,
|
||||
createLenisContext,
|
||||
createPersistentStore,
|
||||
createPerspectiveManager,
|
||||
createResponsiveManager,
|
||||
createTypographyControl,
|
||||
createVirtualizer,
|
||||
@@ -17,6 +19,7 @@ export {
|
||||
getLenisContext,
|
||||
type LineData,
|
||||
type PersistentStore,
|
||||
type PerspectiveManager,
|
||||
type Property,
|
||||
type ResponsiveManager,
|
||||
responsiveManager,
|
||||
|
||||
@@ -150,16 +150,18 @@ function calculateScale(index: number): number | string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
class="h-10 rounded-lg w-12 pl-1 pr-1 sm:pr-1 md:pr-1 sm:pl-1 md:pl-1 text-center"
|
||||
value={inputValue}
|
||||
onchange={handleInputChange}
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
variant="ghost"
|
||||
/>
|
||||
{#if !reduced}
|
||||
<Input
|
||||
class="h-10 rounded-lg w-12 pl-1 pr-1 sm:pr-1 md:pr-1 sm:pl-1 md:pl-1 text-center"
|
||||
value={inputValue}
|
||||
onchange={handleInputChange}
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
variant="ghost"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if label}
|
||||
<div class="flex items-center gap-2 opacity-70">
|
||||
|
||||
@@ -43,8 +43,10 @@ let { rotation = 'clockwise', icon, ...rest }: Props = $props();
|
||||
>
|
||||
{@render icon({
|
||||
className: cn(
|
||||
'size-4 transition-all duration-200 stroke-[1.5] stroke-text-muted group-hover:stroke-foreground group-hover:scale-110 group-hover:stroke-3 group-active:scale-90 group-disabled:stroke-transparent',
|
||||
rotation === 'clockwise' ? 'group-active:rotate-6' : 'group-active:-rotate-6',
|
||||
'size-4 transition-all duration-200 stroke-[1.5] stroke-text-muted group-hover:stroke-foreground group-hover:scale-110 group-hover:stroke-2 group-active:scale-90 group-disabled:stroke-transparent',
|
||||
rotation === 'clockwise'
|
||||
? 'group-active:rotate-6'
|
||||
: 'group-active:-rotate-6',
|
||||
),
|
||||
})}
|
||||
</Button>
|
||||
|
||||
@@ -8,10 +8,11 @@ const { Story } = defineMeta({
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Styles Input component',
|
||||
component: 'Styled input component with size and variant options',
|
||||
},
|
||||
story: { inline: false }, // Render stories in iframe for state isolation
|
||||
},
|
||||
layout: 'centered',
|
||||
},
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
@@ -22,21 +23,76 @@ const { Story } = defineMeta({
|
||||
control: 'text',
|
||||
description: "input's value",
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['default', 'ghost'],
|
||||
description: 'Visual style variant',
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg'],
|
||||
description: 'Size variant',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let value = $state('Initial value');
|
||||
let valueDefault = $state('Initial value');
|
||||
let valueSm = $state('');
|
||||
let valueMd = $state('');
|
||||
let valueLg = $state('');
|
||||
let valueGhostSm = $state('');
|
||||
let valueGhostMd = $state('');
|
||||
let valueGhostLg = $state('');
|
||||
const placeholder = 'Enter text';
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args={{
|
||||
placeholder,
|
||||
value,
|
||||
}}
|
||||
>
|
||||
<Input value={value} placeholder={placeholder} />
|
||||
<!-- Default Story -->
|
||||
<Story name="Default" args={{ placeholder }}>
|
||||
<Input bind:value={valueDefault} {placeholder} />
|
||||
</Story>
|
||||
|
||||
<!-- Size Variants -->
|
||||
<Story name="Small" args={{ placeholder }}>
|
||||
<Input bind:value={valueSm} {placeholder} size="sm" />
|
||||
</Story>
|
||||
|
||||
<Story name="Medium" args={{ placeholder }}>
|
||||
<Input bind:value={valueMd} {placeholder} size="md" />
|
||||
</Story>
|
||||
|
||||
<Story name="Large" args={{ placeholder }}>
|
||||
<Input bind:value={valueLg} {placeholder} size="lg" />
|
||||
</Story>
|
||||
|
||||
<!-- Ghost Variant with Sizes -->
|
||||
<Story name="Ghost Small" args={{ placeholder }}>
|
||||
<Input bind:value={valueGhostSm} {placeholder} variant="ghost" size="sm" />
|
||||
</Story>
|
||||
|
||||
<Story name="Ghost Medium" args={{ placeholder }}>
|
||||
<Input bind:value={valueGhostMd} {placeholder} variant="ghost" size="md" />
|
||||
</Story>
|
||||
|
||||
<Story name="Ghost Large" args={{ placeholder }}>
|
||||
<Input bind:value={valueGhostLg} {placeholder} variant="ghost" size="lg" />
|
||||
</Story>
|
||||
|
||||
<!-- Size Comparison -->
|
||||
<Story name="All Sizes" tags={['!autodocs']}>
|
||||
<div class="flex flex-col gap-4 w-full max-w-md p-8">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-text-muted">Small</span>
|
||||
<Input placeholder="Small input" size="sm" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-text-muted">Medium</span>
|
||||
<Input placeholder="Medium input" size="md" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-text-muted">Large</span>
|
||||
<Input placeholder="Large input" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
@@ -2,60 +2,89 @@
|
||||
Component: Input
|
||||
Provides styled input component with all the shadcn input props
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
<script lang="ts" module>
|
||||
import { Input as BaseInput } from '$shared/shadcn/ui/input';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import {
|
||||
type VariantProps,
|
||||
tv,
|
||||
} from 'tailwind-variants';
|
||||
|
||||
type Props = ComponentProps<typeof Input> & {
|
||||
export const inputVariants = tv({
|
||||
base: [
|
||||
'w-full backdrop-blur-md border font-medium transition-all duration-200',
|
||||
'focus-visible:border-border-soft focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-muted/30 focus-visible:bg-background-95',
|
||||
'hover:bg-background-95 hover:border-border-soft',
|
||||
'text-foreground placeholder:text-text-muted placeholder:font-mono placeholder:tracking-wide',
|
||||
],
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background-80 border-border-muted shadow-[0_1px_3px_rgba(0,0,0,0.04)]',
|
||||
ghost: 'bg-transparent border-transparent shadow-none',
|
||||
},
|
||||
size: {
|
||||
sm: [
|
||||
'h-9 sm:h-10 md:h-11 rounded-lg',
|
||||
'px-3 sm:px-3.5 md:px-4',
|
||||
'text-xs sm:text-sm md:text-base',
|
||||
'placeholder:text-xs sm:placeholder:text-sm md:placeholder:text-base',
|
||||
],
|
||||
md: [
|
||||
'h-10 sm:h-12 md:h-14 rounded-xl',
|
||||
'px-3.5 sm:px-4 md:px-5',
|
||||
'text-sm sm:text-base md:text-lg',
|
||||
'placeholder:text-xs sm:placeholder:text-sm md:placeholder:text-base',
|
||||
],
|
||||
lg: [
|
||||
'h-12 sm:h-14 md:h-16 rounded-2xl',
|
||||
'px-4 sm:px-5 md:px-6',
|
||||
'text-sm sm:text-base md:text-lg',
|
||||
'placeholder:text-xs sm:placeholder:text-sm md:placeholder:text-base',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'lg',
|
||||
},
|
||||
});
|
||||
|
||||
type InputVariant = VariantProps<typeof inputVariants>['variant'];
|
||||
type InputSize = VariantProps<typeof inputVariants>['size'];
|
||||
|
||||
export type InputProps = {
|
||||
/**
|
||||
* Current search value (bindable)
|
||||
*/
|
||||
value: string;
|
||||
value?: string;
|
||||
/**
|
||||
* Additional CSS classes for the container
|
||||
*/
|
||||
class?: string;
|
||||
|
||||
variant?: 'default' | 'ghost';
|
||||
/**
|
||||
* Visual style variant
|
||||
*/
|
||||
variant?: InputVariant;
|
||||
/**
|
||||
* Size variant
|
||||
*/
|
||||
size?: InputSize;
|
||||
[key: string]: any;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
value = $bindable(''),
|
||||
class: className,
|
||||
variant = 'default',
|
||||
size = 'lg',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const isGhost = $derived(variant === 'ghost');
|
||||
}: InputProps = $props();
|
||||
</script>
|
||||
|
||||
<Input
|
||||
<BaseInput
|
||||
bind:value={value}
|
||||
class={cn(
|
||||
'h-12 sm:h-14 md:h-16 w-full text-sm sm:text-base',
|
||||
'backdrop-blur-md',
|
||||
isGhost ? 'bg-transparent' : 'bg-background-80',
|
||||
'border border-border-muted',
|
||||
isGhost ? 'border-transparent' : 'border-border-muted',
|
||||
isGhost ? 'shadow-none' : 'shadow-[0_1px_3px_rgba(0,0,0,0.04)]',
|
||||
'focus-visible:border-border-soft',
|
||||
'focus-visible:outline-none',
|
||||
'focus-visible:ring-1',
|
||||
'focus-visible:ring-border-muted/30',
|
||||
'focus-visible:bg-background-95',
|
||||
'hover:bg-background-95',
|
||||
'hover:border-border-soft',
|
||||
'text-foreground',
|
||||
'placeholder:text-text-muted',
|
||||
'placeholder:font-mono',
|
||||
'placeholder:text-xs sm:placeholder:text-sm',
|
||||
'placeholder:tracking-wide',
|
||||
'pl-4 sm:pl-6 pr-4 sm:pr-6',
|
||||
'rounded-xl',
|
||||
'transition-all duration-200',
|
||||
'font-medium',
|
||||
className,
|
||||
)}
|
||||
class={cn(inputVariants({ variant, size }), className)}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
83
src/shared/ui/PerspectivePlan/PerspectivePlan.svelte
Normal file
83
src/shared/ui/PerspectivePlan/PerspectivePlan.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<!--
|
||||
Component: PerspectivePlan
|
||||
Wrapper that applies perspective transformations based on back/front state.
|
||||
Style computation moved from manager to component for simpler architecture.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { PerspectiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { type Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Perspective manager
|
||||
*/
|
||||
manager: PerspectiveManager;
|
||||
/**
|
||||
* Additional classes
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Children
|
||||
*/
|
||||
children: Snippet<[{ className?: string }]>;
|
||||
/**
|
||||
* Constrain plan to a horizontal region
|
||||
* 'left' | 'right' | 'full' (default)
|
||||
*/
|
||||
region?: 'left' | 'right' | 'full';
|
||||
/**
|
||||
* Width percentage when using left/right region (default 50)
|
||||
*/
|
||||
regionWidth?: number;
|
||||
}
|
||||
|
||||
let { manager, children, class: className = '', region = 'full', regionWidth = 50 }: Props = $props();
|
||||
|
||||
const config = $derived(manager.getConfig());
|
||||
|
||||
// Computed style based on spring position (0 = front, 1 = back)
|
||||
const style = $derived.by(() => {
|
||||
const distance = manager.spring.current;
|
||||
const baseX = config.horizontalOffset ?? 0;
|
||||
|
||||
// Back state: blurred, scaled down, pushed back
|
||||
// Front state: fully visible, in focus
|
||||
const scale = 1 - distance * (config.scaleStep ?? 0.5);
|
||||
const blur = distance * (config.blurStep ?? 4);
|
||||
const opacity = Math.max(0, 1 - distance * (config.opacityStep ?? 0.5));
|
||||
const zIndex = 10;
|
||||
const pointerEvents = distance < 0.4 ? 'auto' : ('none' as const);
|
||||
|
||||
return {
|
||||
transform: `translate3d(${baseX}px, 0px, ${-distance * (config.depthStep ?? 100)}px) scale(${scale})`,
|
||||
filter: `blur(${blur}px)`,
|
||||
opacity,
|
||||
pointerEvents,
|
||||
zIndex,
|
||||
};
|
||||
});
|
||||
|
||||
// Calculate horizontal constraints based on region
|
||||
const regionStyleStr = $derived(() => {
|
||||
if (region === 'full') return '';
|
||||
const side = region === 'left' ? 'left' : 'right';
|
||||
return `position: absolute; ${side}: 0; width: ${regionWidth}%; top: 0; bottom: 0;`;
|
||||
});
|
||||
|
||||
// Visibility: front = visible, back = hidden
|
||||
const isVisible = $derived(manager.isFront);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('will-change-transform', className)}
|
||||
style:transform-style="preserve-3d"
|
||||
style:transform={style?.transform}
|
||||
style:filter={style?.filter}
|
||||
style:opacity={style?.opacity}
|
||||
style:pointer-events={style?.pointerEvents}
|
||||
style:z-index={style?.zIndex}
|
||||
style:custom={regionStyleStr()}
|
||||
>
|
||||
{@render children({ className: isVisible ? 'visible' : 'hidden' })}
|
||||
</div>
|
||||
99
src/shared/ui/SidebarMenu/SidebarMenu.svelte
Normal file
99
src/shared/ui/SidebarMenu/SidebarMenu.svelte
Normal file
@@ -0,0 +1,99 @@
|
||||
<!--
|
||||
Component: SidebarMenu
|
||||
Slides out from the right, closes on click outside
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import {
|
||||
fade,
|
||||
slide,
|
||||
} from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Children to render conditionally
|
||||
*/
|
||||
children?: Snippet;
|
||||
/**
|
||||
* Action (always visible) to render
|
||||
*/
|
||||
action?: Snippet;
|
||||
/**
|
||||
* Wrapper reference to bind
|
||||
*/
|
||||
wrapper?: HTMLElement | null;
|
||||
/**
|
||||
* Class to add to the wrapper
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Bindable visibility flag
|
||||
*/
|
||||
visible?: boolean;
|
||||
/**
|
||||
* Handler for click outside
|
||||
*/
|
||||
onClickOutside?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
action,
|
||||
wrapper = $bindable<HTMLElement | null>(null),
|
||||
class: className,
|
||||
visible = $bindable(false),
|
||||
onClickOutside,
|
||||
}: Props = $props();
|
||||
|
||||
/**
|
||||
* Closes menu on click outside
|
||||
*/
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (!wrapper || !visible) {
|
||||
return;
|
||||
}
|
||||
if (!wrapper.contains(event.target as Node)) {
|
||||
visible = false;
|
||||
onClickOutside?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={handleClick} />
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'transition-all duration-300 delay-200 cubic-bezier-out',
|
||||
className,
|
||||
)}
|
||||
bind:this={wrapper}
|
||||
>
|
||||
{@render action?.()}
|
||||
{#if visible}
|
||||
<div
|
||||
class="relative z-20 h-full w-auto flex flex-col gap-4"
|
||||
in:fade={{ duration: 300, delay: 400, easing: cubicOut }}
|
||||
out:fade={{ duration: 150, easing: cubicOut }}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<!-- Background Gradient -->
|
||||
<div
|
||||
class="
|
||||
absolute inset-0 z-10 h-full transition-all duration-700
|
||||
bg-linear-to-r from-white/75 via-white/45 to-white/10
|
||||
bg-[radial-gradient(ellipse_at_left,_rgba(255,252,245,0.4)_0%,_transparent_70%)]
|
||||
shadow-[_inset_-1px_0_0_rgba(0,0,0,0.04)]
|
||||
border-r border-white/90
|
||||
after:absolute after:right-[-1px] after:top-0 after:h-full after:w-[1px] after:bg-black/[0.05]
|
||||
backdrop-blur-md
|
||||
"
|
||||
in:slide={{ axis: 'x', duration: 250, delay: 100, easing: cubicOut }}
|
||||
out:slide={{ axis: 'x', duration: 150, easing: cubicOut }}
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -54,7 +54,8 @@ let { value = $bindable(), orientation = 'horizontal', class: className, ...rest
|
||||
index={0}
|
||||
class={cn(
|
||||
'group/thumb relative block',
|
||||
orientation === 'horizontal' ? '-top-1 w-2 h-2.25' : '-left-1 h-2 w-2.25',
|
||||
'size-2',
|
||||
orientation === 'horizontal' ? '-top-1' : '-left-1',
|
||||
'rounded-sm',
|
||||
'bg-foreground',
|
||||
// Glow shadow
|
||||
@@ -64,7 +65,7 @@ let { value = $bindable(), orientation = 'horizontal', class: className, ...rest
|
||||
orientation === 'horizontal' ? 'transition-[height,top,left,box-shadow]' : 'transition-[width,top,left,box-shadow]',
|
||||
// Hover: bigger glow
|
||||
'hover:shadow-[0_0_10px_rgba(0,0,0,0.5)]',
|
||||
orientation === 'horizontal' ? 'hover:h-3 hover:-top-[5.5px]' : 'hover:w-3 hover:-left-[5.5px]',
|
||||
orientation === 'horizontal' ? 'hover:size-3 hover:-top-[5.5px]' : 'hover:size-3 hover:-left-[5.5px]',
|
||||
// Active: smaller glow
|
||||
'active:shadow-[0_0_4px_rgba(0,0,0,0.3)]',
|
||||
orientation === 'horizontal' ? 'active:h-2.5 active:-top-[4.5px]' : 'active:w-2.5 active:-left-[4.5px]',
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import Input from './Input/Input.svelte';
|
||||
|
||||
export { default as CheckboxFilter } from './CheckboxFilter/CheckboxFilter.svelte';
|
||||
export { default as ComboControl } from './ComboControl/ComboControl.svelte';
|
||||
export { default as ComboControlV2 } from './ComboControlV2/ComboControlV2.svelte';
|
||||
@@ -6,12 +9,25 @@ export { default as Drawer } from './Drawer/Drawer.svelte';
|
||||
export { default as ExpandableWrapper } from './ExpandableWrapper/ExpandableWrapper.svelte';
|
||||
export { default as Footnote } from './Footnote/Footnote.svelte';
|
||||
export { default as IconButton } from './IconButton/IconButton.svelte';
|
||||
export { default as Input } from './Input/Input.svelte';
|
||||
|
||||
export { default as Loader } from './Loader/Loader.svelte';
|
||||
export { default as Logo } from './Logo/Logo.svelte';
|
||||
export { default as PerspectivePlan } from './PerspectivePlan/PerspectivePlan.svelte';
|
||||
export { default as SearchBar } from './SearchBar/SearchBar.svelte';
|
||||
export { default as Section } from './Section/Section.svelte';
|
||||
export { default as SidebarMenu } from './SidebarMenu/SidebarMenu.svelte';
|
||||
export { default as Skeleton } from './Skeleton/Skeleton.svelte';
|
||||
export { default as Slider } from './Slider/Slider.svelte';
|
||||
export { default as SmoothScroll } from './SmoothScroll/SmoothScroll.svelte';
|
||||
export { default as VirtualList } from './VirtualList/VirtualList.svelte';
|
||||
|
||||
type InputProps = ComponentProps<typeof Input>;
|
||||
type InputSize = InputProps['size'];
|
||||
type InputVariant = InputProps['variant'];
|
||||
|
||||
export {
|
||||
Input,
|
||||
type InputProps,
|
||||
type InputSize,
|
||||
type InputVariant,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
<!--
|
||||
Component: Controls
|
||||
Uses SidebarMenu to show ComparisonSlider's controls:
|
||||
- List of fonts to pick
|
||||
- Input to change text
|
||||
- Sliders for font-weight, font-width, line-height
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { appliedFontsManager } from '$entities/Font';
|
||||
import { getFontUrl } from '$entities/Font/lib';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
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 { SidebarMenu } from '$shared/ui';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import ComparisonList from './FontList.svelte';
|
||||
import ToggleMenuButton from './ToggleMenuButton.svelte';
|
||||
import TypographyControls from './TypographyControls.svelte';
|
||||
|
||||
interface Props {
|
||||
sliderPos: number;
|
||||
isDragging: boolean;
|
||||
typographyControls?: HTMLDivElement | null;
|
||||
container: HTMLElement;
|
||||
/**
|
||||
* Additional class
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Handler to trigger when menu opens/closes
|
||||
*/
|
||||
handleToggle?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
sliderPos,
|
||||
isDragging,
|
||||
typographyControls = $bindable<HTMLDivElement | null>(null),
|
||||
container,
|
||||
}: Props = $props();
|
||||
let { class: className, handleToggle }: Props = $props();
|
||||
|
||||
let visible = $state(false);
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
|
||||
const weight = $derived(comparisonStore.typography.weight);
|
||||
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
let menuWrapper = $state<HTMLElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!fontA || !fontB) {
|
||||
return;
|
||||
}
|
||||
|
||||
const weight = typography.weight;
|
||||
const fontAUrl = getFontUrl(fontA, weight);
|
||||
const fontBUrl = getFontUrl(fontB, weight);
|
||||
|
||||
@@ -63,41 +64,28 @@ $effect(() => {
|
||||
});
|
||||
</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, className })}
|
||||
<div class={cn(className, 'flex flex-col gap-6')}>
|
||||
<SelectComparedFonts {sliderPos} />
|
||||
<TypographyControls
|
||||
{sliderPos}
|
||||
{isDragging}
|
||||
isActive={isOpen}
|
||||
bind:wrapper={typographyControls}
|
||||
containerWidth={container?.clientWidth}
|
||||
staticPosition
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Drawer>
|
||||
{:else}
|
||||
<div class="absolute top-3 sm:top-6 left-3 sm:left-6 z-50">
|
||||
<TypographyControls
|
||||
{sliderPos}
|
||||
{isDragging}
|
||||
bind:wrapper={typographyControls}
|
||||
containerWidth={container?.clientWidth}
|
||||
/>
|
||||
<SidebarMenu
|
||||
class={cn(
|
||||
'w-96 flex flex-col h-full pl-4 lg:pl-6 py-4 sm:py-6 sm:pt-12 gap-4 sm:gap-6 pointer-events-auto overflow-hidden',
|
||||
'relative h-full transition-all duration-700 ease-out',
|
||||
className,
|
||||
)}
|
||||
bind:visible
|
||||
bind:wrapper={menuWrapper}
|
||||
onClickOutside={handleToggle}
|
||||
>
|
||||
{#snippet action()}
|
||||
<!-- Always-visible mode switch -->
|
||||
<div class={cn('absolute top-4 left-0 z-50', visible && 'w-full')}>
|
||||
<ToggleMenuButton bind:isActive={visible} onClick={handleToggle} />
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="h-2/3 overflow-hidden">
|
||||
<ComparisonList />
|
||||
</div>
|
||||
|
||||
<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 class="relative flex w-auto border-b border-gray-400/50 px-2 ml-4 mr-8 lg:mr-10"></div>
|
||||
<div class="mr-4 sm:mr-6">
|
||||
<TypographyControls />
|
||||
</div>
|
||||
{/if}
|
||||
</SidebarMenu>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<!--
|
||||
Component: FontList
|
||||
A scrollable list of fonts with dual selection buttons for fontA and fontB.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
FontVirtualList,
|
||||
type UnifiedFont,
|
||||
} from '$entities/Font';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { draw } from 'svelte/transition';
|
||||
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
|
||||
/**
|
||||
* Select a font as fontA (right slot - compare_to)
|
||||
*/
|
||||
function selectFontA(font: UnifiedFont) {
|
||||
comparisonStore.fontA = font;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a font as fontB (left slot - compare_from)
|
||||
*/
|
||||
function selectFontB(font: UnifiedFont) {
|
||||
comparisonStore.fontB = font;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a font is selected as fontA
|
||||
*/
|
||||
function isFontA(font: UnifiedFont): boolean {
|
||||
return fontA?.id === font.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a font is selected as fontB
|
||||
*/
|
||||
function isFontB(font: UnifiedFont): boolean {
|
||||
return fontB?.id === font.id;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet rightBrackets(className?: string)}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class={cn(
|
||||
'lucide lucide-focus-icon lucide-focus right-0 top-0 absolute',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<path
|
||||
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
|
||||
d="M17 3h2a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class={cn(
|
||||
'lucide lucide-focus-icon lucide-focus right-0 bottom-0 absolute',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<path
|
||||
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
|
||||
d="M21 17v2a2 2 0 0 1-2 2h-2"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
{#snippet leftBrackets(className?: string)}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class={cn(
|
||||
'lucide lucide-focus-icon lucide-focus left-0 top-0 absolute',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<path
|
||||
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
|
||||
d="M3 7V5a2 2 0 0 1 2-2h2"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class={cn(
|
||||
'lucide lucide-focus-icon lucide-focus left-0 bottom-0 absolute',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<path
|
||||
transition:draw={{ duration: 300, delay: 150, easing: cubicOut }}
|
||||
d="M7 21H5a2 2 0 0 1-2-2v-2"
|
||||
/>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
{#snippet brackets(renderLeft?: boolean, renderRight?: boolean, className?: string)}
|
||||
{#if renderLeft}
|
||||
{@render leftBrackets(className)}
|
||||
{/if}
|
||||
{#if renderRight}
|
||||
{@render rightBrackets(className)}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col h-full min-h-0 bg-transparent">
|
||||
<div class="flex-1 min-h-0">
|
||||
<FontVirtualList
|
||||
weight={typography.weight}
|
||||
itemHeight={36}
|
||||
class="bg-transparent"
|
||||
>
|
||||
{#snippet children({ item: font })}
|
||||
{@const isSelectedA = isFontA(font)}
|
||||
{@const isSelectedB = isFontB(font)}
|
||||
{@const isEither = isSelectedA || isSelectedB}
|
||||
{@const isBoth = isSelectedA && isSelectedB}
|
||||
{@const handleSelectFontA = () => selectFontA(font)}
|
||||
{@const handleSelectFontB = () => selectFontB(font)}
|
||||
|
||||
<div class="group relative flex w-auto h-[36px] border-b border-black/[0.03] overflow-hidden mr-4 lg:mr-6">
|
||||
<div
|
||||
class={cn(
|
||||
'absolute inset-0 flex items-center justify-center z-20 pointer-events-none transition-all duration-500 cubic-bezier-out',
|
||||
isSelectedB && !isBoth && '-translate-x-1/4',
|
||||
isSelectedA && !isBoth && 'translate-x-1/4',
|
||||
isBoth && 'translate-x-0',
|
||||
)}
|
||||
>
|
||||
<div class="relative flex items-center px-6">
|
||||
<span
|
||||
class={cn(
|
||||
'font-mono text-[10px] sm:text-[11px] uppercase tracking-tighter select-none transition-all duration-300',
|
||||
isEither
|
||||
? 'opacity-100 font-bold'
|
||||
: 'opacity-30 group-hover:opacity-100',
|
||||
isSelectedB && 'text-indigo-500',
|
||||
isSelectedA && 'text-normal-950',
|
||||
isBoth && 'text-indigo-600',
|
||||
)}
|
||||
>
|
||||
--- {font.name} ---
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={handleSelectFontB}
|
||||
class="flex-1 relative flex items-center justify-between transition-all duration-200 cursor-pointer hover:bg-indigo-500/[0.03]"
|
||||
>
|
||||
{@render brackets(isSelectedB, isSelectedB && !isBoth, 'stroke-1 size-7 stroke-indigo-600')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={handleSelectFontA}
|
||||
class="flex-1 relative flex items-center justify-end transition-all duration-200 cursor-pointer hover:bg-black/[0.02]"
|
||||
>
|
||||
{@render brackets(isSelectedA && !isBoth, isSelectedA, 'stroke-1 size-7 stroke-normal-950')}
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,146 +0,0 @@
|
||||
<!--
|
||||
Component: SelectComparedFonts
|
||||
Displays selects that change the compared fonts
|
||||
-->
|
||||
<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,
|
||||
Item as SelectItem,
|
||||
Root as SelectRoot,
|
||||
Trigger as SelectTrigger,
|
||||
} from '$shared/shadcn/ui/select';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Position of the slider
|
||||
*/
|
||||
sliderPos: number;
|
||||
}
|
||||
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);
|
||||
|
||||
function selectFontA(font: UnifiedFont) {
|
||||
if (!font) return;
|
||||
comparisonStore.fontA = font;
|
||||
}
|
||||
|
||||
function selectFontB(font: UnifiedFont) {
|
||||
if (!font) return;
|
||||
comparisonStore.fontB = font;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet fontSelector(
|
||||
font: UnifiedFont,
|
||||
fonts: UnifiedFont[],
|
||||
url: string,
|
||||
onSelect: (f: UnifiedFont) => void,
|
||||
align: 'start' | 'end',
|
||||
)}
|
||||
<div
|
||||
class="z-50 pointer-events-auto"
|
||||
onpointerdown={(e => e.stopPropagation())}
|
||||
in:fade={{ duration: 300, delay: 300 }}
|
||||
out:fade={{ duration: 300, delay: 300 }}
|
||||
>
|
||||
<SelectRoot type="single" disabled={!fontList.length}>
|
||||
<SelectTrigger
|
||||
class={cn(
|
||||
'w-36 sm:w-44 md:w-52 h-8 sm:h-9 border border-border-muted bg-background-60 backdrop-blur-sm',
|
||||
'px-2 sm:px-3 rounded-lg transition-all flex items-center justify-between gap-2',
|
||||
'font-mono text-[10px] sm:text-[11px] tracking-tight font-medium text-foreground',
|
||||
'hover:bg-background-80 hover:border-border-soft hover:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<div class="text-left flex-1 min-w-0">
|
||||
<FontApplicator {font} weight={typography.weight}>
|
||||
{font.name}
|
||||
</FontApplicator>
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
class={cn(
|
||||
'bg-background-95 backdrop-blur-xl border border-border-muted shadow-xl',
|
||||
'w-44 sm:w-52 max-h-60 sm:max-h-64 overflow-hidden rounded-lg',
|
||||
)}
|
||||
side="top"
|
||||
{align}
|
||||
sideOffset={8}
|
||||
size="small"
|
||||
>
|
||||
<div class="p-1 sm:p-1.5">
|
||||
<FontVirtualList items={fonts} weight={typography.weight}>
|
||||
{#snippet children({ item: fontListItem })}
|
||||
{@const handleClick = () => onSelect(fontListItem)}
|
||||
<SelectItem
|
||||
value={fontListItem.id}
|
||||
class="data-highlighted:bg-background-muted 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
|
||||
font={fontListItem}
|
||||
weight={typography.weight}
|
||||
>
|
||||
{fontListItem.name}
|
||||
</FontApplicator>
|
||||
</SelectItem>
|
||||
{/snippet}
|
||||
</FontVirtualList>
|
||||
</div>
|
||||
</SelectContent>
|
||||
</SelectRoot>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<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}
|
||||
style:transform="translateY({sliderPos < 20 ? '8px' : '0px'})"
|
||||
>
|
||||
<div class="flex items-center gap-2 sm:gap-2.5 px-1">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-indigo-500 shadow-[0_0_6px_rgba(99,102,241,0.6)]"></div>
|
||||
<div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
|
||||
<span class="font-mono text-[8px] sm:text-[9px] uppercase tracking-[0.2em] text-text-subtle font-medium">
|
||||
ch_01
|
||||
</span>
|
||||
</div>
|
||||
{#if fontB && fontBUrl}
|
||||
{@render fontSelector(fontB, fontList, fontBUrl, selectFontB, 'start')}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-end text-right gap-1.5 sm:gap-2 transition-all duration-500"
|
||||
style:opacity={sliderPos > 80 ? 0 : 1}
|
||||
style:transform="translateY({sliderPos > 80 ? '8px' : '0px'})"
|
||||
>
|
||||
<div class="flex items-center gap-2 sm:gap-2.5 px-1">
|
||||
<span class="font-mono text-[8px] sm:text-[9px] uppercase tracking-[0.2em] text-text-subtle font-medium">
|
||||
ch_02
|
||||
</span>
|
||||
<div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-foreground shadow-[0_0_6px_rgba(0,0,0,0.4)]"></div>
|
||||
</div>
|
||||
{#if fontA && fontAUrl}
|
||||
{@render fontSelector(fontA, fontList, fontAUrl, selectFontA, 'end')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,6 +19,7 @@ interface Props {
|
||||
}
|
||||
let { sliderPos, isDragging }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'absolute inset-y-2 sm:inset-y-4 pointer-events-none -translate-x-1/2 z-50 flex flex-col justify-center items-center',
|
||||
@@ -30,6 +31,7 @@ let { sliderPos, isDragging }: Props = $props();
|
||||
style:left="{sliderPos}%"
|
||||
style:will-change={isDragging ? 'left' : 'auto'}
|
||||
in:fade={{ duration: 300, delay: 150, easing: cubicOut }}
|
||||
out:fade={{ duration: 150, easing: cubicOut }}
|
||||
>
|
||||
<!-- We use part of lucide cursor svg icon as a handle -->
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<!--
|
||||
Component: ToggleMenuButton
|
||||
Toggles menu sidebar, displays selected fonts names
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { draw } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
let { isActive = $bindable(false), onClick }: Props = $props();
|
||||
|
||||
// Handle click and toggle
|
||||
const toggle = () => {
|
||||
onClick?.();
|
||||
isActive = !isActive;
|
||||
};
|
||||
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
</script>
|
||||
|
||||
{#snippet icon(className?: string)}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class={cn('lucide lucide-circle-arrow-right-icon lucide-circle-arrow-right', className)}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
{#if isActive}
|
||||
<path transition:draw={{ duration: 150, delay: 150, easing: cubicOut }} d="m15 9-6 6" /><path
|
||||
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
|
||||
d="m9 9 6 6"
|
||||
/>
|
||||
{:else}
|
||||
<path transition:draw={{ duration: 150, delay: 150, easing: cubicOut }} d="m12 16 4-4-4-4" /><path
|
||||
transition:draw={{ duration: 150, delay: 150, easing: cubicOut }}
|
||||
d="M8 12h8"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<button
|
||||
onclick={toggle}
|
||||
aria-pressed={isActive}
|
||||
class={cn(
|
||||
'group relative flex items-center justify-center gap-2 sm:gap-3 px-4 sm:px-6 py-2',
|
||||
'cursor-pointer select-none overflow-hidden',
|
||||
'transition-transform duration-150 active:scale-98',
|
||||
)}
|
||||
>
|
||||
{@render icon('size-4 stroke-[1.5] stroke-gray-500')}
|
||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
||||
<div class="text-xs uppercase transition-all delay-150 group-hover:opacity-100 text-indigo-500 text-right">
|
||||
{fontB?.name}
|
||||
</div>
|
||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
||||
<div class="text-xs uppercase transition-all delay-150 group-hover:opacity-100 text-neural-950 text-left">
|
||||
{fontA?.name}
|
||||
</div>
|
||||
</button>
|
||||
@@ -1,198 +1,51 @@
|
||||
<!--
|
||||
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
|
||||
Controls for text input and typography settings (size, weight, height).
|
||||
Simplified version for static positioning in settings mode.
|
||||
-->
|
||||
<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 { type Orientation } from 'bits-ui';
|
||||
import { untrack } from 'svelte';
|
||||
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(() => {
|
||||
// Trigger only when side changes
|
||||
const currentSide = side;
|
||||
|
||||
untrack(() => {
|
||||
if (containerWidth > 0 && panelWidth > 0) {
|
||||
const targetX = currentSide === 'right'
|
||||
? containerWidth - panelWidth - margin * 2
|
||||
: 0;
|
||||
|
||||
// On side change set the position and the rotation
|
||||
xSpring.target = targetX;
|
||||
rotateSpring.target = currentSide === 'right' ? 3.5 : -3.5;
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
rotateSpring.target = 0;
|
||||
}, 600);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet InputComponent(className: string)}
|
||||
<Input
|
||||
class={className}
|
||||
bind:value={comparisonStore.text}
|
||||
disabled={isDragging}
|
||||
onfocusin={handleInputFocus}
|
||||
placeholder="The quick brown fox..."
|
||||
/>
|
||||
{/snippet}
|
||||
<!-- Text input -->
|
||||
<Input
|
||||
bind:value={comparisonStore.text}
|
||||
size="sm"
|
||||
label="Text"
|
||||
placeholder="The quick brown fox..."
|
||||
class="w-full px-3 py-2 h-10 rounded-lg border border-border-muted bg-background-60 backdrop-blur-sm mr-4"
|
||||
/>
|
||||
|
||||
{#snippet Controls(className: string, orientation: Orientation)}
|
||||
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
|
||||
<div class={className}>
|
||||
<ComboControlV2 control={typography.weightControl} {orientation} reduced />
|
||||
<ComboControlV2 control={typography.sizeControl} {orientation} reduced />
|
||||
<ComboControlV2 control={typography.heightControl} {orientation} reduced />
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<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">
|
||||
{@render InputComponent?.('p-6')}
|
||||
{@render Controls?.('flex flex-col justify-between items-center-safe gap-6', 'horizontal')}
|
||||
</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()}
|
||||
{@render InputComponent(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',
|
||||
))}
|
||||
{/snippet}
|
||||
|
||||
{#snippet hiddenContent()}
|
||||
{@render Controls?.('flex flex-row justify-between items-center-safe gap-2 sm:gap-0 h-64', 'vertical')}
|
||||
{/snippet}
|
||||
</ExpandableWrapper>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Typography controls -->
|
||||
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
|
||||
<div class="flex flex-col gap-1.5 mt-1.5">
|
||||
<ComboControlV2
|
||||
control={typography.weightControl}
|
||||
orientation="horizontal"
|
||||
class="sm:py-0"
|
||||
showScale={false}
|
||||
reduced
|
||||
/>
|
||||
<ComboControlV2
|
||||
control={typography.sizeControl}
|
||||
orientation="horizontal"
|
||||
class="sm:py-0"
|
||||
showScale={false}
|
||||
reduced
|
||||
/>
|
||||
<ComboControlV2
|
||||
control={typography.heightControl}
|
||||
orientation="horizontal"
|
||||
class="sm:py-0"
|
||||
showScale={false}
|
||||
reduced
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -24,38 +24,6 @@ let innerHeight = $state(0);
|
||||
// Is the component above the middle of the viewport?
|
||||
let isAboveMiddle = $state(false);
|
||||
|
||||
const isLoading = $derived(
|
||||
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
|
||||
);
|
||||
|
||||
/**
|
||||
* Load more fonts by moving to the next page
|
||||
*/
|
||||
function loadMore() {
|
||||
if (
|
||||
!unifiedFontStore.pagination.hasMore
|
||||
|| unifiedFontStore.isFetching
|
||||
) {
|
||||
return;
|
||||
}
|
||||
unifiedFontStore.nextPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll near bottom - auto-load next page
|
||||
*
|
||||
* Triggered by VirtualList when the user scrolls within 5 items of the end
|
||||
* of the loaded items. Only fetches if there are more pages available.
|
||||
*/
|
||||
function handleNearBottom(_lastVisibleIndex: number) {
|
||||
const { hasMore } = unifiedFontStore.pagination;
|
||||
|
||||
// VirtualList already checks if we're near the bottom of loaded items
|
||||
if (hasMore && !unifiedFontStore.isFetching) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate display range for pagination info
|
||||
*/
|
||||
@@ -83,13 +51,9 @@ const checkPosition = throttle(() => {
|
||||
|
||||
<div bind:this={wrapper}>
|
||||
<FontVirtualList
|
||||
items={unifiedFontStore.fonts}
|
||||
total={unifiedFontStore.pagination.total}
|
||||
onNearBottom={handleNearBottom}
|
||||
itemHeight={220}
|
||||
useWindowScroll={true}
|
||||
weight={controlManager.weight}
|
||||
{isLoading}
|
||||
>
|
||||
{#snippet children({
|
||||
item: font,
|
||||
|
||||
Reference in New Issue
Block a user