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
|
- Renders a virtualized list of fonts
|
||||||
- Handles font registration with the manager
|
- Handles font registration with the manager
|
||||||
-->
|
-->
|
||||||
<script lang="ts" generics="T extends UnifiedFont">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
Skeleton,
|
Skeleton,
|
||||||
VirtualList,
|
VirtualList,
|
||||||
@@ -11,26 +11,23 @@ import {
|
|||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { getFontUrl } from '../../lib';
|
import { getFontUrl } from '../../lib';
|
||||||
import type { FontConfigRequest } from '../../model';
|
|
||||||
import {
|
import {
|
||||||
|
type FontConfigRequest,
|
||||||
type UnifiedFont,
|
type UnifiedFont,
|
||||||
appliedFontsManager,
|
appliedFontsManager,
|
||||||
|
unifiedFontStore,
|
||||||
} from '../../model';
|
} from '../../model';
|
||||||
|
|
||||||
interface Props extends
|
interface Props extends
|
||||||
Omit<
|
Omit<
|
||||||
ComponentProps<typeof VirtualList<T>>,
|
ComponentProps<typeof VirtualList<UnifiedFont>>,
|
||||||
'onVisibleItemsChange'
|
'items' | 'total' | 'isLoading' | 'onVisibleItemsChange' | 'onNearBottom'
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Callback for when visible items change
|
* Callback for when visible items change
|
||||||
*/
|
*/
|
||||||
onVisibleItemsChange?: (items: T[]) => void;
|
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
|
||||||
/**
|
|
||||||
* Callback for when near bottom is reached
|
|
||||||
*/
|
|
||||||
onNearBottom?: (lastVisibleIndex: number) => void;
|
|
||||||
/**
|
/**
|
||||||
* Weight of the font
|
* Weight of the font
|
||||||
*/
|
*/
|
||||||
@@ -38,23 +35,20 @@ interface Props extends
|
|||||||
* Weight of the font
|
* Weight of the font
|
||||||
*/
|
*/
|
||||||
weight: number;
|
weight: number;
|
||||||
/**
|
|
||||||
* Whether the list is in a loading state
|
|
||||||
*/
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
items,
|
|
||||||
children,
|
children,
|
||||||
onVisibleItemsChange,
|
onVisibleItemsChange,
|
||||||
onNearBottom,
|
|
||||||
weight,
|
weight,
|
||||||
isLoading = false,
|
|
||||||
...rest
|
...rest
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function handleInternalVisibleChange(visibleItems: T[]) {
|
const isLoading = $derived(
|
||||||
|
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||||
const configs: FontConfigRequest[] = [];
|
const configs: FontConfigRequest[] = [];
|
||||||
|
|
||||||
visibleItems.forEach(item => {
|
visibleItems.forEach(item => {
|
||||||
@@ -77,9 +71,32 @@ function handleInternalVisibleChange(visibleItems: T[]) {
|
|||||||
// onVisibleItemsChange?.(visibleItems);
|
// onVisibleItemsChange?.(visibleItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNearBottom(lastVisibleIndex: number) {
|
/**
|
||||||
// Forward the call to any external listener
|
* Load more fonts by moving to the next page
|
||||||
onNearBottom?.(lastVisibleIndex);
|
*/
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -99,10 +116,11 @@ function handleNearBottom(lastVisibleIndex: number) {
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<VirtualList
|
<VirtualList
|
||||||
{items}
|
items={unifiedFontStore.fonts}
|
||||||
{...rest}
|
total={unifiedFontStore.pagination.total}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
onNearBottom={handleNearBottom}
|
onNearBottom={handleNearBottom}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
{#snippet children(scope)}
|
{#snippet children(scope)}
|
||||||
{@render children(scope)}
|
{@render children(scope)}
|
||||||
|
|||||||
@@ -276,3 +276,5 @@ export function createCharacterComparison<
|
|||||||
getCharState,
|
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';
|
} from './createEntityStore/createEntityStore.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
type CharacterComparison,
|
||||||
createCharacterComparison,
|
createCharacterComparison,
|
||||||
type LineData,
|
type LineData,
|
||||||
} from './createCharacterComparison/createCharacterComparison.svelte';
|
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||||
@@ -48,3 +49,8 @@ export {
|
|||||||
getLenisContext,
|
getLenisContext,
|
||||||
setLenisContext,
|
setLenisContext,
|
||||||
} from './createScrollContext/createScrollContext.svelte';
|
} from './createScrollContext/createScrollContext.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createPerspectiveManager,
|
||||||
|
type PerspectiveManager,
|
||||||
|
} from './createPerspectiveManager/createPerspectiveManager.svelte';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export {
|
export {
|
||||||
|
type CharacterComparison,
|
||||||
type ControlDataModel,
|
type ControlDataModel,
|
||||||
type ControlModel,
|
type ControlModel,
|
||||||
createCharacterComparison,
|
createCharacterComparison,
|
||||||
@@ -7,6 +8,7 @@ export {
|
|||||||
createFilter,
|
createFilter,
|
||||||
createLenisContext,
|
createLenisContext,
|
||||||
createPersistentStore,
|
createPersistentStore,
|
||||||
|
createPerspectiveManager,
|
||||||
createResponsiveManager,
|
createResponsiveManager,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
createVirtualizer,
|
createVirtualizer,
|
||||||
@@ -17,6 +19,7 @@ export {
|
|||||||
getLenisContext,
|
getLenisContext,
|
||||||
type LineData,
|
type LineData,
|
||||||
type PersistentStore,
|
type PersistentStore,
|
||||||
|
type PerspectiveManager,
|
||||||
type Property,
|
type Property,
|
||||||
type ResponsiveManager,
|
type ResponsiveManager,
|
||||||
responsiveManager,
|
responsiveManager,
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ function calculateScale(index: number): number | string {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !reduced}
|
||||||
<Input
|
<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"
|
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}
|
value={inputValue}
|
||||||
@@ -160,6 +161,7 @@ function calculateScale(index: number): number | string {
|
|||||||
pattern={REGEXP_ONLY_DIGITS}
|
pattern={REGEXP_ONLY_DIGITS}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if label}
|
{#if label}
|
||||||
<div class="flex items-center gap-2 opacity-70">
|
<div class="flex items-center gap-2 opacity-70">
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ let { rotation = 'clockwise', icon, ...rest }: Props = $props();
|
|||||||
>
|
>
|
||||||
{@render icon({
|
{@render icon({
|
||||||
className: cn(
|
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',
|
'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',
|
rotation === 'clockwise'
|
||||||
|
? 'group-active:rotate-6'
|
||||||
|
: 'group-active:-rotate-6',
|
||||||
),
|
),
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ const { Story } = defineMeta({
|
|||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
description: {
|
description: {
|
||||||
component: 'Styles Input component',
|
component: 'Styled input component with size and variant options',
|
||||||
},
|
},
|
||||||
story: { inline: false }, // Render stories in iframe for state isolation
|
story: { inline: false }, // Render stories in iframe for state isolation
|
||||||
},
|
},
|
||||||
|
layout: 'centered',
|
||||||
},
|
},
|
||||||
argTypes: {
|
argTypes: {
|
||||||
placeholder: {
|
placeholder: {
|
||||||
@@ -22,21 +23,76 @@ const { Story } = defineMeta({
|
|||||||
control: 'text',
|
control: 'text',
|
||||||
description: "input's value",
|
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>
|
||||||
|
|
||||||
<script lang="ts">
|
<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';
|
const placeholder = 'Enter text';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Story
|
<!-- Default Story -->
|
||||||
name="Default"
|
<Story name="Default" args={{ placeholder }}>
|
||||||
args={{
|
<Input bind:value={valueDefault} {placeholder} />
|
||||||
placeholder,
|
</Story>
|
||||||
value,
|
|
||||||
}}
|
<!-- Size Variants -->
|
||||||
>
|
<Story name="Small" args={{ placeholder }}>
|
||||||
<Input value={value} placeholder={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>
|
</Story>
|
||||||
|
|||||||
@@ -2,60 +2,89 @@
|
|||||||
Component: Input
|
Component: Input
|
||||||
Provides styled input component with all the shadcn input props
|
Provides styled input component with all the shadcn input props
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts" module>
|
||||||
import { Input } from '$shared/shadcn/ui/input';
|
import { Input as BaseInput } from '$shared/shadcn/ui/input';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
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)
|
* Current search value (bindable)
|
||||||
*/
|
*/
|
||||||
value: string;
|
value?: string;
|
||||||
/**
|
/**
|
||||||
* Additional CSS classes for the container
|
* Additional CSS classes for the container
|
||||||
*/
|
*/
|
||||||
class?: string;
|
class?: string;
|
||||||
|
/**
|
||||||
variant?: 'default' | 'ghost';
|
* Visual style variant
|
||||||
|
*/
|
||||||
|
variant?: InputVariant;
|
||||||
|
/**
|
||||||
|
* Size variant
|
||||||
|
*/
|
||||||
|
size?: InputSize;
|
||||||
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
let {
|
let {
|
||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
class: className,
|
class: className,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
|
size = 'lg',
|
||||||
...rest
|
...rest
|
||||||
}: Props = $props();
|
}: InputProps = $props();
|
||||||
|
|
||||||
const isGhost = $derived(variant === 'ghost');
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Input
|
<BaseInput
|
||||||
bind:value={value}
|
bind:value={value}
|
||||||
class={cn(
|
class={cn(inputVariants({ variant, size }), className)}
|
||||||
'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,
|
|
||||||
)}
|
|
||||||
{...rest}
|
{...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}
|
index={0}
|
||||||
class={cn(
|
class={cn(
|
||||||
'group/thumb relative block',
|
'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',
|
'rounded-sm',
|
||||||
'bg-foreground',
|
'bg-foreground',
|
||||||
// Glow shadow
|
// 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]',
|
orientation === 'horizontal' ? 'transition-[height,top,left,box-shadow]' : 'transition-[width,top,left,box-shadow]',
|
||||||
// Hover: bigger glow
|
// Hover: bigger glow
|
||||||
'hover:shadow-[0_0_10px_rgba(0,0,0,0.5)]',
|
'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: smaller glow
|
||||||
'active:shadow-[0_0_4px_rgba(0,0,0,0.3)]',
|
'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]',
|
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 CheckboxFilter } from './CheckboxFilter/CheckboxFilter.svelte';
|
||||||
export { default as ComboControl } from './ComboControl/ComboControl.svelte';
|
export { default as ComboControl } from './ComboControl/ComboControl.svelte';
|
||||||
export { default as ComboControlV2 } from './ComboControlV2/ComboControlV2.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 ExpandableWrapper } from './ExpandableWrapper/ExpandableWrapper.svelte';
|
||||||
export { default as Footnote } from './Footnote/Footnote.svelte';
|
export { default as Footnote } from './Footnote/Footnote.svelte';
|
||||||
export { default as IconButton } from './IconButton/IconButton.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 Loader } from './Loader/Loader.svelte';
|
||||||
export { default as Logo } from './Logo/Logo.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 SearchBar } from './SearchBar/SearchBar.svelte';
|
||||||
export { default as Section } from './Section/Section.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 Skeleton } from './Skeleton/Skeleton.svelte';
|
||||||
export { default as Slider } from './Slider/Slider.svelte';
|
export { default as Slider } from './Slider/Slider.svelte';
|
||||||
export { default as SmoothScroll } from './SmoothScroll/SmoothScroll.svelte';
|
export { default as SmoothScroll } from './SmoothScroll/SmoothScroll.svelte';
|
||||||
export { default as VirtualList } from './VirtualList/VirtualList.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.
|
- Character-level morphing: Font changes exactly when the slider passes the character's global position.
|
||||||
- Responsive layout with Tailwind breakpoints for font sizing.
|
- Responsive layout with Tailwind breakpoints for font sizing.
|
||||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
- Slider mode: Text centered in 1st plan, controls hidden
|
||||||
|
- Settings mode: Text moves to left (2nd plan), controls appear on right (1st plan)
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
|
type CharacterComparison,
|
||||||
|
type LineData,
|
||||||
|
type ResponsiveManager,
|
||||||
createCharacterComparison,
|
createCharacterComparison,
|
||||||
createTypographyControl,
|
createPerspectiveManager,
|
||||||
} from '$shared/lib';
|
} from '$shared/lib';
|
||||||
import type {
|
import {
|
||||||
LineData,
|
Loader,
|
||||||
ResponsiveManager,
|
PerspectivePlan,
|
||||||
} from '$shared/lib';
|
} from '$shared/ui';
|
||||||
import { Loader } from '$shared/ui';
|
|
||||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { Spring } from 'svelte/motion';
|
import { Spring } from 'svelte/motion';
|
||||||
@@ -31,10 +37,11 @@ import SliderLine from './components/SliderLine.svelte';
|
|||||||
const fontA = $derived(comparisonStore.fontA);
|
const fontA = $derived(comparisonStore.fontA);
|
||||||
const fontB = $derived(comparisonStore.fontB);
|
const fontB = $derived(comparisonStore.fontB);
|
||||||
|
|
||||||
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
const isLoading = $derived(
|
||||||
|
comparisonStore.isLoading || !comparisonStore.isReady,
|
||||||
|
);
|
||||||
|
|
||||||
let container = $state<HTMLElement>();
|
let container = $state<HTMLElement>();
|
||||||
let typographyControls = $state<HTMLDivElement | null>(null);
|
|
||||||
let measureCanvas = $state<HTMLCanvasElement>();
|
let measureCanvas = $state<HTMLCanvasElement>();
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
const typography = $derived(comparisonStore.typography);
|
const typography = $derived(comparisonStore.typography);
|
||||||
@@ -45,7 +52,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
|
|||||||
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
||||||
* Manages line breaking and character state based on fonts and container dimensions.
|
* Manages line breaking and character state based on fonts and container dimensions.
|
||||||
*/
|
*/
|
||||||
const charComparison = createCharacterComparison(
|
const charComparison: CharacterComparison = createCharacterComparison(
|
||||||
() => comparisonStore.text,
|
() => comparisonStore.text,
|
||||||
() => fontA,
|
() => fontA,
|
||||||
() => fontB,
|
() => fontB,
|
||||||
@@ -53,6 +60,22 @@ const charComparison = createCharacterComparison(
|
|||||||
() => typography.renderedSize,
|
() => typography.renderedSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perspective manager for back/front state toggling:
|
||||||
|
* - Front (slider mode): Text fully visible, interactive
|
||||||
|
* - Back (settings mode): Text blurred, scaled down, shifted left, controls visible
|
||||||
|
*
|
||||||
|
* Uses simple boolean flag for smooth transitions between states.
|
||||||
|
*/
|
||||||
|
const perspective = createPerspectiveManager({
|
||||||
|
parallaxIntensity: 0, // Disabled to not interfere with slider
|
||||||
|
horizontalOffset: 0, // Text shifts left when in back position
|
||||||
|
scaleStep: 0.5,
|
||||||
|
blurStep: 2,
|
||||||
|
depthStep: 100,
|
||||||
|
opacityStep: 0.3,
|
||||||
|
});
|
||||||
|
|
||||||
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
||||||
|
|
||||||
/** Physics-based spring for smooth handle movement */
|
/** Physics-based spring for smooth handle movement */
|
||||||
@@ -64,7 +87,10 @@ const sliderPos = $derived(sliderSpring.current);
|
|||||||
|
|
||||||
/** Updates spring target based on pointer position */
|
/** Updates spring target based on pointer position */
|
||||||
function handleMove(e: PointerEvent) {
|
function handleMove(e: PointerEvent) {
|
||||||
if (!isDragging || !container) return;
|
if (!isDragging || !container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||||
const percentage = (x / rect.width) * 100;
|
const percentage = (x / rect.width) * 100;
|
||||||
@@ -72,18 +98,15 @@ function handleMove(e: PointerEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startDragging(e: PointerEvent) {
|
function startDragging(e: PointerEvent) {
|
||||||
if (
|
|
||||||
e.target === typographyControls
|
|
||||||
|| typographyControls?.contains(e.target as Node)
|
|
||||||
) {
|
|
||||||
e.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
handleMove(e);
|
handleMove(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function togglePerspective() {
|
||||||
|
perspective.toggle();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the multiplier for slider font size based on the current responsive state
|
* Sets the multiplier for slider font size based on the current responsive state
|
||||||
*/
|
*/
|
||||||
@@ -146,28 +169,28 @@ $effect(() => {
|
|||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isInSettingsMode = $derived(perspective.isBack);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet renderLine(line: LineData, index: number)}
|
{#snippet renderLine(line: LineData, index: number)}
|
||||||
|
{@const pos = sliderPos}
|
||||||
|
{@const element = lineElements[index]}
|
||||||
<div
|
<div
|
||||||
bind:this={lineElements[index]}
|
bind:this={lineElements[index]}
|
||||||
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
||||||
style:height={`${typography.height}em`}
|
style:height={`${typography.height}em`}
|
||||||
style:line-height={`${typography.height}em`}
|
style:line-height={`${typography.height}em`}
|
||||||
>
|
>
|
||||||
{#each line.text.split('') as char, charIndex}
|
{#each line.text.split('') as char, index}
|
||||||
{@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)}
|
{@const { proximity, isPast } = charComparison.getCharState(index, pos, element, container)}
|
||||||
<!--
|
<!--
|
||||||
Single Character Span
|
Single Character Span
|
||||||
- Font Family switches based on `isPast`
|
- Font Family switches based on `isPast`
|
||||||
- Transitions/Transforms provide the "morph" feel
|
- Transitions/Transforms provide the "morph" feel
|
||||||
-->
|
-->
|
||||||
{#if fontA && fontB}
|
{#if fontA && fontB}
|
||||||
<CharacterSlot
|
<CharacterSlot {char} {proximity} {isPast} />
|
||||||
{char}
|
|
||||||
{proximity}
|
|
||||||
{isPast}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -176,7 +199,33 @@ $effect(() => {
|
|||||||
<!-- Hidden canvas used for text measurement by the helper -->
|
<!-- Hidden canvas used for text measurement by the helper -->
|
||||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
||||||
|
|
||||||
<div class="relative">
|
<!-- Main container with perspective and fixed height -->
|
||||||
|
<div
|
||||||
|
class="
|
||||||
|
relative w-full flex justify-center items-center
|
||||||
|
perspective-distant perspective-origin-center transform-3d
|
||||||
|
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
|
||||||
|
min-h-72 sm:min-h-96
|
||||||
|
backdrop-blur-lg bg-linear-to-br from-gray-200/40 via-white/80 to-gray-100/60
|
||||||
|
border border-border-muted
|
||||||
|
shadow-[inset_2px_0_8px_rgba(0,0,0,0.05)]
|
||||||
|
before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
|
||||||
|
before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
|
||||||
|
before:-z-10 before:blur-sm
|
||||||
|
overflow-hidden
|
||||||
|
[perspective:1500px] perspective-origin-center transform-3d
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{#if isLoading}
|
||||||
|
<div out:fade={{ duration: 300 }}>
|
||||||
|
<Loader size={24} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Text Plan -->
|
||||||
|
<PerspectivePlan
|
||||||
|
manager={perspective}
|
||||||
|
class="absolute inset-0 flex justify-center origin-right w-full h-full"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
bind:this={container}
|
bind:this={container}
|
||||||
role="slider"
|
role="slider"
|
||||||
@@ -185,31 +234,19 @@ $effect(() => {
|
|||||||
aria-label="Font comparison slider"
|
aria-label="Font comparison slider"
|
||||||
onpointerdown={startDragging}
|
onpointerdown={startDragging}
|
||||||
class="
|
class="
|
||||||
group relative w-full py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24 overflow-hidden
|
relative w-full h-full flex justify-center
|
||||||
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
|
py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24
|
||||||
select-none touch-none cursor-ew-resize min-h-72 sm:min-h-96 flex flex-col justify-center
|
select-none touch-none cursor-ew-resize
|
||||||
backdrop-blur-lg bg-linear-to-br from-gray-100/70 via-white/50 to-gray-100/60
|
|
||||||
border border-border-muted
|
|
||||||
shadow-[inset_0_4px_12px_0_rgba(0,0,0,0.12),inset_0_2px_4px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(255,255,255,0.8)]
|
|
||||||
before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
|
|
||||||
before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
|
|
||||||
before:-z-10 before:blur-sm
|
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<!-- Text Rendering Container -->
|
|
||||||
{#if isLoading}
|
|
||||||
<div out:fade={{ duration: 300 }}>
|
|
||||||
<Loader size={24} />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div
|
<div
|
||||||
class="
|
class="
|
||||||
relative flex flex-col items-center gap-3 sm:gap-4
|
relative flex flex-col items-center gap-3 sm:gap-4
|
||||||
text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold leading-[1.15]
|
text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold leading-[1.15]
|
||||||
z-10 pointer-events-none text-center
|
z-10 pointer-events-none text-center
|
||||||
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
|
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
|
||||||
|
my-auto
|
||||||
"
|
"
|
||||||
style:perspective="1000px"
|
|
||||||
in:fade={{ duration: 300, delay: 300 }}
|
in:fade={{ duration: 300, delay: 300 }}
|
||||||
out:fade={{ duration: 300 }}
|
out:fade={{ duration: 300 }}
|
||||||
>
|
>
|
||||||
@@ -226,9 +263,16 @@ $effect(() => {
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Slider Line - visible in slider mode -->
|
||||||
|
{#if !isInSettingsMode}
|
||||||
<SliderLine {sliderPos} {isDragging} />
|
<SliderLine {sliderPos} {isDragging} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<!-- Since there're slider controls inside we put them outside the main one -->
|
</PerspectivePlan>
|
||||||
<Controls {sliderPos} {isDragging} {typographyControls} {container} />
|
|
||||||
|
<Controls
|
||||||
|
class="absolute inset-y-0 left-0 transition-all duration-150"
|
||||||
|
handleToggle={togglePerspective}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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">
|
<script lang="ts">
|
||||||
import { appliedFontsManager } from '$entities/Font';
|
import { appliedFontsManager } from '$entities/Font';
|
||||||
import { getFontUrl } from '$entities/Font/lib';
|
import { getFontUrl } from '$entities/Font/lib';
|
||||||
import type { ResponsiveManager } from '$shared/lib';
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import {
|
import { SidebarMenu } from '$shared/ui';
|
||||||
Drawer,
|
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||||
IconButton,
|
import ComparisonList from './FontList.svelte';
|
||||||
} from '$shared/ui';
|
import ToggleMenuButton from './ToggleMenuButton.svelte';
|
||||||
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';
|
import TypographyControls from './TypographyControls.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sliderPos: number;
|
/**
|
||||||
isDragging: boolean;
|
* Additional class
|
||||||
typographyControls?: HTMLDivElement | null;
|
*/
|
||||||
container: HTMLElement;
|
class?: string;
|
||||||
|
/**
|
||||||
|
* Handler to trigger when menu opens/closes
|
||||||
|
*/
|
||||||
|
handleToggle?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { class: className, handleToggle }: Props = $props();
|
||||||
sliderPos,
|
|
||||||
isDragging,
|
|
||||||
typographyControls = $bindable<HTMLDivElement | null>(null),
|
|
||||||
container,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
|
let visible = $state(false);
|
||||||
const fontA = $derived(comparisonStore.fontA);
|
const fontA = $derived(comparisonStore.fontA);
|
||||||
const fontB = $derived(comparisonStore.fontB);
|
const fontB = $derived(comparisonStore.fontB);
|
||||||
|
const typography = $derived(comparisonStore.typography);
|
||||||
const weight = $derived(comparisonStore.typography.weight);
|
let menuWrapper = $state<HTMLElement | null>(null);
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!fontA || !fontB) {
|
if (!fontA || !fontB) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const weight = typography.weight;
|
||||||
const fontAUrl = getFontUrl(fontA, weight);
|
const fontAUrl = getFontUrl(fontA, weight);
|
||||||
const fontBUrl = getFontUrl(fontB, weight);
|
const fontBUrl = getFontUrl(fontB, weight);
|
||||||
|
|
||||||
@@ -63,41 +64,28 @@ $effect(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if responsive.isMobile}
|
<SidebarMenu
|
||||||
<Drawer>
|
class={cn(
|
||||||
{#snippet trigger({ isOpen, onClick })}
|
'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',
|
||||||
<IconButton class="absolute right-3 top-3" onclick={onClick}>
|
'relative h-full transition-all duration-700 ease-out',
|
||||||
{#snippet icon({ className })}
|
className,
|
||||||
<SlidersIcon class={className} />
|
)}
|
||||||
{/snippet}
|
bind:visible
|
||||||
</IconButton>
|
bind:wrapper={menuWrapper}
|
||||||
{/snippet}
|
onClickOutside={handleToggle}
|
||||||
|
>
|
||||||
{#snippet content({ isOpen, className })}
|
{#snippet action()}
|
||||||
<div class={cn(className, 'flex flex-col gap-6')}>
|
<!-- Always-visible mode switch -->
|
||||||
<SelectComparedFonts {sliderPos} />
|
<div class={cn('absolute top-4 left-0 z-50', visible && 'w-full')}>
|
||||||
<TypographyControls
|
<ToggleMenuButton bind:isActive={visible} onClick={handleToggle} />
|
||||||
{sliderPos}
|
|
||||||
{isDragging}
|
|
||||||
isActive={isOpen}
|
|
||||||
bind:wrapper={typographyControls}
|
|
||||||
containerWidth={container?.clientWidth}
|
|
||||||
staticPosition
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Drawer>
|
<div class="h-2/3 overflow-hidden">
|
||||||
{:else}
|
<ComparisonList />
|
||||||
<div class="absolute top-3 sm:top-6 left-3 sm:left-6 z-50">
|
|
||||||
<TypographyControls
|
|
||||||
{sliderPos}
|
|
||||||
{isDragging}
|
|
||||||
bind:wrapper={typographyControls}
|
|
||||||
containerWidth={container?.clientWidth}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute bottom-3 sm:bottom-6 md:bottom-8 inset-x-3 sm:inset-x-6 md:inset-x-12">
|
<div class="relative flex w-auto border-b border-gray-400/50 px-2 ml-4 mr-8 lg:mr-10"></div>
|
||||||
<SelectComparedFonts {sliderPos} />
|
<div class="mr-4 sm:mr-6">
|
||||||
|
<TypographyControls />
|
||||||
</div>
|
</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();
|
let { sliderPos, isDragging }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
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',
|
'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:left="{sliderPos}%"
|
||||||
style:will-change={isDragging ? 'left' : 'auto'}
|
style:will-change={isDragging ? 'left' : 'auto'}
|
||||||
in:fade={{ duration: 300, delay: 150, easing: cubicOut }}
|
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 -->
|
<!-- We use part of lucide cursor svg icon as a handle -->
|
||||||
<svg
|
<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
|
Component: TypographyControls
|
||||||
Wrapper for the controls of the slider.
|
Controls for text input and typography settings (size, weight, height).
|
||||||
- Input to change the text
|
Simplified version for static positioning in settings mode.
|
||||||
- Three combo controls with inputs and sliders for font-weight, font-size, and line-height
|
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import {
|
import {
|
||||||
ComboControlV2,
|
ComboControlV2,
|
||||||
ExpandableWrapper,
|
|
||||||
Input,
|
Input,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
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 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>
|
</script>
|
||||||
|
|
||||||
{#snippet InputComponent(className: string)}
|
<!-- Text input -->
|
||||||
<Input
|
<Input
|
||||||
class={className}
|
|
||||||
bind:value={comparisonStore.text}
|
bind:value={comparisonStore.text}
|
||||||
disabled={isDragging}
|
size="sm"
|
||||||
onfocusin={handleInputFocus}
|
label="Text"
|
||||||
placeholder="The quick brown fox..."
|
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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 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
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
|
||||||
|
|
||||||
{#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>
|
</div>
|
||||||
{/if}
|
{/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>
|
|
||||||
|
|||||||
@@ -24,38 +24,6 @@ let innerHeight = $state(0);
|
|||||||
// Is the component above the middle of the viewport?
|
// Is the component above the middle of the viewport?
|
||||||
let isAboveMiddle = $state(false);
|
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
|
* Calculate display range for pagination info
|
||||||
*/
|
*/
|
||||||
@@ -83,13 +51,9 @@ const checkPosition = throttle(() => {
|
|||||||
|
|
||||||
<div bind:this={wrapper}>
|
<div bind:this={wrapper}>
|
||||||
<FontVirtualList
|
<FontVirtualList
|
||||||
items={unifiedFontStore.fonts}
|
|
||||||
total={unifiedFontStore.pagination.total}
|
|
||||||
onNearBottom={handleNearBottom}
|
|
||||||
itemHeight={220}
|
itemHeight={220}
|
||||||
useWindowScroll={true}
|
useWindowScroll={true}
|
||||||
weight={controlManager.weight}
|
weight={controlManager.weight}
|
||||||
{isLoading}
|
|
||||||
>
|
>
|
||||||
{#snippet children({
|
{#snippet children({
|
||||||
item: font,
|
item: font,
|
||||||
|
|||||||
Reference in New Issue
Block a user