Compare commits
15 Commits
d749f86edc
...
2f45dc3620
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f45dc3620 | ||
|
|
d282448c53 | ||
|
|
f2e8de1d1d | ||
|
|
cee2a80c41 | ||
|
|
8b02333c01 | ||
|
|
0e85851cfd | ||
|
|
7dce7911c0 | ||
|
|
5e3929575d | ||
|
|
d3297d519f | ||
|
|
21d8273967 | ||
|
|
cdb2c355c0 | ||
|
|
3423eebf77 | ||
|
|
08d474289b | ||
|
|
2e6fc0e858 | ||
|
|
173816b5c0 |
@@ -67,6 +67,7 @@
|
|||||||
"vitest-browser-svelte": "^2.0.1"
|
"vitest-browser-svelte": "^2.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/svelte-query": "^6.0.14"
|
"@tanstack/svelte-query": "^6.0.14",
|
||||||
|
"lenis": "^1.3.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/app/types/ambient.d.ts
vendored
13
src/app/types/ambient.d.ts
vendored
@@ -35,3 +35,16 @@ declare module '*.jpg' {
|
|||||||
const content: string;
|
const content: string;
|
||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly DEV: boolean;
|
||||||
|
readonly PROD: boolean;
|
||||||
|
readonly MODE: string;
|
||||||
|
// Add other env variables you use
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
Component: FontApplicator
|
Component: FontApplicator
|
||||||
Loads fonts from fontshare with link tag
|
Loads fonts from fontshare with link tag
|
||||||
- Loads font only if it's not already applied
|
- Loads font only if it's not already applied
|
||||||
- Uses IntersectionObserver to detect when font is visible
|
- Reacts to font load status to show/hide content
|
||||||
- Adds smooth transition when font appears
|
- Adds smooth transition when font appears
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getFontUrl } from '$entities/Font/lib';
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { prefersReducedMotion } from 'svelte/motion';
|
import { prefersReducedMotion } from 'svelte/motion';
|
||||||
@@ -34,46 +33,23 @@ interface Props {
|
|||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { font, weight = 400, className, children }: Props = $props();
|
let {
|
||||||
let element: Element;
|
font,
|
||||||
|
weight = 400,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
// Track if the user has actually scrolled this into view
|
const status = $derived(
|
||||||
let hasEnteredViewport = $state(false);
|
appliedFontsManager.getFontStatus(
|
||||||
const status = $derived(appliedFontsManager.getFontStatus(font.id, weight, font.features.isVariable));
|
font.id,
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (status === 'loaded' || status === 'error') {
|
|
||||||
hasEnteredViewport = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(entries => {
|
|
||||||
if (entries[0].isIntersecting) {
|
|
||||||
hasEnteredViewport = true;
|
|
||||||
const url = getFontUrl(font, weight);
|
|
||||||
// Touch ensures it's in the queue.
|
|
||||||
// It's safe to call this even if VirtualList called it
|
|
||||||
// (Manager dedupes based on key)
|
|
||||||
if (url) {
|
|
||||||
appliedFontsManager.touch([{
|
|
||||||
id: font.id,
|
|
||||||
weight,
|
weight,
|
||||||
name: font.name,
|
font.features.isVariable,
|
||||||
url,
|
),
|
||||||
isVariable: font.features.isVariable,
|
);
|
||||||
}]);
|
|
||||||
}
|
|
||||||
|
|
||||||
observer.unobserve(element);
|
// The "Show" condition: Font is loaded OR it errored out OR it's a noTouch preview (like in search)
|
||||||
}
|
const shouldReveal = $derived(status === 'loaded' || status === 'error');
|
||||||
});
|
|
||||||
|
|
||||||
if (element) observer.observe(element);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The "Show" condition: Element is in view AND (Font is ready OR it errored out)
|
|
||||||
const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded'));
|
|
||||||
|
|
||||||
const transitionClasses = $derived(
|
const transitionClasses = $derived(
|
||||||
prefersReducedMotion.current
|
prefersReducedMotion.current
|
||||||
@@ -83,12 +59,14 @@ const transitionClasses = $derived(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={element}
|
style:font-family={shouldReveal
|
||||||
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
|
? `'${font.name}'`
|
||||||
|
: 'system-ui, -apple-system, sans-serif'}
|
||||||
class={cn(
|
class={cn(
|
||||||
transitionClasses,
|
transitionClasses,
|
||||||
// If reduced motion is on, we skip the transform/blur entirely
|
// If reduced motion is on, we skip the transform/blur entirely
|
||||||
!shouldReveal && !prefersReducedMotion.current
|
!shouldReveal
|
||||||
|
&& !prefersReducedMotion.current
|
||||||
&& 'opacity-50 scale-[0.95] blur-sm',
|
&& 'opacity-50 scale-[0.95] blur-sm',
|
||||||
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
||||||
shouldReveal && 'opacity-100 scale-100 blur-0',
|
shouldReveal && 'opacity-100 scale-100 blur-0',
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<!--
|
|
||||||
Component: FontListItem
|
|
||||||
Displays a font item and manages its animations
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { Spring } from 'svelte/motion';
|
|
||||||
import { type UnifiedFont } from '../../model';
|
import { type UnifiedFont } from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -31,51 +26,14 @@ interface Props {
|
|||||||
children: Snippet<[font: UnifiedFont]>;
|
children: Snippet<[font: UnifiedFont]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { font, isFullyVisible, isPartiallyVisible, proximity, children }: Props = $props();
|
const { font, children }: Props = $props();
|
||||||
|
|
||||||
let timeoutId = $state<NodeJS.Timeout | null>(null);
|
|
||||||
|
|
||||||
// Create a spring for smooth scale animation
|
|
||||||
const scale = new Spring(1, {
|
|
||||||
stiffness: 0.3,
|
|
||||||
damping: 0.7,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Springs react to the virtualizer's computed state
|
|
||||||
const bloom = new Spring(0, {
|
|
||||||
stiffness: 0.15,
|
|
||||||
damping: 0.6,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync spring to proximity for a "Lens" effect
|
|
||||||
$effect(() => {
|
|
||||||
bloom.target = isPartiallyVisible ? 1 : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function animateSelection() {
|
|
||||||
scale.target = 0.98;
|
|
||||||
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
scale.target = 1;
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn('pb-1 will-change-transform')}
|
class={cn(
|
||||||
style:opacity={bloom.current}
|
'pb-1 will-change-transform transition-transform duration-200 ease-out',
|
||||||
style:transform="
|
'hover:scale-[0.98]', // Simple CSS hover effect
|
||||||
scale({0.92 + (bloom.current * 0.08)})
|
)}
|
||||||
translateY({(1 - bloom.current) * 10}px)
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
{@render children?.(font)}
|
{@render children?.(font)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const letterSpacing = $derived(controlManager.spacing);
|
|||||||
class="
|
class="
|
||||||
w-full h-full rounded-xl sm:rounded-2xl
|
w-full h-full rounded-xl sm:rounded-2xl
|
||||||
flex flex-col
|
flex flex-col
|
||||||
backdrop-blur-md bg-background-80
|
bg-background-80
|
||||||
border border-border-muted
|
border border-border-muted
|
||||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||||
relative overflow-hidden
|
relative overflow-hidden
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import Lenis from 'lenis';
|
||||||
|
import {
|
||||||
|
getContext,
|
||||||
|
setContext,
|
||||||
|
} from 'svelte';
|
||||||
|
|
||||||
|
const LENIS_KEY = Symbol('lenis');
|
||||||
|
|
||||||
|
export function createLenisContext() {
|
||||||
|
let lenis = $state<Lenis | null>(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get lenis() {
|
||||||
|
return lenis;
|
||||||
|
},
|
||||||
|
setLenis(instance: Lenis) {
|
||||||
|
lenis = instance;
|
||||||
|
},
|
||||||
|
destroyLenis() {
|
||||||
|
lenis?.destroy();
|
||||||
|
lenis = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLenisContext(context: ReturnType<typeof createLenisContext>) {
|
||||||
|
setContext(LENIS_KEY, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLenisContext() {
|
||||||
|
return getContext<ReturnType<typeof createLenisContext>>(LENIS_KEY);
|
||||||
|
}
|
||||||
@@ -120,9 +120,11 @@ export function createVirtualizer<T>(
|
|||||||
// By wrapping the getter in $derived, we track everything inside it
|
// By wrapping the getter in $derived, we track everything inside it
|
||||||
const options = $derived(optionsGetter());
|
const options = $derived(optionsGetter());
|
||||||
|
|
||||||
// This derivation now tracks: count, measuredSizes, AND the data array itself
|
// This derivation now tracks: count, _version (for measuredSizes updates), AND the data array itself
|
||||||
const offsets = $derived.by(() => {
|
const offsets = $derived.by(() => {
|
||||||
const count = options.count;
|
const count = options.count;
|
||||||
|
// Implicit dependency on version signal
|
||||||
|
const v = _version;
|
||||||
const result = new Float64Array(count);
|
const result = new Float64Array(count);
|
||||||
let accumulated = 0;
|
let accumulated = 0;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -144,6 +146,8 @@ export function createVirtualizer<T>(
|
|||||||
// We MUST read options.data here so Svelte knows to re-run
|
// We MUST read options.data here so Svelte knows to re-run
|
||||||
// this derivation when the items array is replaced!
|
// this derivation when the items array is replaced!
|
||||||
const { count, data } = options;
|
const { count, data } = options;
|
||||||
|
// Implicit dependency
|
||||||
|
const v = _version;
|
||||||
if (count === 0 || containerHeight === 0 || !data) return [];
|
if (count === 0 || containerHeight === 0 || !data) return [];
|
||||||
|
|
||||||
const overscan = options.overscan ?? 5;
|
const overscan = options.overscan ?? 5;
|
||||||
@@ -318,6 +322,9 @@ export function createVirtualizer<T>(
|
|||||||
|
|
||||||
let measurementBuffer: Record<number, number> = {};
|
let measurementBuffer: Record<number, number> = {};
|
||||||
let frameId: number | null = null;
|
let frameId: number | null = null;
|
||||||
|
// Signal to trigger updates when mutating measuredSizes in place
|
||||||
|
let _version = $state(0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Svelte action to measure individual item elements for dynamic height support.
|
* Svelte action to measure individual item elements for dynamic height support.
|
||||||
*
|
*
|
||||||
@@ -334,18 +341,25 @@ export function createVirtualizer<T>(
|
|||||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
||||||
|
|
||||||
if (!isNaN(index)) {
|
if (!isNaN(index)) {
|
||||||
|
// Accessing the version ensures we have the latest state if needed,
|
||||||
|
// though here we just read the raw object.
|
||||||
const oldHeight = measuredSizes[index];
|
const oldHeight = measuredSizes[index];
|
||||||
|
|
||||||
// Only update if the height difference is significant (> 0.5px)
|
// Only update if the height difference is significant (> 0.5px)
|
||||||
// This prevents "jitter" from focus rings or sub-pixel border changes
|
|
||||||
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
||||||
// Stuff the measurement into a temporary buffer
|
// Stuff the measurement into a temporary buffer to batch updates
|
||||||
measurementBuffer[index] = height;
|
measurementBuffer[index] = height;
|
||||||
|
|
||||||
// Schedule a single update for the next animation frame
|
// Schedule a single update for the next animation frame
|
||||||
if (frameId === null) {
|
if (frameId === null) {
|
||||||
frameId = requestAnimationFrame(() => {
|
frameId = requestAnimationFrame(() => {
|
||||||
measuredSizes = { ...measuredSizes, ...measurementBuffer };
|
// Mutation in place for performance
|
||||||
// Reset the buffer
|
Object.assign(measuredSizes, measurementBuffer);
|
||||||
|
|
||||||
|
// Trigger reactivity
|
||||||
|
_version += 1;
|
||||||
|
|
||||||
|
// Reset buffer
|
||||||
measurementBuffer = {};
|
measurementBuffer = {};
|
||||||
frameId = null;
|
frameId = null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,3 +42,9 @@ export {
|
|||||||
type ResponsiveManager,
|
type ResponsiveManager,
|
||||||
responsiveManager,
|
responsiveManager,
|
||||||
} from './createResponsiveManager/createResponsiveManager.svelte';
|
} from './createResponsiveManager/createResponsiveManager.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createLenisContext,
|
||||||
|
getLenisContext,
|
||||||
|
setLenisContext,
|
||||||
|
} from './createScrollContext/createScrollContext.svelte';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export {
|
|||||||
createDebouncedState,
|
createDebouncedState,
|
||||||
createEntityStore,
|
createEntityStore,
|
||||||
createFilter,
|
createFilter,
|
||||||
|
createLenisContext,
|
||||||
createPersistentStore,
|
createPersistentStore,
|
||||||
createResponsiveManager,
|
createResponsiveManager,
|
||||||
createTypographyControl,
|
createTypographyControl,
|
||||||
@@ -13,11 +14,13 @@ export {
|
|||||||
type EntityStore,
|
type EntityStore,
|
||||||
type Filter,
|
type Filter,
|
||||||
type FilterModel,
|
type FilterModel,
|
||||||
|
getLenisContext,
|
||||||
type LineData,
|
type LineData,
|
||||||
type PersistentStore,
|
type PersistentStore,
|
||||||
type Property,
|
type Property,
|
||||||
type ResponsiveManager,
|
type ResponsiveManager,
|
||||||
responsiveManager,
|
responsiveManager,
|
||||||
|
setLenisContext,
|
||||||
type TypographyControl,
|
type TypographyControl,
|
||||||
type VirtualItem,
|
type VirtualItem,
|
||||||
type Virtualizer,
|
type Virtualizer,
|
||||||
@@ -27,10 +30,12 @@ export {
|
|||||||
export {
|
export {
|
||||||
buildQueryString,
|
buildQueryString,
|
||||||
clampNumber,
|
clampNumber,
|
||||||
|
debounce,
|
||||||
getDecimalPlaces,
|
getDecimalPlaces,
|
||||||
roundToStepPrecision,
|
roundToStepPrecision,
|
||||||
smoothScroll,
|
smoothScroll,
|
||||||
splitArray,
|
splitArray,
|
||||||
|
throttle,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
export { springySlideFade } from './transitions';
|
export { springySlideFade } from './transitions';
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
|||||||
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
||||||
export { smoothScroll } from './smoothScroll/smoothScroll';
|
export { smoothScroll } from './smoothScroll/smoothScroll';
|
||||||
export { splitArray } from './splitArray/splitArray';
|
export { splitArray } from './splitArray/splitArray';
|
||||||
|
export { throttle } from './throttle/throttle';
|
||||||
|
|||||||
32
src/shared/lib/utils/throttle/throttle.ts
Normal file
32
src/shared/lib/utils/throttle/throttle.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Throttle function execution to a maximum frequency.
|
||||||
|
*
|
||||||
|
* @param fn Function to throttle.
|
||||||
|
* @param wait Maximum time between function calls.
|
||||||
|
* @returns Throttled function.
|
||||||
|
*/
|
||||||
|
export function throttle<T extends (...args: any[]) => any>(
|
||||||
|
fn: T,
|
||||||
|
wait: number,
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let lastCall = 0;
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastCall = now - lastCall;
|
||||||
|
|
||||||
|
if (timeSinceLastCall >= wait) {
|
||||||
|
lastCall = now;
|
||||||
|
fn(...args);
|
||||||
|
} else {
|
||||||
|
// Schedule for end of wait period
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
lastCall = Date.now();
|
||||||
|
fn(...args);
|
||||||
|
timeoutId = null;
|
||||||
|
}, wait - timeSinceLastCall);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
78
src/shared/ui/SmoothScroll/SmoothScroll.svelte
Normal file
78
src/shared/ui/SmoothScroll/SmoothScroll.svelte
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
createLenisContext,
|
||||||
|
setLenisContext,
|
||||||
|
} from '$shared/lib';
|
||||||
|
import Lenis from 'lenis';
|
||||||
|
import type { LenisOptions } from 'lenis';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: import('svelte').Snippet;
|
||||||
|
// Lenis options - all optional with sensible defaults
|
||||||
|
duration?: number;
|
||||||
|
easing?: (t: number) => number;
|
||||||
|
smoothWheel?: boolean;
|
||||||
|
wheelMultiplier?: number;
|
||||||
|
touchMultiplier?: number;
|
||||||
|
infinite?: boolean;
|
||||||
|
orientation?: 'vertical' | 'horizontal';
|
||||||
|
gestureOrientation?: 'vertical' | 'horizontal' | 'both';
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
duration = 1.2,
|
||||||
|
easing = t => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||||
|
smoothWheel = true,
|
||||||
|
wheelMultiplier = 1,
|
||||||
|
touchMultiplier = 2,
|
||||||
|
infinite = false,
|
||||||
|
orientation = 'vertical',
|
||||||
|
gestureOrientation = 'vertical',
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const lenisContext = createLenisContext();
|
||||||
|
setLenisContext(lenisContext);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const lenisOptions: LenisOptions = {
|
||||||
|
duration,
|
||||||
|
easing,
|
||||||
|
smoothWheel,
|
||||||
|
wheelMultiplier,
|
||||||
|
touchMultiplier,
|
||||||
|
infinite,
|
||||||
|
orientation,
|
||||||
|
gestureOrientation,
|
||||||
|
// Prevent jitter with virtual scroll
|
||||||
|
prevent: (node: HTMLElement) => {
|
||||||
|
// Don't smooth scroll inside elements with data-lenis-prevent
|
||||||
|
return node.hasAttribute('data-lenis-prevent');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const lenis = new Lenis(lenisOptions);
|
||||||
|
|
||||||
|
lenisContext.setLenis(lenis);
|
||||||
|
|
||||||
|
// RAF loop
|
||||||
|
function raf(time: number) {
|
||||||
|
lenis.raf(time);
|
||||||
|
requestAnimationFrame(raf);
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(raf);
|
||||||
|
|
||||||
|
// Expose to window for debugging (only in dev)
|
||||||
|
if (import.meta.env?.DEV) {
|
||||||
|
(window as any).lenis = lenis;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
lenisContext.destroyLenis();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
-->
|
-->
|
||||||
<script lang="ts" generics="T">
|
<script lang="ts" generics="T">
|
||||||
import { createVirtualizer } from '$shared/lib';
|
import { createVirtualizer } from '$shared/lib';
|
||||||
|
import { throttle } from '$shared/lib/utils';
|
||||||
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
@@ -154,10 +155,20 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const throttledVisibleChange = throttle((visibleItems: T[]) => {
|
||||||
|
onVisibleItemsChange?.(visibleItems);
|
||||||
|
}, 150); // 150ms debounce
|
||||||
|
|
||||||
|
const throttledNearBottom = throttle((lastVisibleIndex: number) => {
|
||||||
|
onNearBottom?.(lastVisibleIndex);
|
||||||
|
}, 200); // 200ms debounce
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const visibleItems = virtualizer.items.map(item => items[item.index]);
|
const visibleItems = virtualizer.items.map(item => items[item.index]);
|
||||||
onVisibleItemsChange?.(visibleItems);
|
throttledVisibleChange(visibleItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
|
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
|
||||||
if (virtualizer.items.length > 0 && onNearBottom) {
|
if (virtualizer.items.length > 0 && onNearBottom) {
|
||||||
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
|
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
|
||||||
@@ -165,7 +176,7 @@ $effect(() => {
|
|||||||
const itemsRemaining = items.length - lastVisibleItem.index;
|
const itemsRemaining = items.length - lastVisibleItem.index;
|
||||||
|
|
||||||
if (itemsRemaining <= 5) {
|
if (itemsRemaining <= 5) {
|
||||||
onNearBottom(lastVisibleItem.index);
|
throttledNearBottom(lastVisibleItem.index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -180,6 +191,7 @@ $effect(() => {
|
|||||||
data-index={item.index}
|
data-index={item.index}
|
||||||
class="absolute top-0 left-0 w-full will-change-transform"
|
class="absolute top-0 left-0 w-full will-change-transform"
|
||||||
style:transform="translateY({item.start}px)"
|
style:transform="translateY({item.start}px)"
|
||||||
|
data-lenis-prevent
|
||||||
>
|
>
|
||||||
{#if item.index < items.length}
|
{#if item.index < items.length}
|
||||||
{@render children({
|
{@render children({
|
||||||
@@ -212,7 +224,6 @@ $effect(() => {
|
|||||||
data-index={item.index}
|
data-index={item.index}
|
||||||
class="absolute top-0 left-0 w-full will-change-transform"
|
class="absolute top-0 left-0 w-full will-change-transform"
|
||||||
style:transform="translateY({item.start}px)"
|
style:transform="translateY({item.start}px)"
|
||||||
animate:flip={{ delay: 0, duration: 300, easing: quintOut }}
|
|
||||||
>
|
>
|
||||||
{#if item.index < items.length}
|
{#if item.index < items.length}
|
||||||
{@render children({
|
{@render children({
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ 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 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 VirtualList } from './VirtualList/VirtualList.svelte';
|
export { default as VirtualList } from './VirtualList/VirtualList.svelte';
|
||||||
|
|||||||
@@ -78,8 +78,6 @@ class ComparisonStore {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#fontsReady = false;
|
|
||||||
|
|
||||||
const weight = this.#typography.weight;
|
const weight = this.#typography.weight;
|
||||||
const size = this.#typography.renderedSize;
|
const size = this.#typography.renderedSize;
|
||||||
const fontAName = this.#fontA?.name;
|
const fontAName = this.#fontA?.name;
|
||||||
@@ -87,11 +85,25 @@ class ComparisonStore {
|
|||||||
|
|
||||||
if (!fontAName || !fontBName) return;
|
if (!fontAName || !fontBName) return;
|
||||||
|
|
||||||
|
const fontAString = `${weight} ${size}px "${fontAName}"`;
|
||||||
|
const fontBString = `${weight} ${size}px "${fontBName}"`;
|
||||||
|
|
||||||
|
// Check if already loaded to avoid UI flash
|
||||||
|
const isALoaded = document.fonts.check(fontAString);
|
||||||
|
const isBLoaded = document.fonts.check(fontBString);
|
||||||
|
|
||||||
|
if (isALoaded && isBLoaded) {
|
||||||
|
this.#fontsReady = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#fontsReady = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Load fonts into memory
|
// Step 1: Load fonts into memory
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
document.fonts.load(`${weight} ${size}px "${fontAName}"`),
|
document.fonts.load(fontAString),
|
||||||
document.fonts.load(`${weight} ${size}px "${fontBName}"`),
|
document.fonts.load(fontBString),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Step 2: Wait for browser to be ready to render
|
// Step 2: Wait for browser to be ready to render
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
Renders a character with particular styling based on proximity, isPast, weight, fontAName, and fontBName.
|
Renders a character with particular styling based on proximity, isPast, weight, fontAName, and fontBName.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { appliedFontsManager } from '$entities/Font';
|
|
||||||
import { getFontUrl } from '$entities/Font/lib';
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import { comparisonStore } from '../../../model';
|
import { comparisonStore } from '../../../model';
|
||||||
|
|
||||||
@@ -28,33 +26,6 @@ let { char, proximity, isPast }: Props = $props();
|
|||||||
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 typography = $derived(comparisonStore.typography);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!fontA || !fontB) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlA = getFontUrl(fontA, typography.weight);
|
|
||||||
const urlB = getFontUrl(fontB, typography.weight);
|
|
||||||
|
|
||||||
if (!urlA || !urlB) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
appliedFontsManager.touch([{
|
|
||||||
id: fontA.id,
|
|
||||||
weight: typography.weight,
|
|
||||||
name: fontA.name,
|
|
||||||
url: urlA,
|
|
||||||
isVariable: fontA.features.isVariable,
|
|
||||||
}, {
|
|
||||||
id: fontB.id,
|
|
||||||
weight: typography.weight,
|
|
||||||
name: fontB.name,
|
|
||||||
url: urlB,
|
|
||||||
isVariable: fontB.features.isVariable,
|
|
||||||
}]);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if fontA && fontB}
|
{#if fontA && fontB}
|
||||||
@@ -67,13 +38,18 @@ $effect(() => {
|
|||||||
style:font-weight={typography.weight}
|
style:font-weight={typography.weight}
|
||||||
style:font-size={`${typography.renderedSize}px`}
|
style:font-size={`${typography.renderedSize}px`}
|
||||||
style:transform="
|
style:transform="
|
||||||
scale({1 + proximity * 0.3})
|
scale({1 + proximity * 0.3}) translateY({-proximity * 12}px) rotateY({proximity *
|
||||||
translateY({-proximity * 12}px)
|
25 *
|
||||||
rotateY({proximity * 25 * (isPast ? -1 : 1)}deg)
|
(isPast ? -1 : 1)}deg)
|
||||||
"
|
"
|
||||||
style:filter="brightness({1 + proximity * 0.2}) contrast({1 + proximity * 0.1})"
|
style:filter="brightness({1 + proximity * 0.2}) contrast({1 +
|
||||||
style:text-shadow={proximity > 0.5 ? '0 0 15px rgba(99,102,241,0.3)' : 'none'}
|
proximity * 0.1})"
|
||||||
style:will-change={proximity > 0 ? 'transform, font-family, color' : 'auto'}
|
style:text-shadow={proximity > 0.5
|
||||||
|
? '0 0 15px rgba(99,102,241,0.3)'
|
||||||
|
: 'none'}
|
||||||
|
style:will-change={proximity > 0
|
||||||
|
? 'transform, font-family, color'
|
||||||
|
: 'auto'}
|
||||||
>
|
>
|
||||||
{char === ' ' ? '\u00A0' : char}
|
{char === ' ' ? '\u00A0' : char}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -20,11 +20,16 @@ interface Props {
|
|||||||
container: HTMLElement;
|
container: HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { sliderPos, isDragging, typographyControls = $bindable<HTMLDivElement | null>(null), container }: Props = $props();
|
let {
|
||||||
|
sliderPos,
|
||||||
|
isDragging,
|
||||||
|
typographyControls = $bindable<HTMLDivElement | null>(null),
|
||||||
|
container,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
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 weight = $derived(comparisonStore.typography.weight);
|
const weight = $derived(comparisonStore.typography.weight);
|
||||||
|
|
||||||
const responsive = getContext<ResponsiveManager>('responsive');
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
@@ -41,8 +46,18 @@ $effect(() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fontAConfig = { id: fontA.id, name: fontA.name, url: fontAUrl, weight: weight };
|
const fontAConfig = {
|
||||||
const fontBConfig = { id: fontB.id, name: fontB.name, url: fontBUrl, weight: weight };
|
id: fontA.id,
|
||||||
|
name: fontA.name,
|
||||||
|
url: fontAUrl,
|
||||||
|
weight: weight,
|
||||||
|
};
|
||||||
|
const fontBConfig = {
|
||||||
|
id: fontB.id,
|
||||||
|
name: fontB.name,
|
||||||
|
url: fontBUrl,
|
||||||
|
weight: weight,
|
||||||
|
};
|
||||||
|
|
||||||
appliedFontsManager.touch([fontAConfig, fontBConfig]);
|
appliedFontsManager.touch([fontAConfig, fontBConfig]);
|
||||||
});
|
});
|
||||||
@@ -73,7 +88,6 @@ $effect(() => {
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
{:else}
|
{:else}
|
||||||
{#if !isLoading}
|
|
||||||
<div class="absolute top-3 sm:top-6 left-3 sm:left-6 z-50">
|
<div class="absolute top-3 sm:top-6 left-3 sm:left-6 z-50">
|
||||||
<TypographyControls
|
<TypographyControls
|
||||||
{sliderPos}
|
{sliderPos}
|
||||||
@@ -82,11 +96,8 @@ $effect(() => {
|
|||||||
containerWidth={container?.clientWidth}
|
containerWidth={container?.clientWidth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !isLoading}
|
|
||||||
<div class="absolute bottom-3 sm:bottom-6 md:bottom-8 inset-x-3 sm:inset-x-6 md:inset-x-12">
|
<div class="absolute bottom-3 sm:bottom-6 md:bottom-8 inset-x-3 sm:inset-x-6 md:inset-x-12">
|
||||||
<SelectComparedFonts {sliderPos} />
|
<SelectComparedFonts {sliderPos} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
TypographyMenu,
|
TypographyMenu,
|
||||||
controlManager,
|
controlManager,
|
||||||
} from '$features/SetupFont';
|
} from '$features/SetupFont';
|
||||||
|
import { throttle } from '$shared/lib/utils';
|
||||||
|
|
||||||
let text = $state('The quick brown fox jumps over the lazy dog...');
|
let text = $state('The quick brown fox jumps over the lazy dog...');
|
||||||
let wrapper = $state<HTMLDivElement | null>(null);
|
let wrapper = $state<HTMLDivElement | null>(null);
|
||||||
@@ -23,7 +24,9 @@ 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);
|
const isLoading = $derived(
|
||||||
|
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load more fonts by moving to the next page
|
* Load more fonts by moving to the next page
|
||||||
@@ -62,14 +65,14 @@ const displayRange = $derived.by(() => {
|
|||||||
return `Showing ${loadedCount} of ${total} fonts`;
|
return `Showing ${loadedCount} of ${total} fonts`;
|
||||||
});
|
});
|
||||||
|
|
||||||
function checkPosition() {
|
const checkPosition = throttle(() => {
|
||||||
if (!wrapper) return;
|
if (!wrapper) return;
|
||||||
|
|
||||||
const rect = wrapper.getBoundingClientRect();
|
const rect = wrapper.getBoundingClientRect();
|
||||||
const viewportMiddle = innerHeight / 2;
|
const viewportMiddle = innerHeight / 2;
|
||||||
|
|
||||||
isAboveMiddle = rect.top < viewportMiddle;
|
isAboveMiddle = rect.top < viewportMiddle;
|
||||||
}
|
}, 100);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
@@ -95,7 +98,12 @@ function checkPosition() {
|
|||||||
proximity,
|
proximity,
|
||||||
index,
|
index,
|
||||||
})}
|
})}
|
||||||
<FontListItem {font} {isFullyVisible} {isPartiallyVisible} {proximity}>
|
<FontListItem
|
||||||
|
{font}
|
||||||
|
{isFullyVisible}
|
||||||
|
{isPartiallyVisible}
|
||||||
|
{proximity}
|
||||||
|
>
|
||||||
<FontSampler {font} bind:text {index} />
|
<FontSampler {font} bind:text {index} />
|
||||||
</FontListItem>
|
</FontListItem>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
19
yarn.lock
19
yarn.lock
@@ -2459,6 +2459,7 @@ __metadata:
|
|||||||
dprint: "npm:^0.50.2"
|
dprint: "npm:^0.50.2"
|
||||||
jsdom: "npm:^27.4.0"
|
jsdom: "npm:^27.4.0"
|
||||||
lefthook: "npm:^2.0.13"
|
lefthook: "npm:^2.0.13"
|
||||||
|
lenis: "npm:^1.3.17"
|
||||||
oxlint: "npm:^1.35.0"
|
oxlint: "npm:^1.35.0"
|
||||||
playwright: "npm:^1.57.0"
|
playwright: "npm:^1.57.0"
|
||||||
storybook: "npm:^10.1.11"
|
storybook: "npm:^10.1.11"
|
||||||
@@ -2849,6 +2850,24 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"lenis@npm:^1.3.17":
|
||||||
|
version: 1.3.17
|
||||||
|
resolution: "lenis@npm:1.3.17"
|
||||||
|
peerDependencies:
|
||||||
|
"@nuxt/kit": ">=3.0.0"
|
||||||
|
react: ">=17.0.0"
|
||||||
|
vue: ">=3.0.0"
|
||||||
|
peerDependenciesMeta:
|
||||||
|
"@nuxt/kit":
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
vue:
|
||||||
|
optional: true
|
||||||
|
checksum: 10c0/c268da36d5711677b239c7d173bc52775276df08f86f7f89f305c4e02ba4055d8c50ea69125d16c94bb1e1999ccd95f654237d11c6647dc5fdf63aa90515fbfb
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"lightningcss-android-arm64@npm:1.30.2":
|
"lightningcss-android-arm64@npm:1.30.2":
|
||||||
version: 1.30.2
|
version: 1.30.2
|
||||||
resolution: "lightningcss-android-arm64@npm:1.30.2"
|
resolution: "lightningcss-android-arm64@npm:1.30.2"
|
||||||
|
|||||||
Reference in New Issue
Block a user