Merge pull request 'feature/ux-improvements' (#26) from feature/ux-improvements into main
Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
@@ -37,6 +37,28 @@
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
|
||||
--background-20: oklch(1 0 0 / 20%);
|
||||
--background-40: oklch(1 0 0 / 40%);
|
||||
--background-60: oklch(1 0 0 / 60%);
|
||||
--background-80: oklch(1 0 0 / 80%);
|
||||
--background-95: oklch(1 0 0 / 95%);
|
||||
--background-subtle: oklch(0.98 0 0);
|
||||
--background-muted: oklch(0.97 0.002 286.375);
|
||||
|
||||
--text-muted: oklch(0.552 0.016 285.938);
|
||||
--text-subtle: oklch(0.705 0.015 286.067);
|
||||
--text-soft: oklch(0.5 0.01 286);
|
||||
|
||||
--border-subtle: oklch(0.95 0.003 286.32);
|
||||
--border-muted: oklch(0.92 0.004 286.32);
|
||||
--border-soft: oklch(0.88 0.005 286.32);
|
||||
|
||||
--gradient-from: oklch(0.98 0.002 286.32);
|
||||
--gradient-via: oklch(1 0 0);
|
||||
--gradient-to: oklch(0.98 0.002 286.32);
|
||||
|
||||
--font-mono: 'Major Mono Display';
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -71,6 +93,26 @@
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
|
||||
--background-20: oklch(0.21 0.006 285.885 / 20%);
|
||||
--background-40: oklch(0.21 0.006 285.885 / 40%);
|
||||
--background-60: oklch(0.21 0.006 285.885 / 60%);
|
||||
--background-80: oklch(0.21 0.006 285.885 / 80%);
|
||||
--background-95: oklch(0.21 0.006 285.885 / 95%);
|
||||
--background-subtle: oklch(0.18 0.005 285.823);
|
||||
--background-muted: oklch(0.274 0.006 286.033);
|
||||
|
||||
--text-muted: oklch(0.705 0.015 286.067);
|
||||
--text-subtle: oklch(0.552 0.016 285.938);
|
||||
--text-soft: oklch(0.8 0.01 286);
|
||||
|
||||
--border-subtle: oklch(1 0 0 / 8%);
|
||||
--border-muted: oklch(1 0 0 / 10%);
|
||||
--border-soft: oklch(1 0 0 / 15%);
|
||||
|
||||
--gradient-from: oklch(0.25 0.005 285.885);
|
||||
--gradient-via: oklch(0.21 0.006 285.885);
|
||||
--gradient-to: oklch(0.25 0.005 285.885);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -109,6 +151,23 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-background-20: var(--background-20);
|
||||
--color-background-40: var(--background-40);
|
||||
--color-background-60: var(--background-60);
|
||||
--color-background-80: var(--background-80);
|
||||
--color-background-95: var(--background-95);
|
||||
--color-background-subtle: var(--background-subtle);
|
||||
--color-background-muted: var(--background-muted);
|
||||
--color-text-muted: var(--text-muted);
|
||||
--color-text-subtle: var(--text-subtle);
|
||||
--color-text-soft: var(--text-soft);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-border-muted: var(--border-muted);
|
||||
--color-border-soft: var(--border-soft);
|
||||
--color-gradient-from: var(--gradient-from);
|
||||
--color-gradient-via: var(--gradient-via);
|
||||
--color-gradient-to: var(--gradient-to);
|
||||
--font-mono: var(--font-mono);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -166,3 +225,82 @@
|
||||
.barlow {
|
||||
font-family: "Barlow", system-ui, Inter, Roboto, "Segoe UI", Arial, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(0 0% 70% / 0.4) transparent;
|
||||
}
|
||||
|
||||
.dark * {
|
||||
scrollbar-color: hsl(0 0% 40% / 0.5) transparent;
|
||||
}
|
||||
|
||||
/* ---- Webkit / Blink ---- */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 70% / 0);
|
||||
border-radius: 3px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
/* Show thumb when container is hovered or actively scrolling */
|
||||
:hover > ::-webkit-scrollbar-thumb,
|
||||
::-webkit-scrollbar-thumb:hover,
|
||||
*:hover::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 70% / 0.4);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(0 0% 50% / 0.6);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: hsl(0 0% 40% / 0.8);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 40% / 0);
|
||||
}
|
||||
|
||||
.dark :hover > ::-webkit-scrollbar-thumb,
|
||||
.dark ::-webkit-scrollbar-thumb:hover,
|
||||
.dark *:hover::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 40% / 0.5);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(0 0% 55% / 0.6);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:active {
|
||||
background: hsl(0 0% 65% / 0.7);
|
||||
}
|
||||
|
||||
/* ---- Behavior ---- */
|
||||
* {
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -55,30 +55,36 @@ onMount(async () => {
|
||||
<link rel="icon" href={GD} />
|
||||
|
||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||
<link rel="preconnect" href="https://cdn.fontshare.com" crossorigin="anonymous" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://cdn.fontshare.com"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
as="style"
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&display=swap"
|
||||
>
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||
media="print"
|
||||
onload={(e => ((e.currentTarget as HTMLLinkElement).media = 'all'))}
|
||||
>
|
||||
/>
|
||||
<noscript>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&display=swap"
|
||||
>
|
||||
href="https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,100;0,200;1,100;1,200&family=Karla:wght@200..800&family=Major+Mono+Display&display=swap"
|
||||
/>
|
||||
</noscript>
|
||||
<title>
|
||||
Compare Typography & Typefaces | GlyphDiff
|
||||
</title>
|
||||
<title>Compare Typography & Typefaces | GlyphDiff</title>
|
||||
</svelte:head>
|
||||
|
||||
<ResponsiveProvider>
|
||||
@@ -88,7 +94,7 @@ onMount(async () => {
|
||||
</header>
|
||||
|
||||
<!-- <ScrollArea class="h-screen w-screen"> -->
|
||||
<main class="flex-1 h-full w-full max-w-6xl mx-auto px-0 pt-0 pb-10 sm:px-6 sm:pt-8 sm:pb-12 md:px-8 md:pt-10 md:pb-16 lg:px-10 lg:pt-12 lg:pb-20 xl:px-16 relative overflow-x-hidden">
|
||||
<main class="flex-1 w-full mx-auto px-4 pt-0 pb-10 sm:px-6 sm:pt-8 sm:pb-12 md:px-8 md:pt-10 md:pb-16 lg:px-10 lg:pt-12 lg:pb-20 xl:px-16 relative">
|
||||
<TooltipProvider>
|
||||
{#if fontsReady}
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
/**
|
||||
* Index of the item to display
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* ID of the item to navigate to
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Title snippet to render
|
||||
*/
|
||||
title: Snippet<[{ className?: string }]>;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Fixed header for breadcrumbs navigation for sections in the page
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { smoothScroll } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
fly,
|
||||
@@ -16,8 +17,8 @@ import { scrollBreadcrumbsStore } from '../../model';
|
||||
transition:slide={{ duration: 200 }}
|
||||
class="
|
||||
fixed top-0 left-0 right-0 z-100
|
||||
backdrop-blur-lg bg-white/20
|
||||
border-b border-gray-300/50
|
||||
backdrop-blur-lg bg-background-20
|
||||
border-b border-border-muted
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
h-10 sm:h-12
|
||||
"
|
||||
@@ -27,7 +28,7 @@ import { scrollBreadcrumbsStore } from '../../model';
|
||||
GLYPHDIFF
|
||||
</h1>
|
||||
|
||||
<div class="h-3.5 sm:h-4 w-px bg-gray-300/60 hidden sm:block"></div>
|
||||
<div class="h-3.5 sm:h-4 w-px bg-border-subtle hidden sm:block"></div>
|
||||
|
||||
<nav class="flex items-center gap-2 sm:gap-3 overflow-x-auto scrollbar-hide flex-1">
|
||||
{#each scrollBreadcrumbsStore.items as item, idx (item.index)}
|
||||
@@ -36,19 +37,19 @@ import { scrollBreadcrumbsStore } from '../../model';
|
||||
out:fly={{ duration: 300, y: 10, x: 100, opacity: 0 }}
|
||||
class="flex items-center gap-2 sm:gap-3 whitespace-nowrap shrink-0"
|
||||
>
|
||||
<span class="font-mono text-[8px] sm:text-[9px] text-gray-400 tracking-wider">
|
||||
<span class="font-mono text-[8px] sm:text-[9px] text-text-muted tracking-wider">
|
||||
{String(item.index).padStart(2, '0')}
|
||||
</span>
|
||||
|
||||
{@render item.title({
|
||||
className: 'text-[9px] sm:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-gray-900',
|
||||
})}
|
||||
<a href={`#${item.id}`} use:smoothScroll>
|
||||
{@render item.title({
|
||||
className: 'text-[9px] sm:text-[10px] font-bold uppercase tracking-tight leading-[0.95] text-foreground',
|
||||
})}</a>
|
||||
|
||||
{#if idx < scrollBreadcrumbsStore.items.length - 1}
|
||||
<div class="flex items-center gap-0.5 opacity-40">
|
||||
<div class="w-1 h-px bg-gray-400"></div>
|
||||
<div class="w-1 h-px bg-gray-400"></div>
|
||||
<div class="w-1 h-px bg-gray-400"></div>
|
||||
<div class="w-1 h-px bg-text-muted"></div>
|
||||
<div class="w-1 h-px bg-text-muted"></div>
|
||||
<div class="w-1 h-px bg-text-muted"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -56,8 +57,8 @@ import { scrollBreadcrumbsStore } from '../../model';
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center gap-1.5 sm:gap-2 opacity-50 ml-auto">
|
||||
<div class="w-px h-2 sm:h-2.5 bg-gray-300/60 hidden sm:block"></div>
|
||||
<span class="font-mono text-[7px] sm:text-[8px] text-gray-400 tracking-wider">
|
||||
<div class="w-px h-2 sm:h-2.5 bg-border-subtle hidden sm:block"></div>
|
||||
<span class="font-mono text-[7px] sm:text-[8px] text-text-muted tracking-wider">
|
||||
[{scrollBreadcrumbsStore.items.length}]
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,67 +1,109 @@
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
|
||||
/** Loading state of a font. Failed loads may be retried up to MAX_RETRIES. */
|
||||
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||
|
||||
/** Configuration for a font load request. */
|
||||
export interface FontConfigRequest {
|
||||
/**
|
||||
* Font id
|
||||
* Unique identifier for the font (e.g., "lato", "roboto").
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Real font name (e.g. "Lato")
|
||||
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The .ttf URL
|
||||
* URL pointing to the font file (typically .ttf or .woff2).
|
||||
*/
|
||||
url: string;
|
||||
/**
|
||||
* Font weight
|
||||
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Flag of the variable weight
|
||||
* Variable fonts load once per ID; static fonts load per weight.
|
||||
*/
|
||||
isVariable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager that handles loading of fonts.
|
||||
* Logic:
|
||||
* - Variable fonts: Loaded once per id (covers all weights).
|
||||
* - Static fonts: Loaded per id + weight combination.
|
||||
* Manages web font loading with caching, adaptive concurrency, and automatic cleanup.
|
||||
*
|
||||
* **Two-Phase Loading Strategy:**
|
||||
* 1. *Concurrent Fetching*: Font files fetched in parallel (network I/O is non-blocking)
|
||||
* 2. *Sequential Parsing*: Buffers parsed into FontFace objects one at a time with periodic yields
|
||||
*
|
||||
* **Yielding Strategy:**
|
||||
* - Chromium: Yields only when user input is pending (via `scheduler.yield()` + `isInputPending()`)
|
||||
* - Others: Time-based fallback, yields every 8ms
|
||||
*
|
||||
* **Network Adaptation:**
|
||||
* - 2G: 1 concurrent request, 3G: 2, 4G+: 4 (via Network Information API)
|
||||
* - Respects `saveData` mode to defer non-critical weights
|
||||
*
|
||||
* **Cache Integration:** Cache API with best-effort fallback (handles private browsing, quota limits)
|
||||
*
|
||||
* **Cleanup:** LRU-style eviction after 5 minutes of inactivity; cleanup runs every 60 seconds
|
||||
*
|
||||
* **Font Identity:** Variable fonts use `{id}@vf`, static fonts use `{id}@{weight}`
|
||||
*
|
||||
* **Browser APIs Used:** `scheduler.yield()`, `isInputPending()`, `requestIdleCallback`, Cache API, Network Information API
|
||||
*/
|
||||
export class AppliedFontsManager {
|
||||
// Stores the actual FontFace objects for cleanup
|
||||
// Loaded FontFace instances registered with document.fonts. Key: `{id}@{weight}` or `{id}@vf`
|
||||
#loadedFonts = new Map<string, FontFace>();
|
||||
// Optimization: Map<batchId, Set<fontKeys>> to avoid O(N^2) scans
|
||||
#batchToKeys = new Map<string, Set<string>>();
|
||||
// Optimization: Map<fontKey, batchId> for reverse lookup
|
||||
#keyToBatch = new Map<string, string>();
|
||||
|
||||
// Last-used timestamps for LRU cleanup. Key: `{id}@{weight}` or `{id}@vf`, Value: unix timestamp (ms)
|
||||
#usageTracker = new Map<string, number>();
|
||||
|
||||
// Fonts queued for loading by `touch()`, processed by `#processQueue()`
|
||||
#queue = new Map<string, FontConfigRequest>();
|
||||
|
||||
// Handle for scheduled queue processing (requestIdleCallback or setTimeout)
|
||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly #PURGE_INTERVAL = 60000;
|
||||
readonly #TTL = 5 * 60 * 1000;
|
||||
readonly #CHUNK_SIZE = 5;
|
||||
// Interval handle for periodic cleanup (runs every PURGE_INTERVAL)
|
||||
#intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// AbortController for canceling in-flight fetches on destroy
|
||||
#abortController = new AbortController();
|
||||
|
||||
// Tracks which callback type is pending ('idle' | 'timeout' | null) for proper cancellation
|
||||
#pendingType: 'idle' | 'timeout' | null = null;
|
||||
|
||||
// Retry counts for failed loads; fonts exceeding MAX_RETRIES are permanently skipped
|
||||
#retryCounts = new Map<string, number>();
|
||||
|
||||
readonly #MAX_RETRIES = 3;
|
||||
readonly #PURGE_INTERVAL = 60000; // 60 seconds
|
||||
readonly #TTL = 5 * 60 * 1000; // 5 minutes
|
||||
readonly #CACHE_NAME = 'font-cache-v1'; // Versioned for future invalidation
|
||||
|
||||
// Reactive status map for Svelte components to track font states
|
||||
statuses = new SvelteMap<string, FontStatus>();
|
||||
|
||||
// Starts periodic cleanup timer (browser-only).
|
||||
constructor() {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Using a weak reference style approach isn't possible for DOM,
|
||||
// so we stick to the interval but make it highly efficient.
|
||||
setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
// Generates font key: `{id}@vf` for variable, `{id}@{weight}` for static.
|
||||
#getFontKey(id: string, weight: number, isVariable: boolean): string {
|
||||
return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests fonts to be loaded. Updates usage tracking and queues new fonts.
|
||||
*
|
||||
* Retry behavior: 'loaded' and 'loading' fonts are skipped; 'error' fonts retry if count < MAX_RETRIES.
|
||||
* Scheduling: Prefers requestIdleCallback (150ms timeout), falls back to setTimeout(16ms).
|
||||
*/
|
||||
touch(configs: FontConfigRequest[]) {
|
||||
if (this.#abortController.signal.aborted) return;
|
||||
|
||||
const now = Date.now();
|
||||
let hasNewItems = false;
|
||||
|
||||
@@ -69,105 +111,244 @@ export class AppliedFontsManager {
|
||||
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
|
||||
this.#usageTracker.set(key, now);
|
||||
|
||||
if (this.statuses.get(key) === 'loaded' || this.statuses.get(key) === 'loading' || this.#queue.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const status = this.statuses.get(key);
|
||||
if (status === 'loaded' || status === 'loading' || this.#queue.has(key)) continue;
|
||||
if (status === 'error' && (this.#retryCounts.get(key) ?? 0) >= this.#MAX_RETRIES) continue;
|
||||
|
||||
this.#queue.set(key, config);
|
||||
hasNewItems = true;
|
||||
}
|
||||
|
||||
// IMPROVEMENT: Only trigger timer if not already pending
|
||||
if (hasNewItems && !this.#timeoutId) {
|
||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16); // ~1 frame delay
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
this.#timeoutId = requestIdleCallback(
|
||||
() => this.#processQueue(),
|
||||
{ timeout: 150 },
|
||||
) as unknown as ReturnType<typeof setTimeout>;
|
||||
this.#pendingType = 'idle';
|
||||
} else {
|
||||
this.#timeoutId = setTimeout(() => this.#processQueue(), 16);
|
||||
this.#pendingType = 'timeout';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#processQueue() {
|
||||
this.#timeoutId = null;
|
||||
const entries = Array.from(this.#queue.entries());
|
||||
if (entries.length === 0) return;
|
||||
/** Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() (Chrome/Edge) or MessageChannel fallback. */
|
||||
async #yieldToMain(): Promise<void> {
|
||||
// @ts-expect-error - scheduler not in TypeScript lib yet
|
||||
if (typeof scheduler !== 'undefined' && 'yield' in scheduler) {
|
||||
// @ts-expect-error - scheduler.yield not in TypeScript lib yet
|
||||
await scheduler.yield();
|
||||
} else {
|
||||
await new Promise<void>(resolve => {
|
||||
const ch = new MessageChannel();
|
||||
ch.port1.onmessage = () => resolve();
|
||||
ch.port2.postMessage(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns optimal concurrent fetches based on Network Information API: 1 for 2G, 2 for 3G, 4 for 4G/default. */
|
||||
#getEffectiveConcurrency(): number {
|
||||
const nav = navigator as any;
|
||||
const conn = nav.connection;
|
||||
if (!conn) return 4;
|
||||
|
||||
switch (conn.effectiveType) {
|
||||
case 'slow-2g':
|
||||
case '2g':
|
||||
return 1;
|
||||
case '3g':
|
||||
return 2;
|
||||
default:
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if data-saver mode is enabled (defers non-critical weights). */
|
||||
#shouldDeferNonCritical(): boolean {
|
||||
const nav = navigator as any;
|
||||
return nav.connection?.saveData === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes queued fonts in two phases:
|
||||
* 1. Concurrent fetching (network I/O, non-blocking)
|
||||
* 2. Sequential parsing with periodic yields (CPU-intensive, can block UI)
|
||||
*
|
||||
* Yielding: Chromium uses `isInputPending()` for optimal responsiveness; others yield every 8ms.
|
||||
*/
|
||||
async #processQueue() {
|
||||
this.#timeoutId = null;
|
||||
this.#pendingType = null;
|
||||
|
||||
let entries = Array.from(this.#queue.entries());
|
||||
if (!entries.length) return;
|
||||
this.#queue.clear();
|
||||
|
||||
// Process in chunks to keep the UI responsive
|
||||
for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
|
||||
this.#applyBatch(entries.slice(i, i + this.#CHUNK_SIZE));
|
||||
if (this.#shouldDeferNonCritical()) {
|
||||
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
||||
}
|
||||
}
|
||||
|
||||
async #applyBatch(batchEntries: [string, FontConfigRequest][]) {
|
||||
if (typeof document === 'undefined') return;
|
||||
// Phase 1: Concurrent fetching (I/O bound, non-blocking)
|
||||
const concurrency = this.#getEffectiveConcurrency();
|
||||
const buffers = new Map<string, ArrayBuffer>();
|
||||
|
||||
const batchId = crypto.randomUUID();
|
||||
const keysInBatch = new Set<string>();
|
||||
for (let i = 0; i < entries.length; i += concurrency) {
|
||||
const chunk = entries.slice(i, i + concurrency);
|
||||
const results = await Promise.allSettled(
|
||||
chunk.map(async ([key, config]) => {
|
||||
this.statuses.set(key, 'loading');
|
||||
const buffer = await this.#fetchFontBuffer(
|
||||
config.url,
|
||||
this.#abortController.signal,
|
||||
);
|
||||
buffers.set(key, buffer);
|
||||
}),
|
||||
);
|
||||
|
||||
const loadPromises = batchEntries.map(([key, config]) => {
|
||||
this.statuses.set(key, 'loading');
|
||||
this.#keyToBatch.set(key, batchId);
|
||||
keysInBatch.add(key);
|
||||
|
||||
// Use a unique internal family name to prevent collisions
|
||||
// while keeping the "real" name for the browser to resolve weight/style.
|
||||
const internalName = `f_${config.id}`;
|
||||
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
|
||||
|
||||
const font = new FontFace(config.name, `url(${config.url}) format('woff2')`, {
|
||||
weight: weightRange,
|
||||
style: 'normal',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
this.#loadedFonts.set(key, font);
|
||||
|
||||
return font.load()
|
||||
.then(loadedFace => {
|
||||
document.fonts.add(loadedFace);
|
||||
this.statuses.set(key, 'loaded');
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(`Font load failed: ${config.name}`, e);
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
if (results[j].status === 'rejected') {
|
||||
const [key, config] = chunk[j];
|
||||
console.error(`Font fetch failed: ${config.name}`, (results[j] as PromiseRejectedResult).reason);
|
||||
this.statuses.set(key, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
this.#batchToKeys.set(batchId, keysInBatch);
|
||||
await Promise.allSettled(loadPromises);
|
||||
}
|
||||
|
||||
#purgeUnused() {
|
||||
const now = Date.now();
|
||||
|
||||
// We iterate over batches, not individual fonts, to reduce loops
|
||||
for (const [batchId, keys] of this.#batchToKeys.entries()) {
|
||||
let canPurgeBatch = true;
|
||||
|
||||
for (const key of keys) {
|
||||
const lastUsed = this.#usageTracker.get(key) || 0;
|
||||
if (now - lastUsed < this.#TTL) {
|
||||
canPurgeBatch = false;
|
||||
break;
|
||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canPurgeBatch) {
|
||||
keys.forEach(key => {
|
||||
const font = this.#loadedFonts.get(key);
|
||||
if (font) document.fonts.delete(font);
|
||||
// Phase 2: Sequential parsing (CPU-intensive, yields periodically)
|
||||
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||
let lastYield = performance.now();
|
||||
const YIELD_INTERVAL = 8; // ms
|
||||
|
||||
this.#loadedFonts.delete(key);
|
||||
this.#keyToBatch.delete(key);
|
||||
this.#usageTracker.delete(key);
|
||||
this.statuses.delete(key);
|
||||
for (const [key, config] of entries) {
|
||||
const buffer = buffers.get(key);
|
||||
if (!buffer) continue;
|
||||
|
||||
try {
|
||||
const weightRange = config.isVariable ? '100 900' : `${config.weight}`;
|
||||
const font = new FontFace(config.name, buffer, {
|
||||
weight: weightRange,
|
||||
style: 'normal',
|
||||
display: 'swap',
|
||||
});
|
||||
this.#batchToKeys.delete(batchId);
|
||||
await font.load();
|
||||
document.fonts.add(font);
|
||||
this.#loadedFonts.set(key, font);
|
||||
this.statuses.set(key, 'loaded');
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.name === 'AbortError') continue;
|
||||
console.error(`Font parse failed: ${config.name}`, e);
|
||||
this.statuses.set(key, 'error');
|
||||
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const shouldYield = hasInputPending
|
||||
? (navigator as any).scheduling.isInputPending({ includeContinuous: true })
|
||||
: (performance.now() - lastYield > YIELD_INTERVAL);
|
||||
|
||||
if (shouldYield) {
|
||||
await this.#yieldToMain();
|
||||
lastYield = performance.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches font with cache-aside pattern: checks Cache API first, falls back to network.
|
||||
* Cache failures (private browsing, quota limits) are silently ignored.
|
||||
*/
|
||||
async #fetchFontBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
||||
try {
|
||||
if (typeof caches !== 'undefined') {
|
||||
const cache = await caches.open(this.#CACHE_NAME);
|
||||
const cached = await cache.match(url);
|
||||
if (cached) return cached.arrayBuffer();
|
||||
}
|
||||
} catch {
|
||||
// Cache unavailable (private browsing, security restrictions) — fall through to network
|
||||
}
|
||||
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
try {
|
||||
if (typeof caches !== 'undefined') {
|
||||
const cache = await caches.open(this.#CACHE_NAME);
|
||||
await cache.put(url, response.clone());
|
||||
}
|
||||
} catch {
|
||||
// Cache write failed (quota, storage pressure) — return font anyway
|
||||
}
|
||||
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
/** Removes fonts unused within TTL (LRU-style cleanup). Runs every PURGE_INTERVAL. */
|
||||
#purgeUnused() {
|
||||
const now = Date.now();
|
||||
for (const [key, lastUsed] of this.#usageTracker) {
|
||||
if (now - lastUsed < this.#TTL) continue;
|
||||
|
||||
const font = this.#loadedFonts.get(key);
|
||||
if (font) document.fonts.delete(font);
|
||||
|
||||
this.#loadedFonts.delete(key);
|
||||
this.#usageTracker.delete(key);
|
||||
this.statuses.delete(key);
|
||||
this.#retryCounts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns current loading status for a font, or undefined if never requested. */
|
||||
getFontStatus(id: string, weight: number, isVariable = false) {
|
||||
return this.statuses.get(this.#getFontKey(id, weight, isVariable));
|
||||
}
|
||||
|
||||
/** Waits for all fonts to finish loading using document.fonts.ready. */
|
||||
async ready(): Promise<void> {
|
||||
if (typeof document === 'undefined') return;
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
} catch {
|
||||
// document.fonts.ready can reject in some edge cases
|
||||
// (e.g., document unloaded). Silently resolve.
|
||||
}
|
||||
}
|
||||
|
||||
/** Aborts all operations, removes fonts from document, and clears state. Manager cannot be reused after. */
|
||||
destroy() {
|
||||
this.#abortController.abort();
|
||||
|
||||
if (this.#timeoutId !== null) {
|
||||
if (this.#pendingType === 'idle' && typeof cancelIdleCallback !== 'undefined') {
|
||||
cancelIdleCallback(this.#timeoutId as unknown as number);
|
||||
} else {
|
||||
clearTimeout(this.#timeoutId);
|
||||
}
|
||||
this.#timeoutId = null;
|
||||
this.#pendingType = null;
|
||||
}
|
||||
|
||||
if (this.#intervalId) {
|
||||
clearInterval(this.#intervalId);
|
||||
this.#intervalId = null;
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
for (const font of this.#loadedFonts.values()) {
|
||||
document.fonts.delete(font);
|
||||
}
|
||||
}
|
||||
|
||||
this.#loadedFonts.clear();
|
||||
this.#usageTracker.clear();
|
||||
this.#retryCounts.clear();
|
||||
this.statuses.clear();
|
||||
this.#queue.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** Singleton instance — use throughout the application for unified font loading state. */
|
||||
export const appliedFontsManager = new AppliedFontsManager();
|
||||
|
||||
@@ -215,7 +215,6 @@ export class UnifiedFontStore extends BaseFontStore<ProxyFontsParams> {
|
||||
// Note: For offset === 0, we rely on the $effect above to handle the reset/init
|
||||
// This prevents race conditions and double-setting.
|
||||
if (params.offset !== 0) {
|
||||
// Append new fonts to existing ones only for pagination
|
||||
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
Component: FontApplicator
|
||||
Loads fonts from fontshare with link tag
|
||||
- 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
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getFontUrl } from '$entities/Font/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
@@ -34,46 +33,23 @@ interface Props {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { font, weight = 400, className, children }: Props = $props();
|
||||
let element: Element;
|
||||
let {
|
||||
font,
|
||||
weight = 400,
|
||||
className,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
// Track if the user has actually scrolled this into view
|
||||
let hasEnteredViewport = $state(false);
|
||||
const status = $derived(appliedFontsManager.getFontStatus(font.id, weight, font.features.isVariable));
|
||||
const status = $derived(
|
||||
appliedFontsManager.getFontStatus(
|
||||
font.id,
|
||||
weight,
|
||||
font.features.isVariable,
|
||||
),
|
||||
);
|
||||
|
||||
$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,
|
||||
name: font.name,
|
||||
url,
|
||||
isVariable: font.features.isVariable,
|
||||
}]);
|
||||
}
|
||||
|
||||
observer.unobserve(element);
|
||||
}
|
||||
});
|
||||
|
||||
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'));
|
||||
// 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');
|
||||
|
||||
const transitionClasses = $derived(
|
||||
prefersReducedMotion.current
|
||||
@@ -83,12 +59,14 @@ const transitionClasses = $derived(
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={element}
|
||||
style:font-family={shouldReveal ? `'${font.name}'` : 'system-ui, -apple-system, sans-serif'}
|
||||
style:font-family={shouldReveal
|
||||
? `'${font.name}'`
|
||||
: 'system-ui, -apple-system, sans-serif'}
|
||||
class={cn(
|
||||
transitionClasses,
|
||||
// If reduced motion is on, we skip the transform/blur entirely
|
||||
!shouldReveal && !prefersReducedMotion.current
|
||||
!shouldReveal
|
||||
&& !prefersReducedMotion.current
|
||||
&& 'opacity-50 scale-[0.95] blur-sm',
|
||||
!shouldReveal && prefersReducedMotion.current && 'opacity-0', // Still hide until font is ready, but no movement
|
||||
shouldReveal && 'opacity-100 scale-100 blur-0',
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<!--
|
||||
Component: FontListItem
|
||||
Displays a font item and manages its animations
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import { type UnifiedFont } from '../../model';
|
||||
|
||||
interface Props {
|
||||
@@ -31,51 +26,14 @@ interface Props {
|
||||
children: Snippet<[font: UnifiedFont]>;
|
||||
}
|
||||
|
||||
const { font, isFullyVisible, isPartiallyVisible, proximity, 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);
|
||||
}
|
||||
const { font, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('pb-1 will-change-transform')}
|
||||
style:opacity={bloom.current}
|
||||
style:transform="
|
||||
scale({0.92 + (bloom.current * 0.08)})
|
||||
translateY({(1 - bloom.current) * 10}px)
|
||||
"
|
||||
class={cn(
|
||||
'pb-1 will-change-transform transition-transform duration-200 ease-out',
|
||||
'hover:scale-[0.98]', // Simple CSS hover effect
|
||||
)}
|
||||
>
|
||||
{@render children?.(font)}
|
||||
</div>
|
||||
|
||||
@@ -3,58 +3,57 @@
|
||||
- Renders a virtualized list of fonts
|
||||
- Handles font registration with the manager
|
||||
-->
|
||||
<script lang="ts" generics="T extends UnifiedFont">
|
||||
<script lang="ts">
|
||||
import {
|
||||
Skeleton,
|
||||
VirtualList,
|
||||
} from '$shared/ui';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import type {
|
||||
ComponentProps,
|
||||
Snippet,
|
||||
} from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { getFontUrl } from '../../lib';
|
||||
import type { FontConfigRequest } from '../../model';
|
||||
import {
|
||||
type FontConfigRequest,
|
||||
type UnifiedFont,
|
||||
appliedFontsManager,
|
||||
unifiedFontStore,
|
||||
} from '../../model';
|
||||
|
||||
interface Props extends
|
||||
Omit<
|
||||
ComponentProps<typeof VirtualList<T>>,
|
||||
'onVisibleItemsChange'
|
||||
ComponentProps<typeof VirtualList<UnifiedFont>>,
|
||||
'items' | 'total' | 'isLoading' | 'onVisibleItemsChange' | 'onNearBottom'
|
||||
>
|
||||
{
|
||||
/**
|
||||
* Callback for when visible items change
|
||||
*/
|
||||
onVisibleItemsChange?: (items: T[]) => void;
|
||||
/**
|
||||
* Callback for when near bottom is reached
|
||||
*/
|
||||
onNearBottom?: (lastVisibleIndex: number) => void;
|
||||
/**
|
||||
* Weight of the font
|
||||
*/
|
||||
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
|
||||
/**
|
||||
* Weight of the font
|
||||
*/
|
||||
weight: number;
|
||||
/**
|
||||
* Whether the list is in a loading state
|
||||
* Skeleton snippet
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
skeleton?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
children,
|
||||
onVisibleItemsChange,
|
||||
onNearBottom,
|
||||
weight,
|
||||
isLoading = false,
|
||||
skeleton,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
function handleInternalVisibleChange(visibleItems: T[]) {
|
||||
const isLoading = $derived(
|
||||
unifiedFontStore.isFetching || unifiedFontStore.isLoading,
|
||||
);
|
||||
|
||||
function handleInternalVisibleChange(visibleItems: UnifiedFont[]) {
|
||||
const configs: FontConfigRequest[] = [];
|
||||
|
||||
visibleItems.forEach(item => {
|
||||
@@ -77,37 +76,54 @@ function handleInternalVisibleChange(visibleItems: T[]) {
|
||||
// onVisibleItemsChange?.(visibleItems);
|
||||
}
|
||||
|
||||
function handleNearBottom(lastVisibleIndex: number) {
|
||||
// Forward the call to any external listener
|
||||
onNearBottom?.(lastVisibleIndex);
|
||||
/**
|
||||
* Load more fonts by moving to the next page
|
||||
*/
|
||||
function loadMore() {
|
||||
if (
|
||||
!unifiedFontStore.pagination.hasMore
|
||||
|| unifiedFontStore.isFetching
|
||||
) {
|
||||
return;
|
||||
}
|
||||
unifiedFontStore.nextPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll near bottom - auto-load next page
|
||||
*
|
||||
* Triggered by VirtualList when the user scrolls within 5 items of the end
|
||||
* of the loaded items. Only fetches if there are more pages available.
|
||||
*/
|
||||
function handleNearBottom(_lastVisibleIndex: number) {
|
||||
const { hasMore } = unifiedFontStore.pagination;
|
||||
|
||||
// VirtualList already checks if we're near the bottom of loaded items
|
||||
if (hasMore && !unifiedFontStore.isFetching) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key isLoading}
|
||||
<div class="relative w-full h-full" transition:fade={{ duration: 300 }}>
|
||||
{#if isLoading}
|
||||
<div class="flex flex-col gap-3 sm:gap-4 p-3 sm:p-4">
|
||||
{#each Array(5) as _, i}
|
||||
<div class="flex flex-col gap-1.5 sm:gap-2 p-3 sm:p-4 border rounded-lg sm:rounded-xl border-gray-200/50 bg-white/40">
|
||||
<div class="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<Skeleton class="h-6 sm:h-8 w-1/3" />
|
||||
<Skeleton class="h-6 sm:h-8 w-6 sm:w-8 rounded-full" />
|
||||
</div>
|
||||
<Skeleton class="h-24 sm:h-32 w-full" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<VirtualList
|
||||
{items}
|
||||
{...rest}
|
||||
onVisibleItemsChange={handleInternalVisibleChange}
|
||||
onNearBottom={handleNearBottom}
|
||||
>
|
||||
{#snippet children(scope)}
|
||||
{@render children(scope)}
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
{/if}
|
||||
</div>
|
||||
{/key}
|
||||
<div class="relative w-full h-full">
|
||||
{#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
|
||||
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
||||
<div transition:fade={{ duration: 300 }}>
|
||||
{@render skeleton()}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||
<VirtualList
|
||||
items={unifiedFontStore.fonts}
|
||||
total={unifiedFontStore.pagination.total}
|
||||
isLoading={isLoading}
|
||||
onVisibleItemsChange={handleInternalVisibleChange}
|
||||
onNearBottom={handleNearBottom}
|
||||
{...rest}
|
||||
>
|
||||
{#snippet children(scope)}
|
||||
{@render children(scope)}
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -36,12 +36,7 @@ interface Props {
|
||||
letterSpacing?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
font,
|
||||
text = $bindable(),
|
||||
index = 0,
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
let { font, text = $bindable(), index = 0, ...restProps }: Props = $props();
|
||||
|
||||
const fontWeight = $derived(controlManager.weight);
|
||||
const fontSize = $derived(controlManager.renderedSize);
|
||||
@@ -53,22 +48,22 @@ const letterSpacing = $derived(controlManager.spacing);
|
||||
class="
|
||||
w-full h-full rounded-xl sm:rounded-2xl
|
||||
flex flex-col
|
||||
backdrop-blur-md bg-white/80
|
||||
border border-gray-300/50
|
||||
bg-background-80
|
||||
border border-border-muted
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
relative overflow-hidden
|
||||
"
|
||||
style:font-weight={fontWeight}
|
||||
>
|
||||
<div class="px-4 sm:px-5 md:px-6 py-2.5 sm:py-3 border-b border-gray-200/60 flex items-center justify-between">
|
||||
<div class="px-4 sm:px-5 md:px-6 py-2.5 sm:py-3 border-b border-border-subtle flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 sm:gap-2.5">
|
||||
<Footnote>
|
||||
typeface_{String(index).padStart(3, '0')}
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 bg-gray-300/60"></div>
|
||||
<Footnote class="tracking-[0.15em] font-bold text-gray-900">
|
||||
<div class="w-px h-2 sm:h-2.5 bg-border-subtle"></div>
|
||||
<div class="font-bold text-foreground">
|
||||
{font.name}
|
||||
</Footnote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
@@ -86,28 +81,28 @@ const letterSpacing = $derived(controlManager.spacing);
|
||||
<div class="p-4 sm:p-5 md:p-8 relative z-10">
|
||||
<FontApplicator {font} weight={fontWeight}>
|
||||
<ContentEditable
|
||||
bind:text={text}
|
||||
bind:text
|
||||
{...restProps}
|
||||
fontSize={fontSize}
|
||||
lineHeight={lineHeight}
|
||||
letterSpacing={letterSpacing}
|
||||
{fontSize}
|
||||
{lineHeight}
|
||||
{letterSpacing}
|
||||
/>
|
||||
</FontApplicator>
|
||||
</div>
|
||||
|
||||
<div class="px-4 sm:px-5 md:px-6 py-1.5 sm:py-2 border-t border-gray-200/40 w-full flex flex-row gap-2 sm:gap-4 bg-gray-50/30 mt-auto">
|
||||
<div class="px-4 sm:px-5 md:px-6 py-1.5 sm:py-2 border-t border-border-subtle w-full flex flex-row gap-2 sm:gap-4 bg-background mt-auto">
|
||||
<Footnote class="text-[7px] sm:text-[8px] tracking-wider ml-auto">
|
||||
SZ:{fontSize}PX
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
|
||||
WGT:{fontWeight}
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||
<Footnote class="text-[7px] sm:text-[8px] tracking-wider">
|
||||
LH:{lineHeight?.toFixed(2)}
|
||||
</Footnote>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-gray-300/60 hidden sm:block"></div>
|
||||
<div class="w-px h-2 sm:h-2.5 self-center bg-border-subtle hidden sm:block"></div>
|
||||
<Footnote class="text-[0.4375rem] sm:text-[0.5rem] tracking-wider">
|
||||
LTR:{letterSpacing}
|
||||
</Footnote>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Drawer,
|
||||
IconButton,
|
||||
} from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
|
||||
import { getContext } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
@@ -72,7 +73,11 @@ $effect(() => {
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('w-auto max-screen z-10 flex justify-center', hidden && 'hidden', className)}
|
||||
class={cn(
|
||||
'w-auto max-screen z-10 flex justify-center',
|
||||
hidden && 'hidden',
|
||||
className,
|
||||
)}
|
||||
in:receive={{ key: 'panel' }}
|
||||
out:send={{ key: 'panel' }}
|
||||
>
|
||||
@@ -86,11 +91,17 @@ $effect(() => {
|
||||
</IconButton>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, 'flex flex-col gap-6')}>
|
||||
<Label
|
||||
class="mt-6 mb-12 px-2"
|
||||
text="Typography Controls"
|
||||
align="center"
|
||||
/>
|
||||
<div class={cn(className, 'flex flex-col gap-8')}>
|
||||
{#each controlManager.controls as control (control.id)}
|
||||
<ComboControlV2
|
||||
control={control.instance}
|
||||
orientation="horizontal"
|
||||
label={control.controlLabel}
|
||||
reduced
|
||||
/>
|
||||
{/each}
|
||||
@@ -112,6 +123,7 @@ $effect(() => {
|
||||
decreaseLabel={control.decreaseLabel}
|
||||
controlLabel={control.controlLabel}
|
||||
orientation="vertical"
|
||||
showScale={false}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { scrollBreadcrumbsStore } from '$entities/Breadcrumb';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
Logo,
|
||||
Section,
|
||||
@@ -15,17 +17,26 @@ import CodeIcon from '@lucide/svelte/icons/code';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import LineSquiggleIcon from '@lucide/svelte/icons/line-squiggle';
|
||||
import ScanSearchIcon from '@lucide/svelte/icons/search';
|
||||
import type { Snippet } from 'svelte';
|
||||
import {
|
||||
type Snippet,
|
||||
getContext,
|
||||
} from 'svelte';
|
||||
import { cubicIn } from 'svelte/easing';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let searchContainer: HTMLElement;
|
||||
|
||||
let isExpanded = $state(false);
|
||||
let isExpanded = $state(true);
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippet<[{ className?: string }]>) {
|
||||
function handleTitleStatusChanged(
|
||||
index: number,
|
||||
isPast: boolean,
|
||||
title?: Snippet<[{ className?: string }]>,
|
||||
id?: string,
|
||||
) {
|
||||
if (isPast && title) {
|
||||
scrollBreadcrumbsStore.add({ index, title });
|
||||
scrollBreadcrumbsStore.add({ index, title, id });
|
||||
} else {
|
||||
scrollBreadcrumbsStore.remove(index);
|
||||
}
|
||||
@@ -34,35 +45,38 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
|
||||
scrollBreadcrumbsStore.remove(index);
|
||||
};
|
||||
}
|
||||
|
||||
// $effect(() => {
|
||||
// appliedFontsManager.touch(
|
||||
// selectedFontsStore.all.map(font => ({
|
||||
// slug: font.id,
|
||||
// weight: controlManager.weight,
|
||||
// })),
|
||||
// );
|
||||
// });
|
||||
</script>
|
||||
|
||||
<!-- Font List -->
|
||||
<div
|
||||
class="p-2 sm:p-3 md:p-4 h-full flex flex-col gap-3 sm:gap-4"
|
||||
class="p-2 sm:p-3 md:p-4 h-full grid gap-3 sm:gap-4 grid-cols-[max-content_1fr]"
|
||||
in:fade={{ duration: 500, delay: 150, easing: cubicIn }}
|
||||
>
|
||||
<Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" onTitleStatusChange={handleTitleStatusChanged}>
|
||||
<Section
|
||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-8"
|
||||
onTitleStatusChange={handleTitleStatusChanged}
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<CodeIcon class={className} />
|
||||
{/snippet}
|
||||
{#snippet description({ className })}
|
||||
<span class={className}>
|
||||
Project_Codename
|
||||
</span>
|
||||
<span class={className}> Project_Codename </span>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, 'col-start-0 col-span-2')}>
|
||||
<Logo />
|
||||
</div>
|
||||
{/snippet}
|
||||
<Logo />
|
||||
</Section>
|
||||
|
||||
<Section class="my-12 gap-8" index={1} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
<Section
|
||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||
index={1}
|
||||
id="optical_comparator"
|
||||
onTitleStatusChange={handleTitleStatusChanged}
|
||||
stickyTitle={responsive.isDesktopLarge}
|
||||
stickyOffset="4rem"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<EyeIcon class={className} />
|
||||
{/snippet}
|
||||
@@ -71,10 +85,21 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
|
||||
Optical<br />Comparator
|
||||
</h1>
|
||||
{/snippet}
|
||||
<ComparisonSlider />
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<ComparisonSlider />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Section>
|
||||
|
||||
<Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" index={2} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
<Section
|
||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||
index={2}
|
||||
id="query_module"
|
||||
onTitleStatusChange={handleTitleStatusChanged}
|
||||
stickyTitle={responsive.isDesktopLarge}
|
||||
stickyOffset="4rem"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<ScanSearchIcon class={className} />
|
||||
{/snippet}
|
||||
@@ -83,10 +108,21 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
|
||||
Query<br />Module
|
||||
</h2>
|
||||
{/snippet}
|
||||
<FontSearch bind:showFilters={isExpanded} />
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<FontSearch bind:showFilters={isExpanded} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Section>
|
||||
|
||||
<Section class="my-4 sm:my-10 md:my-12 gap-6 sm:gap-8" index={3} onTitleStatusChange={handleTitleStatusChanged}>
|
||||
<Section
|
||||
class="py-4 sm:py-10 md:py-12 gap-6 sm:gap-x-12 sm:gap-y-8"
|
||||
index={3}
|
||||
id="sample_set"
|
||||
onTitleStatusChange={handleTitleStatusChanged}
|
||||
stickyTitle={responsive.isDesktopLarge}
|
||||
stickyOffset="4rem"
|
||||
>
|
||||
{#snippet icon({ className })}
|
||||
<LineSquiggleIcon class={className} />
|
||||
{/snippet}
|
||||
@@ -95,18 +131,22 @@ function handleTitleStatusChanged(index: number, isPast: boolean, title?: Snippe
|
||||
Sample<br />Set
|
||||
</h2>
|
||||
{/snippet}
|
||||
<SampleList />
|
||||
{#snippet content({ className })}
|
||||
<div class={cn(className, !responsive.isDesktopLarge && 'col-start-0 col-span-2')}>
|
||||
<SampleList />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
/* Tells the browser to skip rendering off-screen content */
|
||||
content-visibility: auto;
|
||||
/* Helps the browser reserve space without calculating everything */
|
||||
contain-intrinsic-size: 1px 1000px;
|
||||
/* Tells the browser to skip rendering off-screen content */
|
||||
content-visibility: auto;
|
||||
/* Helps the browser reserve space without calculating everything */
|
||||
contain-intrinsic-size: 1px 1000px;
|
||||
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
* Interface representing a line of text with its measured width.
|
||||
*/
|
||||
export interface LineData {
|
||||
/**
|
||||
* Line's text
|
||||
*/
|
||||
text: string;
|
||||
/**
|
||||
* It's width
|
||||
*/
|
||||
width: number;
|
||||
}
|
||||
|
||||
@@ -80,16 +86,23 @@ export function createCharacterComparison<
|
||||
container: HTMLElement | undefined,
|
||||
measureCanvas: HTMLCanvasElement | undefined,
|
||||
) {
|
||||
if (!container || !measureCanvas || !fontA() || !fontB()) return;
|
||||
if (!container || !measureCanvas || !fontA() || !fontB()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
containerWidth = rect.width;
|
||||
// Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues
|
||||
// getBoundingClientRect() returns transformed dimensions, which causes incorrect line breaking
|
||||
// when PerspectivePlan applies scale() transforms (e.g., scale(0.5) in settings mode)
|
||||
const width = container.offsetWidth;
|
||||
containerWidth = width;
|
||||
|
||||
// Padding considerations - matches the container padding
|
||||
const padding = window.innerWidth < 640 ? 48 : 96;
|
||||
const availableWidth = rect.width - padding;
|
||||
const availableWidth = width - padding;
|
||||
const ctx = measureCanvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controlledFontSize = size();
|
||||
const fontSize = getFontSize();
|
||||
@@ -276,3 +289,5 @@ export function createCharacterComparison<
|
||||
getCharState,
|
||||
};
|
||||
}
|
||||
|
||||
export type CharacterComparison = ReturnType<typeof createCharacterComparison>;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Spring } from 'svelte/motion';
|
||||
|
||||
export interface PerspectiveConfig {
|
||||
/**
|
||||
* How many px to move back per level
|
||||
*/
|
||||
depthStep?: number;
|
||||
/**
|
||||
* Scale reduction per level
|
||||
*/
|
||||
scaleStep?: number;
|
||||
/**
|
||||
* Blur amount per level
|
||||
*/
|
||||
blurStep?: number;
|
||||
/**
|
||||
* Opacity reduction per level
|
||||
*/
|
||||
opacityStep?: number;
|
||||
/**
|
||||
* Parallax intensity per level
|
||||
*/
|
||||
parallaxIntensity?: number;
|
||||
/**
|
||||
* Horizontal offset for each plan (x-axis positioning)
|
||||
* Positive = right, Negative = left
|
||||
*/
|
||||
horizontalOffset?: number;
|
||||
/**
|
||||
* Layout mode: 'center' (default) or 'split' for Swiss-style side-by-side
|
||||
*/
|
||||
layoutMode?: 'center' | 'split';
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages perspective state with a simple boolean flag.
|
||||
*
|
||||
* Drastically simplified from the complex camera/index system.
|
||||
* Just manages whether content is in "back" or "front" state.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const perspective = createPerspectiveManager({
|
||||
* depthStep: 100,
|
||||
* scaleStep: 0.5,
|
||||
* blurStep: 4,
|
||||
* });
|
||||
*
|
||||
* // Toggle back/front
|
||||
* perspective.toggle();
|
||||
*
|
||||
* // Check state
|
||||
* const isBack = perspective.isBack; // reactive boolean
|
||||
* ```
|
||||
*/
|
||||
export class PerspectiveManager {
|
||||
/**
|
||||
* Spring for smooth back/front transitions
|
||||
*/
|
||||
spring = new Spring(0, {
|
||||
stiffness: 0.2,
|
||||
damping: 0.8,
|
||||
});
|
||||
|
||||
/**
|
||||
* Reactive boolean: true when in back position (blurred, scaled down)
|
||||
*/
|
||||
isBack = $derived(this.spring.current > 0.5);
|
||||
|
||||
/**
|
||||
* Reactive boolean: true when in front position (fully visible, interactive)
|
||||
*/
|
||||
isFront = $derived(this.spring.current < 0.5);
|
||||
|
||||
/**
|
||||
* Configuration values for style computation
|
||||
*/
|
||||
private config: Required<PerspectiveConfig>;
|
||||
|
||||
constructor(config: PerspectiveConfig = {}) {
|
||||
this.config = {
|
||||
depthStep: config.depthStep ?? 100,
|
||||
scaleStep: config.scaleStep ?? 0.5,
|
||||
blurStep: config.blurStep ?? 4,
|
||||
opacityStep: config.opacityStep ?? 0.5,
|
||||
parallaxIntensity: config.parallaxIntensity ?? 0,
|
||||
horizontalOffset: config.horizontalOffset ?? 0,
|
||||
layoutMode: config.layoutMode ?? 'center',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between front (0) and back (1) positions.
|
||||
* Smooth spring animation handles the transition.
|
||||
*/
|
||||
toggle = () => {
|
||||
const target = this.spring.current < 0.5 ? 1 : 0;
|
||||
this.spring.target = target;
|
||||
};
|
||||
|
||||
/**
|
||||
* Force to back position
|
||||
*/
|
||||
setBack = () => {
|
||||
this.spring.target = 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Force to front position
|
||||
*/
|
||||
setFront = () => {
|
||||
this.spring.target = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get configuration for style computation
|
||||
* @internal
|
||||
*/
|
||||
getConfig = () => this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create a PerspectiveManager instance.
|
||||
*
|
||||
* @param config - Configuration options
|
||||
* @returns Configured PerspectiveManager instance
|
||||
*/
|
||||
export function createPerspectiveManager(config: PerspectiveConfig = {}) {
|
||||
return new PerspectiveManager(config);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*
|
||||
* Used to render visible items with absolute positioning based on computed offsets.
|
||||
*/
|
||||
|
||||
export interface VirtualItem {
|
||||
/**
|
||||
* Index of the item in the data array
|
||||
@@ -120,9 +121,11 @@ export function createVirtualizer<T>(
|
||||
// By wrapping the getter in $derived, we track everything inside it
|
||||
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 count = options.count;
|
||||
// Implicit dependency on version signal
|
||||
const v = _version;
|
||||
const result = new Float64Array(count);
|
||||
let accumulated = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -130,6 +133,7 @@ export function createVirtualizer<T>(
|
||||
// Accessing measuredSizes here creates the subscription
|
||||
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -144,6 +148,8 @@ export function createVirtualizer<T>(
|
||||
// We MUST read options.data here so Svelte knows to re-run
|
||||
// this derivation when the items array is replaced!
|
||||
const { count, data } = options;
|
||||
// Implicit dependency
|
||||
const v = _version;
|
||||
if (count === 0 || containerHeight === 0 || !data) return [];
|
||||
|
||||
const overscan = options.overscan ?? 5;
|
||||
@@ -185,10 +191,13 @@ export function createVirtualizer<T>(
|
||||
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
||||
|
||||
// Proximity calculation: 1.0 at center, 0.0 at edges
|
||||
// Guard against division by zero (containerHeight can be 0 on initial render)
|
||||
const itemCenter = itemStart + (itemSize / 2);
|
||||
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
|
||||
const maxDistance = containerHeight / 2;
|
||||
const proximity = Math.max(0, 1 - (distanceToCenter / maxDistance));
|
||||
const proximity = maxDistance > 0
|
||||
? Math.max(0, 1 - (distanceToCenter / maxDistance))
|
||||
: 0;
|
||||
|
||||
result.push({
|
||||
index: i,
|
||||
@@ -202,16 +211,6 @@ export function createVirtualizer<T>(
|
||||
});
|
||||
}
|
||||
|
||||
// console.log('🎯 Virtual Items Calculation:', {
|
||||
// scrollOffset,
|
||||
// containerHeight,
|
||||
// viewportEnd,
|
||||
// startIdx,
|
||||
// endIdx,
|
||||
// withOverscan: { start, end },
|
||||
// itemCount: end - start,
|
||||
// });
|
||||
|
||||
return result;
|
||||
});
|
||||
// Svelte Actions (The DOM Interface)
|
||||
@@ -252,25 +251,19 @@ export function createVirtualizer<T>(
|
||||
scrollOffset = scrolledPastTop;
|
||||
rafId = null;
|
||||
});
|
||||
|
||||
// 🔍 DIAGNOSTIC
|
||||
// console.log('📜 Scroll Event:', {
|
||||
// windowScrollY: window.scrollY,
|
||||
// elementRectTop: rect.top,
|
||||
// scrolledPastTop,
|
||||
// containerHeight
|
||||
// });
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
containerHeight = window.innerHeight;
|
||||
cachedOffsetTop = getElementOffset();
|
||||
elementOffsetTop = getElementOffset();
|
||||
cachedOffsetTop = elementOffsetTop;
|
||||
handleScroll();
|
||||
};
|
||||
|
||||
// Initial setup
|
||||
requestAnimationFrame(() => {
|
||||
cachedOffsetTop = getElementOffset();
|
||||
elementOffsetTop = getElementOffset();
|
||||
cachedOffsetTop = elementOffsetTop;
|
||||
handleScroll();
|
||||
});
|
||||
|
||||
@@ -289,6 +282,11 @@ export function createVirtualizer<T>(
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = null;
|
||||
}
|
||||
// Disconnect shared ResizeObserver
|
||||
if (sharedResizeObserver) {
|
||||
sharedResizeObserver.disconnect();
|
||||
sharedResizeObserver = null;
|
||||
}
|
||||
elementRef = null;
|
||||
},
|
||||
};
|
||||
@@ -310,6 +308,11 @@ export function createVirtualizer<T>(
|
||||
destroy() {
|
||||
node.removeEventListener('scroll', handleScroll);
|
||||
resizeObserver.disconnect();
|
||||
// Disconnect shared ResizeObserver
|
||||
if (sharedResizeObserver) {
|
||||
sharedResizeObserver.disconnect();
|
||||
sharedResizeObserver = null;
|
||||
}
|
||||
elementRef = null;
|
||||
},
|
||||
};
|
||||
@@ -318,44 +321,67 @@ export function createVirtualizer<T>(
|
||||
|
||||
let measurementBuffer: Record<number, number> = {};
|
||||
let frameId: number | null = null;
|
||||
// Signal to trigger updates when mutating measuredSizes in place
|
||||
let _version = $state(0);
|
||||
|
||||
// Single shared ResizeObserver for all items (performance optimization)
|
||||
let sharedResizeObserver: ResizeObserver | null = null;
|
||||
|
||||
/**
|
||||
* Svelte action to measure individual item elements for dynamic height support.
|
||||
*
|
||||
* Attaches a ResizeObserver to track actual element height and updates
|
||||
* measured sizes when dimensions change. Requires `data-index` attribute on the element.
|
||||
* Uses a single shared ResizeObserver for all items to track actual element heights.
|
||||
* Requires `data-index` attribute on the element.
|
||||
*
|
||||
* @param node - The DOM element to measure (should have `data-index` attribute)
|
||||
* @returns Object with destroy method for cleanup
|
||||
*/
|
||||
function measureElement(node: HTMLElement) {
|
||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||
if (!entry) return;
|
||||
const index = parseInt(node.dataset.index || '', 10);
|
||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
||||
// Initialize shared observer on first use
|
||||
if (!sharedResizeObserver) {
|
||||
sharedResizeObserver = new ResizeObserver(entries => {
|
||||
// Process all entries in a single batch
|
||||
for (const entry of entries) {
|
||||
const target = entry.target as HTMLElement;
|
||||
const index = parseInt(target.dataset.index || '', 10);
|
||||
const height = entry.borderBoxSize[0]?.blockSize ?? target.offsetHeight;
|
||||
|
||||
if (!isNaN(index)) {
|
||||
const oldHeight = measuredSizes[index];
|
||||
// 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) {
|
||||
// Stuff the measurement into a temporary buffer
|
||||
measurementBuffer[index] = height;
|
||||
if (!isNaN(index)) {
|
||||
const oldHeight = measuredSizes[index];
|
||||
|
||||
// Schedule a single update for the next animation frame
|
||||
if (frameId === null) {
|
||||
frameId = requestAnimationFrame(() => {
|
||||
measuredSizes = { ...measuredSizes, ...measurementBuffer };
|
||||
// Reset the buffer
|
||||
measurementBuffer = {};
|
||||
frameId = null;
|
||||
});
|
||||
// Only update if the height difference is significant (> 0.5px)
|
||||
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
||||
measurementBuffer[index] = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(node);
|
||||
return { destroy: () => resizeObserver.disconnect() };
|
||||
// Schedule a single update for the next animation frame
|
||||
if (frameId === null && Object.keys(measurementBuffer).length > 0) {
|
||||
frameId = requestAnimationFrame(() => {
|
||||
// Mutation in place for performance
|
||||
Object.assign(measuredSizes, measurementBuffer);
|
||||
|
||||
// Trigger reactivity
|
||||
_version += 1;
|
||||
|
||||
// Reset buffer
|
||||
measurementBuffer = {};
|
||||
frameId = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Observe this element with the shared observer
|
||||
sharedResizeObserver.observe(node);
|
||||
|
||||
// Return cleanup that only unobserves this specific element
|
||||
return {
|
||||
destroy: () => {
|
||||
sharedResizeObserver?.unobserve(node);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Programmatic Scroll
|
||||
@@ -395,6 +421,28 @@ export function createVirtualizer<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the container to a specific pixel offset.
|
||||
* Used for preserving scroll position during data updates.
|
||||
*
|
||||
* @param offset - The scroll offset in pixels
|
||||
* @param behavior - Scroll behavior: 'auto' for instant, 'smooth' for animated
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* virtualizer.scrollToOffset(1000, 'auto'); // Instant scroll to 1000px
|
||||
* ```
|
||||
*/
|
||||
function scrollToOffset(offset: number, behavior: ScrollBehavior = 'auto') {
|
||||
const { useWindowScroll } = optionsGetter();
|
||||
|
||||
if (useWindowScroll) {
|
||||
window.scrollTo({ top: offset + elementOffsetTop, behavior });
|
||||
} else if (elementRef) {
|
||||
elementRef.scrollTo({ top: offset, behavior });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get scrollOffset() {
|
||||
return scrollOffset;
|
||||
@@ -416,6 +464,8 @@ export function createVirtualizer<T>(
|
||||
measureElement,
|
||||
/** Programmatic scroll method to scroll to a specific item */
|
||||
scrollToIndex,
|
||||
/** Programmatic scroll method to scroll to a specific pixel offset */
|
||||
scrollToOffset,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
} from './createEntityStore/createEntityStore.svelte';
|
||||
|
||||
export {
|
||||
type CharacterComparison,
|
||||
createCharacterComparison,
|
||||
type LineData,
|
||||
} from './createCharacterComparison/createCharacterComparison.svelte';
|
||||
@@ -42,3 +43,8 @@ export {
|
||||
type ResponsiveManager,
|
||||
responsiveManager,
|
||||
} from './createResponsiveManager/createResponsiveManager.svelte';
|
||||
|
||||
export {
|
||||
createPerspectiveManager,
|
||||
type PerspectiveManager,
|
||||
} from './createPerspectiveManager/createPerspectiveManager.svelte';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export {
|
||||
type CharacterComparison,
|
||||
type ControlDataModel,
|
||||
type ControlModel,
|
||||
createCharacterComparison,
|
||||
@@ -6,6 +7,7 @@ export {
|
||||
createEntityStore,
|
||||
createFilter,
|
||||
createPersistentStore,
|
||||
createPerspectiveManager,
|
||||
createResponsiveManager,
|
||||
createTypographyControl,
|
||||
createVirtualizer,
|
||||
@@ -15,6 +17,7 @@ export {
|
||||
type FilterModel,
|
||||
type LineData,
|
||||
type PersistentStore,
|
||||
type PerspectiveManager,
|
||||
type Property,
|
||||
type ResponsiveManager,
|
||||
responsiveManager,
|
||||
@@ -24,7 +27,16 @@ export {
|
||||
type VirtualizerOptions,
|
||||
} from './helpers';
|
||||
|
||||
export { splitArray } from './utils';
|
||||
export {
|
||||
buildQueryString,
|
||||
clampNumber,
|
||||
debounce,
|
||||
getDecimalPlaces,
|
||||
roundToStepPrecision,
|
||||
smoothScroll,
|
||||
splitArray,
|
||||
throttle,
|
||||
} from './utils';
|
||||
|
||||
export { springySlideFade } from './transitions';
|
||||
|
||||
|
||||
@@ -11,4 +11,6 @@ export { clampNumber } from './clampNumber/clampNumber';
|
||||
export { debounce } from './debounce/debounce';
|
||||
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
|
||||
export { roundToStepPrecision } from './roundToStepPrecision/roundToStepPrecision';
|
||||
export { smoothScroll } from './smoothScroll/smoothScroll';
|
||||
export { splitArray } from './splitArray/splitArray';
|
||||
export { throttle } from './throttle/throttle';
|
||||
|
||||
32
src/shared/lib/utils/smoothScroll/smoothScroll.ts
Normal file
32
src/shared/lib/utils/smoothScroll/smoothScroll.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Smoothly scrolls to the target element when an anchor element is clicked.
|
||||
* @param node - The anchor element to listen for clicks on.
|
||||
*/
|
||||
export function smoothScroll(node: HTMLAnchorElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const hash = node.getAttribute('href');
|
||||
if (!hash || hash === '#') return;
|
||||
|
||||
const targetElement = document.querySelector(hash);
|
||||
|
||||
if (targetElement) {
|
||||
targetElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
|
||||
// Update URL hash without jumping
|
||||
history.pushState(null, '', hash);
|
||||
}
|
||||
};
|
||||
|
||||
node.addEventListener('click', handleClick);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('click', handleClick);
|
||||
},
|
||||
};
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -44,7 +44,7 @@ let {
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
index={thumb}
|
||||
class="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
class="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-background shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
@@ -103,7 +103,7 @@ const handleSliderChange = (newValue: number) => {
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
class="hover:bg-white/50 hover:font-bold bg-white/20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
|
||||
class="hover:bg-background-50 hover:font-bold bg-background-20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
|
||||
size="icon"
|
||||
aria-label={controlLabel}
|
||||
>
|
||||
|
||||
@@ -98,11 +98,11 @@ const handleInputChange: ChangeEventHandler<HTMLInputElement> = event => {
|
||||
function calculateScale(index: number): number | string {
|
||||
const calculate = () =>
|
||||
orientation === 'horizontal'
|
||||
? (control.min + (index * (control.max - control.min) / 4))
|
||||
: (control.max - (index * (control.max - control.min) / 4));
|
||||
? control.min + (index * (control.max - control.min)) / 4
|
||||
: control.max - (index * (control.max - control.min)) / 4;
|
||||
return Number.isInteger(control.step)
|
||||
? Math.round(calculate())
|
||||
: (calculate()).toFixed(2);
|
||||
: calculate().toFixed(2);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -110,8 +110,10 @@ function calculateScale(index: number): number | string {
|
||||
<div
|
||||
class={cn(
|
||||
'flex gap-4 sm:py-4 sm:px-1 rounded-xl transition-all duration-300',
|
||||
'backdrop-blur-md',
|
||||
orientation === 'horizontal' ? 'flex-row items-end w-full' : 'flex-col items-center h-full',
|
||||
'',
|
||||
orientation === 'horizontal'
|
||||
? 'flex-row items-end w-full'
|
||||
: 'flex-col items-center h-full',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -120,7 +122,9 @@ function calculateScale(index: number): number | string {
|
||||
<div
|
||||
class={cn(
|
||||
'absolute flex justify-between',
|
||||
orientation === 'horizontal' ? 'flex-row w-full -top-5 px-0.5' : 'flex-col h-full -left-5 py-0.5',
|
||||
orientation === 'horizontal'
|
||||
? 'flex-row w-full -top-8 px-0.5'
|
||||
: 'flex-col h-full -left-5 py-0.5',
|
||||
)}
|
||||
>
|
||||
{#each Array(5) as _, i}
|
||||
@@ -130,10 +134,15 @@ function calculateScale(index: number): number | string {
|
||||
orientation === 'horizontal' ? 'flex-col' : 'flex-row',
|
||||
)}
|
||||
>
|
||||
<span class="font-mono text-[0.375rem] text-gray-400 tabular-nums">
|
||||
<span class="font-mono text-[0.375rem] text-text-muted tabular-nums">
|
||||
{calculateScale(i)}
|
||||
</span>
|
||||
<div class={cn('bg-gray-300', orientation === 'horizontal' ? 'w-px h-1' : 'h-px w-1')}>
|
||||
<div
|
||||
class={cn(
|
||||
'bg-border-muted',
|
||||
orientation === 'horizontal' ? 'w-px h-1' : 'h-px w-1',
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -146,29 +155,22 @@ function calculateScale(index: number): number | string {
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
{label}
|
||||
{orientation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
class="h-10 rounded-lg w-12 pl-1 pr-1 sm:pr-1 md:pr-1 sm:pl-1 md:pl-1 text-center"
|
||||
value={inputValue}
|
||||
onchange={handleInputChange}
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
variant="ghost"
|
||||
/>
|
||||
|
||||
{#if label}
|
||||
<div class="flex items-center gap-2 opacity-70">
|
||||
<div class="w-1 h-1 rounded-full bg-gray-900"></div>
|
||||
<div class="w-px h-2 bg-gray-400/50"></div>
|
||||
<span class="font-mono text-[8px] uppercase tracking-[0.2em] text-gray-500 font-medium">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{#if !reduced}
|
||||
<Input
|
||||
class="h-10 rounded-lg w-12 pl-1 pr-1 sm:pr-1 md:pr-1 sm:pl-1 md:pl-1 text-center"
|
||||
value={inputValue}
|
||||
onchange={handleInputChange}
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
variant="ghost"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
@@ -195,7 +197,7 @@ function calculateScale(index: number): number | string {
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
class="hover:bg-white/50 hover:font-bold bg-white/20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
|
||||
class="hover:bg-background-50 hover:font-bold bg-background-20 border-none duration-150 will-change-transform active:scale-95 cursor-pointer"
|
||||
size="icon"
|
||||
aria-label={controlLabel}
|
||||
>
|
||||
|
||||
@@ -199,8 +199,8 @@ $effect(() => {
|
||||
class={cn(
|
||||
'relative p-0.5 sm:p-2 rounded-lg sm:rounded-2xl border transition-all duration-250 ease-out flex flex-col gap-1.5 backdrop-blur-lg',
|
||||
expanded
|
||||
? 'bg-white/5 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
|
||||
: ' bg-white/25 border-white/40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
|
||||
? 'bg-background-20 border-indigo-400/40 shadow-[0_30px_70px_-10px_rgba(99,102,241,0.25)]'
|
||||
: 'bg-background-40 border-background-40 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.12)]',
|
||||
disabled && 'opacity-80 grayscale-[0.2]',
|
||||
containerClassName,
|
||||
)}
|
||||
|
||||
@@ -16,15 +16,22 @@ interface Props {
|
||||
}
|
||||
|
||||
const { children, class: className, render }: Props = $props();
|
||||
|
||||
const baseClasses = 'font-mono text-[0.5625rem] sm:text-[0.625rem] uppercase tracking-[0.2em] text-gray-500 opacity-60';
|
||||
const combinedClasses = cn(baseClasses, className);
|
||||
</script>
|
||||
|
||||
{#if render}
|
||||
{@render render({ class: combinedClasses })}
|
||||
{@render render({
|
||||
class: cn(
|
||||
'font-mono text-[0.5625rem] sm:text-[0.625rem] lowercase tracking-[0.2em] text-text-soft',
|
||||
className,
|
||||
),
|
||||
})}
|
||||
{:else if children}
|
||||
<span class={combinedClasses}>
|
||||
<span
|
||||
class={cn(
|
||||
'font-mono text-[0.5625rem] sm:text-[0.625rem] lowercase tracking-[0.2em] text-text-soft',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{@render children()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
@@ -29,7 +29,7 @@ let { rotation = 'clockwise', icon, ...rest }: Props = $props();
|
||||
variant="ghost"
|
||||
class="
|
||||
group relative border-none size-9
|
||||
bg-white/20 hover:bg-white/60
|
||||
bg-background-20 hover:bg-background-60
|
||||
backdrop-blur-3xl
|
||||
transition-all duration-200 ease-out
|
||||
will-change-transform
|
||||
@@ -43,8 +43,10 @@ let { rotation = 'clockwise', icon, ...rest }: Props = $props();
|
||||
>
|
||||
{@render icon({
|
||||
className: cn(
|
||||
'size-4 transition-all duration-200 stroke-[1.5] stroke-gray-500 group-hover:stroke-gray-900 group-hover:scale-110 group-hover:stroke-3 group-active:scale-90 group-disabled:stroke-transparent',
|
||||
rotation === 'clockwise' ? 'group-active:rotate-6' : 'group-active:-rotate-6',
|
||||
'size-4 transition-all duration-200 stroke-[1.5] stroke-text-muted group-hover:stroke-foreground group-hover:scale-110 group-hover:stroke-2 group-active:scale-90 group-disabled:stroke-transparent',
|
||||
rotation === 'clockwise'
|
||||
? 'group-active:rotate-6'
|
||||
: 'group-active:-rotate-6',
|
||||
),
|
||||
})}
|
||||
</Button>
|
||||
|
||||
@@ -8,10 +8,11 @@ const { Story } = defineMeta({
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Styles Input component',
|
||||
component: 'Styled input component with size and variant options',
|
||||
},
|
||||
story: { inline: false }, // Render stories in iframe for state isolation
|
||||
},
|
||||
layout: 'centered',
|
||||
},
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
@@ -22,21 +23,76 @@ const { Story } = defineMeta({
|
||||
control: 'text',
|
||||
description: "input's value",
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['default', 'ghost'],
|
||||
description: 'Visual style variant',
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg'],
|
||||
description: 'Size variant',
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let value = $state('Initial value');
|
||||
let valueDefault = $state('Initial value');
|
||||
let valueSm = $state('');
|
||||
let valueMd = $state('');
|
||||
let valueLg = $state('');
|
||||
let valueGhostSm = $state('');
|
||||
let valueGhostMd = $state('');
|
||||
let valueGhostLg = $state('');
|
||||
const placeholder = 'Enter text';
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args={{
|
||||
placeholder,
|
||||
value,
|
||||
}}
|
||||
>
|
||||
<Input value={value} placeholder={placeholder} />
|
||||
<!-- Default Story -->
|
||||
<Story name="Default" args={{ placeholder }}>
|
||||
<Input bind:value={valueDefault} {placeholder} />
|
||||
</Story>
|
||||
|
||||
<!-- Size Variants -->
|
||||
<Story name="Small" args={{ placeholder }}>
|
||||
<Input bind:value={valueSm} {placeholder} size="sm" />
|
||||
</Story>
|
||||
|
||||
<Story name="Medium" args={{ placeholder }}>
|
||||
<Input bind:value={valueMd} {placeholder} size="md" />
|
||||
</Story>
|
||||
|
||||
<Story name="Large" args={{ placeholder }}>
|
||||
<Input bind:value={valueLg} {placeholder} size="lg" />
|
||||
</Story>
|
||||
|
||||
<!-- Ghost Variant with Sizes -->
|
||||
<Story name="Ghost Small" args={{ placeholder }}>
|
||||
<Input bind:value={valueGhostSm} {placeholder} variant="ghost" size="sm" />
|
||||
</Story>
|
||||
|
||||
<Story name="Ghost Medium" args={{ placeholder }}>
|
||||
<Input bind:value={valueGhostMd} {placeholder} variant="ghost" size="md" />
|
||||
</Story>
|
||||
|
||||
<Story name="Ghost Large" args={{ placeholder }}>
|
||||
<Input bind:value={valueGhostLg} {placeholder} variant="ghost" size="lg" />
|
||||
</Story>
|
||||
|
||||
<!-- Size Comparison -->
|
||||
<Story name="All Sizes" tags={['!autodocs']}>
|
||||
<div class="flex flex-col gap-4 w-full max-w-md p-8">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-text-muted">Small</span>
|
||||
<Input placeholder="Small input" size="sm" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-text-muted">Medium</span>
|
||||
<Input placeholder="Medium input" size="md" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-text-muted">Large</span>
|
||||
<Input placeholder="Large input" size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
@@ -2,60 +2,89 @@
|
||||
Component: Input
|
||||
Provides styled input component with all the shadcn input props
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Input } from '$shared/shadcn/ui/input';
|
||||
<script lang="ts" module>
|
||||
import { Input as BaseInput } from '$shared/shadcn/ui/input';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import {
|
||||
type VariantProps,
|
||||
tv,
|
||||
} from 'tailwind-variants';
|
||||
|
||||
type Props = ComponentProps<typeof Input> & {
|
||||
export const inputVariants = tv({
|
||||
base: [
|
||||
'w-full backdrop-blur-md border font-medium transition-all duration-200',
|
||||
'focus-visible:border-border-soft focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-muted/30 focus-visible:bg-background-95',
|
||||
'hover:bg-background-95 hover:border-border-soft',
|
||||
'text-foreground placeholder:text-text-muted placeholder:font-mono placeholder:tracking-wide',
|
||||
],
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background-80 border-border-muted shadow-[0_1px_3px_rgba(0,0,0,0.04)]',
|
||||
ghost: 'bg-transparent border-transparent shadow-none',
|
||||
},
|
||||
size: {
|
||||
sm: [
|
||||
'h-9 sm:h-10 md:h-11 rounded-lg',
|
||||
'px-3 sm:px-3.5 md:px-4',
|
||||
'text-xs sm:text-sm md:text-base',
|
||||
'placeholder:text-[0.75rem] 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-[0.75rem] 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-[0.75rem] sm:placeholder:text-sm md:placeholder:text-base',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'lg',
|
||||
},
|
||||
});
|
||||
|
||||
type InputVariant = VariantProps<typeof inputVariants>['variant'];
|
||||
type InputSize = VariantProps<typeof inputVariants>['size'];
|
||||
|
||||
export type InputProps = {
|
||||
/**
|
||||
* Current search value (bindable)
|
||||
*/
|
||||
value: string;
|
||||
value?: string;
|
||||
/**
|
||||
* Additional CSS classes for the container
|
||||
*/
|
||||
class?: string;
|
||||
|
||||
variant?: 'default' | 'ghost';
|
||||
/**
|
||||
* Visual style variant
|
||||
*/
|
||||
variant?: InputVariant;
|
||||
/**
|
||||
* Size variant
|
||||
*/
|
||||
size?: InputSize;
|
||||
[key: string]: any;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
value = $bindable(''),
|
||||
class: className,
|
||||
variant = 'default',
|
||||
size = 'lg',
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
const isGhost = $derived(variant === 'ghost');
|
||||
}: InputProps = $props();
|
||||
</script>
|
||||
|
||||
<Input
|
||||
bind:value={value}
|
||||
class={cn(
|
||||
'h-12 sm:h-14 md:h-16 w-full text-sm sm:text-base',
|
||||
'backdrop-blur-md',
|
||||
isGhost ? 'bg-transparent' : 'bg-white/80',
|
||||
'border border-gray-300/50',
|
||||
isGhost ? 'border-transparent' : 'border-gray-300/50',
|
||||
isGhost ? 'shadow-none' : 'shadow-[0_1px_3px_rgba(0,0,0,0.04)]',
|
||||
'focus-visible:border-gray-400/60',
|
||||
'focus-visible:outline-none',
|
||||
'focus-visible:ring-1',
|
||||
'focus-visible:ring-gray-400/30',
|
||||
'focus-visible:bg-white/90',
|
||||
'hover:bg-white/90',
|
||||
'hover:border-gray-400/60',
|
||||
'text-gray-900',
|
||||
'placeholder:text-gray-400',
|
||||
'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,
|
||||
)}
|
||||
<BaseInput
|
||||
bind:value
|
||||
class={cn(inputVariants({ variant, size }), className)}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
13
src/shared/ui/Input/index.ts
Normal file
13
src/shared/ui/Input/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import Input from './Input.svelte';
|
||||
|
||||
type InputProps = ComponentProps<typeof Input>;
|
||||
type InputSize = InputProps['size'];
|
||||
type InputVariant = InputProps['variant'];
|
||||
|
||||
export {
|
||||
Input,
|
||||
type InputProps,
|
||||
type InputSize,
|
||||
type InputVariant,
|
||||
};
|
||||
45
src/shared/ui/Label/Label.svelte
Normal file
45
src/shared/ui/Label/Label.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
|
||||
interface Props {
|
||||
text?: string;
|
||||
align?: 'left' | 'right' | 'center';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
onlyText?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
const {
|
||||
text,
|
||||
align = 'left',
|
||||
size = 'md',
|
||||
onlyText = false,
|
||||
class: className,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'grid grid-rows-1 gap-2 items-center w-auto',
|
||||
align === 'left' && 'grid-cols-[max-content_1fr]',
|
||||
align === 'center' && 'grid-cols-[1fr_max-content_1fr]',
|
||||
align === 'right' && 'grig-cols-[1fr_max-content]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{#if align !== 'left'}
|
||||
<div class={cn('h-px w-full bg-gray-400/50', onlyText && 'bg-transparent')}></div>
|
||||
{/if}
|
||||
<div
|
||||
class={cn(
|
||||
'text-gray-400 uppercase',
|
||||
size === 'sm' && 'text-[0.5rem]',
|
||||
size === 'md' && 'text-[0.625rem]',
|
||||
size === 'lg' && 'text-[0.75rem]',
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
{#if align !== 'right'}
|
||||
<div class={cn('h-px w-full bg-gray-400/50', onlyText && 'bg-transparent')}></div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@ let { size = 20, class: className = '', message = 'analyzing_data' }: Props = $p
|
||||
out:fade={{ duration: 300 }}
|
||||
>
|
||||
<div style:width="{size}px" style:height="{size}px">
|
||||
<svg class="stroke-gray-900 stroke-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg class="stroke-foreground stroke-1" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(12, 12)">
|
||||
<!-- Four corner brackets rotating -->
|
||||
<g>
|
||||
@@ -68,10 +68,10 @@ let { size = 20, class: className = '', message = 'analyzing_data' }: Props = $p
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Divider -->
|
||||
<div class="w-px h-3 bg-gray-400/50"></div>
|
||||
<div class="w-px h-3 bg-text-muted/50"></div>
|
||||
|
||||
<!-- Message -->
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.2em] text-gray-600 font-medium">
|
||||
<span class="font-mono text-[10px] uppercase tracking-[0.2em] text-text-subtle font-medium">
|
||||
{message}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
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>
|
||||
@@ -35,5 +35,10 @@ let {
|
||||
<div class="absolute left-4 sm:left-5 top-1/2 -translate-y-1/2 pointer-events-none z-10">
|
||||
<AsteriskIcon class="size-3 sm:size-4 stroke-gray-400 stroke-[1.5]" />
|
||||
</div>
|
||||
<Input {id} class={cn('pl-11 sm:pl-14', className)} bind:value={value} placeholder={placeholder} />
|
||||
<Input
|
||||
{id}
|
||||
class={cn('pl-11 sm:pl-14 md:pl-14 lg:pl-14', className)}
|
||||
bind:value
|
||||
{placeholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
import { Footnote } from '..';
|
||||
|
||||
interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
||||
/**
|
||||
* ID of the section
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Additional CSS classes to apply to the section container.
|
||||
*/
|
||||
@@ -40,19 +44,52 @@ interface Props extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
||||
* @param index - Index of the section
|
||||
* @param isPast - Whether the section is past the current scroll position
|
||||
* @param title - Snippet for a title itself
|
||||
* @param id - ID of the section
|
||||
* @returns Cleanup callback
|
||||
*/
|
||||
onTitleStatusChange?: (index: number, isPast: boolean, title?: Snippet<[{ className?: string }]>) => () => void;
|
||||
onTitleStatusChange?: (
|
||||
index: number,
|
||||
isPast: boolean,
|
||||
title?: Snippet<[{ className?: string }]>,
|
||||
id?: string,
|
||||
) => () => void;
|
||||
/**
|
||||
* Snippet for the section content
|
||||
*/
|
||||
children?: Snippet;
|
||||
content?: Snippet<[{ className?: string }]>;
|
||||
/**
|
||||
* When true, the title stays fixed in view while
|
||||
* scrolling through the section content.
|
||||
*/
|
||||
stickyTitle?: boolean;
|
||||
/**
|
||||
* Top offset for sticky title (e.g. header height).
|
||||
* @default '0px'
|
||||
*/
|
||||
stickyOffset?: string;
|
||||
}
|
||||
|
||||
const { class: className, title, icon, description, index = 0, onTitleStatusChange, children }: Props = $props();
|
||||
const {
|
||||
class: className,
|
||||
title,
|
||||
icon,
|
||||
description,
|
||||
index = 0,
|
||||
onTitleStatusChange,
|
||||
id,
|
||||
content,
|
||||
stickyTitle = false,
|
||||
stickyOffset = '0px',
|
||||
}: Props = $props();
|
||||
|
||||
let titleContainer = $state<HTMLElement>();
|
||||
const flyParams: FlyParams = { y: 0, x: -50, duration: 300, easing: cubicOut, opacity: 0.2 };
|
||||
const flyParams: FlyParams = {
|
||||
y: 0,
|
||||
x: -50,
|
||||
duration: 300,
|
||||
easing: cubicOut,
|
||||
opacity: 0.2,
|
||||
};
|
||||
|
||||
// Track if the user has actually scrolled away from view
|
||||
let isScrolledPast = $state(false);
|
||||
@@ -62,18 +99,21 @@ $effect(() => {
|
||||
return;
|
||||
}
|
||||
let cleanup: ((index: number) => void) | undefined;
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
const entry = entries[0];
|
||||
const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0;
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
const entry = entries[0];
|
||||
const isPast = !entry.isIntersecting && entry.boundingClientRect.top < 0;
|
||||
|
||||
if (isPast !== isScrolledPast) {
|
||||
isScrolledPast = isPast;
|
||||
cleanup = onTitleStatusChange?.(index, isPast, title);
|
||||
}
|
||||
}, {
|
||||
// Set threshold to 0 to trigger exactly when the last pixel leaves
|
||||
threshold: 0,
|
||||
});
|
||||
if (isPast !== isScrolledPast) {
|
||||
isScrolledPast = isPast;
|
||||
cleanup = onTitleStatusChange?.(index, isPast, title, id);
|
||||
}
|
||||
},
|
||||
{
|
||||
// Set threshold to 0 to trigger exactly when the last pixel leaves
|
||||
threshold: 0,
|
||||
},
|
||||
);
|
||||
|
||||
observer.observe(titleContainer);
|
||||
return () => {
|
||||
@@ -84,19 +124,32 @@ $effect(() => {
|
||||
</script>
|
||||
|
||||
<section
|
||||
{id}
|
||||
class={cn(
|
||||
'flex flex-col',
|
||||
'col-span-2 grid grid-cols-subgrid',
|
||||
stickyTitle ? 'gap-x-6 sm:gap-x-8 md:gap-x-10 lg:gap-x-12' : 'grid-rows-[max-content_1fr]',
|
||||
className,
|
||||
)}
|
||||
in:fly={flyParams}
|
||||
out:fly={flyParams}
|
||||
>
|
||||
<div class="flex flex-col gap-2 sm:gap-3" bind:this={titleContainer}>
|
||||
<div
|
||||
bind:this={titleContainer}
|
||||
class={cn(
|
||||
'flex flex-col gap-2 sm:gap-3',
|
||||
stickyTitle && 'self-start',
|
||||
)}
|
||||
style:position={stickyTitle ? 'sticky' : undefined}
|
||||
style:top={stickyTitle ? stickyOffset : undefined}
|
||||
>
|
||||
<div class="flex items-center gap-2 sm:gap-3">
|
||||
{#if icon}
|
||||
{@render icon({ className: 'size-3 sm:size-4 stroke-gray-900 stroke-1 opacity-60' })}
|
||||
<div class="w-px h-2.5 sm:h-3 bg-gray-300/60"></div>
|
||||
{@render icon({
|
||||
className: 'size-3 sm:size-4 stroke-foreground stroke-1 opacity-60',
|
||||
})}
|
||||
<div class="w-px h-2.5 sm:h-3 bg-border-subtle"></div>
|
||||
{/if}
|
||||
|
||||
{#if description}
|
||||
<Footnote>
|
||||
{#snippet render({ class: className })}
|
||||
@@ -113,10 +166,14 @@ $effect(() => {
|
||||
{#if title}
|
||||
{@render title({
|
||||
className:
|
||||
'text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tighter text-gray-900 leading-[0.9]',
|
||||
'text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-semibold tracking-tighter text-foreground leading-[0.9]',
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{@render children?.()}
|
||||
{@render content?.({
|
||||
className: stickyTitle
|
||||
? 'row-start-2 col-start-2'
|
||||
: 'row-start-2 col-start-2',
|
||||
})}
|
||||
</section>
|
||||
|
||||
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"
|
||||
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>
|
||||
@@ -30,7 +30,7 @@ const { Story } = defineMeta({
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col gap-4 p-4 w-full">
|
||||
<div class="flex flex-col gap-2 p-4 border rounded-xl border-gray-200/50 bg-white/40">
|
||||
<div class="flex flex-col gap-2 p-4 border rounded-xl border-border-subtle bg-background-40">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Skeleton class="h-8 w-1/3" />
|
||||
<Skeleton class="h-8 w-8 rounded-full" />
|
||||
|
||||
@@ -18,7 +18,7 @@ let { class: className, animate = true, ...rest }: Props = $props();
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'rounded-md bg-gray-100/50 backdrop-blur-sm',
|
||||
'rounded-md bg-background-subtle/50 backdrop-blur-sm',
|
||||
animate && 'animate-pulse',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -9,28 +9,43 @@ import {
|
||||
type SliderRootProps,
|
||||
} from 'bits-ui';
|
||||
|
||||
type Props = Omit<SliderRootProps, 'type' | 'onValueChange' | 'onValueCommit'> & {
|
||||
/**
|
||||
* Slider value, numeric.
|
||||
*/
|
||||
value: number;
|
||||
/**
|
||||
* A callback function called when the value changes.
|
||||
* @param newValue - number
|
||||
*/
|
||||
onValueChange?: (newValue: number) => void;
|
||||
/**
|
||||
* A callback function called when the user stops dragging the thumb and the value is committed.
|
||||
* @param newValue - number
|
||||
*/
|
||||
onValueCommit?: (newValue: number) => void;
|
||||
};
|
||||
type Props =
|
||||
& Omit<
|
||||
SliderRootProps,
|
||||
'type' | 'onValueChange' | 'onValueCommit'
|
||||
>
|
||||
& {
|
||||
/**
|
||||
* Slider value, numeric.
|
||||
*/
|
||||
value: number;
|
||||
/**
|
||||
* Optional label displayed inline on the track before the filled range.
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* A callback function called when the value changes.
|
||||
* @param newValue - number
|
||||
*/
|
||||
onValueChange?: (newValue: number) => void;
|
||||
/**
|
||||
* A callback function called when the user stops dragging the thumb and the value is committed.
|
||||
* @param newValue - number
|
||||
*/
|
||||
onValueCommit?: (newValue: number) => void;
|
||||
};
|
||||
|
||||
let { value = $bindable(), orientation = 'horizontal', class: className, ...rest }: Props = $props();
|
||||
let {
|
||||
value = $bindable(),
|
||||
orientation = 'horizontal',
|
||||
class: className,
|
||||
label,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<Slider.Root
|
||||
bind:value={value}
|
||||
bind:value
|
||||
class={cn(
|
||||
'relative flex h-full w-6 touch-none select-none items-center justify-center',
|
||||
orientation === 'horizontal' ? 'w-48 h-6' : 'w-6 h-48',
|
||||
@@ -41,56 +56,73 @@ let { value = $bindable(), orientation = 'horizontal', class: className, ...rest
|
||||
{...rest}
|
||||
>
|
||||
{#snippet children(props)}
|
||||
{#if label && orientation === 'horizontal'}
|
||||
<span class="absolute top-0 left-0 -translate-y-1/2 text-[0.5rem] uppercase text-gray-400">
|
||||
{label}
|
||||
</span>
|
||||
{/if}
|
||||
<span
|
||||
{...props}
|
||||
class={cn('relative bg-gray-200 rounded-full', orientation === 'horizontal' ? 'w-full h-px' : 'h-full w-px')}
|
||||
class={cn(
|
||||
'relative bg-background-muted rounded-full',
|
||||
orientation === 'horizontal' ? 'w-full h-px' : 'h-full w-px',
|
||||
)}
|
||||
>
|
||||
<!-- Filled range with NO transition -->
|
||||
<Slider.Range
|
||||
class={cn('absolute bg-gray-900 rounded-full', orientation === 'horizontal' ? 'h-full' : 'w-full')}
|
||||
class={cn(
|
||||
'absolute bg-foreground rounded-full',
|
||||
orientation === 'horizontal' ? 'h-full' : 'w-full',
|
||||
)}
|
||||
/>
|
||||
|
||||
<Slider.Thumb
|
||||
index={0}
|
||||
class={cn(
|
||||
'group/thumb relative block',
|
||||
orientation === 'horizontal' ? '-top-1 w-2 h-2.25' : '-left-1 h-2 w-2.25',
|
||||
'rounded-sm',
|
||||
'bg-gray-900',
|
||||
'size-2',
|
||||
orientation === 'horizontal' ? '-top-1' : '-left-1',
|
||||
'rounded-full',
|
||||
'bg-foreground',
|
||||
// Glow shadow
|
||||
'shadow-[0_0_6px_rgba(0,0,0,0.4)]',
|
||||
// Smooth transitions only for size/position
|
||||
'duration-200 ease-out',
|
||||
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:shadow-[0_0_10px_rgba(0,0,0,0.5)]',
|
||||
orientation === 'horizontal' ? 'hover:h-3 hover:-top-[5.5px]' : 'hover:w-3 hover:-left-[5.5px]',
|
||||
orientation === 'horizontal'
|
||||
? 'hover:size-3 hover:-top-[5.5px]'
|
||||
: 'hover:size-3 hover:-left-[5.5px]',
|
||||
// Active: smaller glow
|
||||
'active:shadow-[0_0_4px_rgba(0,0,0,0.3)]',
|
||||
orientation === 'horizontal' ? 'active:h-2.5 active:-top-[4.5px]' : 'active:w-2.5 active:-left-[4.5px]',
|
||||
orientation === 'horizontal'
|
||||
? 'active:h-2.5 active:-top-[4.5px]'
|
||||
: 'active:w-2.5 active:-left-[4.5px]',
|
||||
'focus:outline-none',
|
||||
'cursor-grab active:cursor-grabbing',
|
||||
)}
|
||||
>
|
||||
<!-- Soft glow on hover -->
|
||||
<div
|
||||
class="
|
||||
absolute inset-0 rounded-sm
|
||||
bg-white/20
|
||||
absolute inset-0 rounded-full
|
||||
bg-background-20
|
||||
opacity-0 group-hover/thumb:opacity-100
|
||||
transition-opacity duration-200
|
||||
"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Value label -->
|
||||
<span
|
||||
class={cn(
|
||||
'absolute',
|
||||
orientation === 'horizontal' ? '-top-8 left-1/2 -translate-x-1/2' : 'left-5 top-1/2 -translate-y-1/2',
|
||||
orientation === 'horizontal'
|
||||
? '-top-8 left-1/2 -translate-x-1/2'
|
||||
: 'left-5 top-1/2 -translate-y-1/2',
|
||||
'px-1.5 py-0.5 rounded-md',
|
||||
'bg-gray-900/90 backdrop-blur-sm',
|
||||
'font-mono text-[0.625rem] font-medium text-white ',
|
||||
'bg-foreground/90 backdrop-blur-sm',
|
||||
'font-mono text-[0.625rem] font-medium text-background',
|
||||
'opacity-0 group-hover/thumb:opacity-100',
|
||||
'transition-all duration-300',
|
||||
'pointer-events-none',
|
||||
|
||||
@@ -6,15 +6,13 @@
|
||||
- Keyboard navigation (ArrowUp/Down, Home, End)
|
||||
- Fixed or dynamic item heights
|
||||
- ARIA listbox/option pattern with single tab stop
|
||||
- Custom shadcn ScrollArea scrollbar
|
||||
- Native browser scroll
|
||||
-->
|
||||
<script lang="ts" generics="T">
|
||||
import { createVirtualizer } from '$shared/lib';
|
||||
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
||||
import { throttle } from '$shared/lib/utils';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -135,10 +133,13 @@ let {
|
||||
isLoading = false,
|
||||
}: Props = $props();
|
||||
|
||||
// Reference to the ScrollArea viewport element for attaching the virtualizer
|
||||
// Reference to the scroll container element for attaching the virtualizer
|
||||
let viewportRef = $state<HTMLElement | null>(null);
|
||||
|
||||
// Use items.length for count to keep existing item positions stable
|
||||
// But calculate a separate totalSize for scrollbar that accounts for unloaded items
|
||||
const virtualizer = createVirtualizer(() => ({
|
||||
// Only virtualize loaded items - this keeps positions stable when new items load
|
||||
count: items.length,
|
||||
data: items,
|
||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||
@@ -146,6 +147,34 @@ const virtualizer = createVirtualizer(() => ({
|
||||
useWindowScroll,
|
||||
}));
|
||||
|
||||
// Calculate total size including unloaded items for proper scrollbar sizing
|
||||
// Use estimateSize() for items that haven't been loaded yet
|
||||
const estimatedTotalSize = $derived.by(() => {
|
||||
if (total === items.length) {
|
||||
// No unloaded items, use virtualizer's totalSize
|
||||
return virtualizer.totalSize;
|
||||
}
|
||||
|
||||
// Start with the virtualized (loaded) items size
|
||||
const loadedSize = virtualizer.totalSize;
|
||||
|
||||
// Add estimated size for unloaded items
|
||||
const unloadedCount = total - items.length;
|
||||
if (unloadedCount <= 0) return loadedSize;
|
||||
|
||||
// Estimate the size of unloaded items
|
||||
// Get the average size of loaded items, or use the estimateSize function
|
||||
const estimateFn = typeof itemHeight === 'function' ? itemHeight : () => itemHeight;
|
||||
|
||||
// Use estimateSize for unloaded items (index from items.length to total - 1)
|
||||
let unloadedSize = 0;
|
||||
for (let i = items.length; i < total; i++) {
|
||||
unloadedSize += estimateFn(i);
|
||||
}
|
||||
|
||||
return loadedSize + unloadedSize;
|
||||
});
|
||||
|
||||
// Attach virtualizer.container action to the viewport when it becomes available
|
||||
$effect(() => {
|
||||
if (viewportRef) {
|
||||
@@ -154,18 +183,29 @@ $effect(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const throttledVisibleChange = throttle((visibleItems: T[]) => {
|
||||
onVisibleItemsChange?.(visibleItems);
|
||||
}, 150); // 150ms throttle
|
||||
|
||||
const throttledNearBottom = throttle((lastVisibleIndex: number) => {
|
||||
onNearBottom?.(lastVisibleIndex);
|
||||
}, 200); // 200ms debounce
|
||||
|
||||
$effect(() => {
|
||||
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)
|
||||
if (virtualizer.items.length > 0 && onNearBottom) {
|
||||
// Only trigger if container has sufficient height to avoid false positives
|
||||
if (virtualizer.items.length > 0 && onNearBottom && virtualizer.containerHeight > 100) {
|
||||
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
|
||||
// Compare against loaded items length, not total
|
||||
const itemsRemaining = items.length - lastVisibleItem.index;
|
||||
|
||||
if (itemsRemaining <= 5) {
|
||||
onNearBottom(lastVisibleItem.index);
|
||||
throttledNearBottom(lastVisibleItem.index);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -173,17 +213,18 @@ $effect(() => {
|
||||
|
||||
{#if useWindowScroll}
|
||||
<div class={cn('relative w-full', className)} bind:this={viewportRef}>
|
||||
<div style:height="{virtualizer.totalSize}px" class="relative w-full">
|
||||
<div style:height="{estimatedTotalSize}px" class="relative w-full">
|
||||
{#each virtualizer.items as item (item.key)}
|
||||
<div
|
||||
use:virtualizer.measureElement
|
||||
data-index={item.index}
|
||||
class="absolute top-0 left-0 w-full will-change-transform"
|
||||
style:transform="translateY({item.start}px)"
|
||||
data-lenis-prevent
|
||||
>
|
||||
{#if item.index < items.length}
|
||||
{@render children({
|
||||
// TODO: Fix indenation rule for this case
|
||||
// TODO: Fix indentation rule for this case
|
||||
item: items[item.index],
|
||||
index: item.index,
|
||||
isFullyVisible: item.isFullyVisible,
|
||||
@@ -196,27 +237,26 @@ $effect(() => {
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ScrollArea
|
||||
bind:viewportRef
|
||||
<div
|
||||
bind:this={viewportRef}
|
||||
class={cn(
|
||||
'relative rounded-md bg-background',
|
||||
'h-150 w-full',
|
||||
'relative overflow-y-auto overflow-x-hidden',
|
||||
'rounded-md bg-background',
|
||||
'w-full min-h-[200px]',
|
||||
className,
|
||||
)}
|
||||
orientation="vertical"
|
||||
>
|
||||
<div style:height="{virtualizer.totalSize}px" class="relative w-full">
|
||||
<div style:height="{estimatedTotalSize}px" class="relative w-full">
|
||||
{#each virtualizer.items as item (item.key)}
|
||||
<div
|
||||
use:virtualizer.measureElement
|
||||
data-index={item.index}
|
||||
class="absolute top-0 left-0 w-full will-change-transform"
|
||||
style:transform="translateY({item.start}px)"
|
||||
animate:flip={{ delay: 0, duration: 300, easing: quintOut }}
|
||||
>
|
||||
{#if item.index < items.length}
|
||||
{@render children({
|
||||
// TODO: Fix indenation rule for this case
|
||||
// TODO: Fix indentation rule for this case
|
||||
item: items[item.index],
|
||||
index: item.index,
|
||||
isFullyVisible: item.isFullyVisible,
|
||||
@@ -227,5 +267,5 @@ $effect(() => {
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -6,11 +6,18 @@ export { default as Drawer } from './Drawer/Drawer.svelte';
|
||||
export { default as ExpandableWrapper } from './ExpandableWrapper/ExpandableWrapper.svelte';
|
||||
export { default as Footnote } from './Footnote/Footnote.svelte';
|
||||
export { default as IconButton } from './IconButton/IconButton.svelte';
|
||||
export { default as Input } from './Input/Input.svelte';
|
||||
export {
|
||||
Input,
|
||||
type InputSize,
|
||||
type InputVariant,
|
||||
} from './Input';
|
||||
export { default as Label } from './Label/Label.svelte';
|
||||
export { default as Loader } from './Loader/Loader.svelte';
|
||||
export { default as Logo } from './Logo/Logo.svelte';
|
||||
export { default as PerspectivePlan } from './PerspectivePlan/PerspectivePlan.svelte';
|
||||
export { default as SearchBar } from './SearchBar/SearchBar.svelte';
|
||||
export { default as Section } from './Section/Section.svelte';
|
||||
export { default as SidebarMenu } from './SidebarMenu/SidebarMenu.svelte';
|
||||
export { default as Skeleton } from './Skeleton/Skeleton.svelte';
|
||||
export { default as Slider } from './Slider/Slider.svelte';
|
||||
export { default as VirtualList } from './VirtualList/VirtualList.svelte';
|
||||
|
||||
@@ -78,8 +78,6 @@ class ComparisonStore {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#fontsReady = false;
|
||||
|
||||
const weight = this.#typography.weight;
|
||||
const size = this.#typography.renderedSize;
|
||||
const fontAName = this.#fontA?.name;
|
||||
@@ -87,11 +85,25 @@ class ComparisonStore {
|
||||
|
||||
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 {
|
||||
// Step 1: Load fonts into memory
|
||||
await Promise.all([
|
||||
document.fonts.load(`${weight} ${size}px "${fontAName}"`),
|
||||
document.fonts.load(`${weight} ${size}px "${fontBName}"`),
|
||||
document.fonts.load(fontAString),
|
||||
document.fonts.load(fontBString),
|
||||
]);
|
||||
|
||||
// Step 2: Wait for browser to be ready to render
|
||||
|
||||
@@ -8,17 +8,23 @@
|
||||
- Character-level morphing: Font changes exactly when the slider passes the character's global position.
|
||||
- Responsive layout with Tailwind breakpoints for font sizing.
|
||||
- Performance optimized using offscreen canvas for measurements and transform-based animations.
|
||||
|
||||
Modes:
|
||||
- Slider mode: Text centered in 1st plan, controls hidden
|
||||
- Settings mode: Text moves to left (2nd plan), controls appear on right (1st plan)
|
||||
-->
|
||||
<script lang="ts">
|
||||
import {
|
||||
type CharacterComparison,
|
||||
type LineData,
|
||||
type ResponsiveManager,
|
||||
createCharacterComparison,
|
||||
createTypographyControl,
|
||||
createPerspectiveManager,
|
||||
} from '$shared/lib';
|
||||
import type {
|
||||
LineData,
|
||||
ResponsiveManager,
|
||||
} from '$shared/lib';
|
||||
import { Loader } from '$shared/ui';
|
||||
import {
|
||||
Loader,
|
||||
PerspectivePlan,
|
||||
} from '$shared/ui';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import { getContext } from 'svelte';
|
||||
import { Spring } from 'svelte/motion';
|
||||
@@ -31,10 +37,11 @@ import SliderLine from './components/SliderLine.svelte';
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
|
||||
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
||||
const isLoading = $derived(
|
||||
comparisonStore.isLoading || !comparisonStore.isReady,
|
||||
);
|
||||
|
||||
let container = $state<HTMLElement>();
|
||||
let typographyControls = $state<HTMLDivElement | null>(null);
|
||||
let measureCanvas = $state<HTMLCanvasElement>();
|
||||
let isDragging = $state(false);
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
@@ -45,7 +52,7 @@ const responsive = getContext<ResponsiveManager>('responsive');
|
||||
* Encapsulated helper for text splitting, measuring, and character proximity calculations.
|
||||
* Manages line breaking and character state based on fonts and container dimensions.
|
||||
*/
|
||||
const charComparison = createCharacterComparison(
|
||||
const charComparison: CharacterComparison = createCharacterComparison(
|
||||
() => comparisonStore.text,
|
||||
() => fontA,
|
||||
() => fontB,
|
||||
@@ -53,6 +60,22 @@ const charComparison = createCharacterComparison(
|
||||
() => typography.renderedSize,
|
||||
);
|
||||
|
||||
/**
|
||||
* Perspective manager for back/front state toggling:
|
||||
* - Front (slider mode): Text fully visible, interactive
|
||||
* - Back (settings mode): Text blurred, scaled down, shifted left, controls visible
|
||||
*
|
||||
* Uses simple boolean flag for smooth transitions between states.
|
||||
*/
|
||||
const perspective = createPerspectiveManager({
|
||||
parallaxIntensity: 0, // Disabled to not interfere with slider
|
||||
horizontalOffset: 0, // Text shifts left when in back position
|
||||
scaleStep: 0.5,
|
||||
blurStep: 2,
|
||||
depthStep: 100,
|
||||
opacityStep: 0.3,
|
||||
});
|
||||
|
||||
let lineElements = $state<(HTMLElement | undefined)[]>([]);
|
||||
|
||||
/** Physics-based spring for smooth handle movement */
|
||||
@@ -64,7 +87,10 @@ const sliderPos = $derived(sliderSpring.current);
|
||||
|
||||
/** Updates spring target based on pointer position */
|
||||
function handleMove(e: PointerEvent) {
|
||||
if (!isDragging || !container) return;
|
||||
if (!isDragging || !container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const percentage = (x / rect.width) * 100;
|
||||
@@ -72,18 +98,15 @@ function handleMove(e: PointerEvent) {
|
||||
}
|
||||
|
||||
function startDragging(e: PointerEvent) {
|
||||
if (
|
||||
e.target === typographyControls
|
||||
|| typographyControls?.contains(e.target as Node)
|
||||
) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
isDragging = true;
|
||||
handleMove(e);
|
||||
}
|
||||
|
||||
function togglePerspective() {
|
||||
perspective.toggle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the multiplier for slider font size based on the current responsive state
|
||||
*/
|
||||
@@ -146,28 +169,28 @@ $effect(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
const isInSettingsMode = $derived(perspective.isBack);
|
||||
</script>
|
||||
|
||||
{#snippet renderLine(line: LineData, index: number)}
|
||||
{@const pos = sliderPos}
|
||||
{@const element = lineElements[index]}
|
||||
<div
|
||||
bind:this={lineElements[index]}
|
||||
class="relative flex w-full justify-center items-center whitespace-nowrap"
|
||||
style:height={`${typography.height}em`}
|
||||
style:line-height={`${typography.height}em`}
|
||||
>
|
||||
{#each line.text.split('') as char, charIndex}
|
||||
{@const { proximity, isPast } = charComparison.getCharState(charIndex, sliderPos, lineElements[index], container)}
|
||||
{#each line.text.split('') as char, index}
|
||||
{@const { proximity, isPast } = charComparison.getCharState(index, pos, element, container)}
|
||||
<!--
|
||||
Single Character Span
|
||||
- Font Family switches based on `isPast`
|
||||
- Transitions/Transforms provide the "morph" feel
|
||||
-->
|
||||
{#if fontA && fontB}
|
||||
<CharacterSlot
|
||||
{char}
|
||||
{proximity}
|
||||
{isPast}
|
||||
/>
|
||||
<CharacterSlot {char} {proximity} {isPast} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
@@ -176,59 +199,80 @@ $effect(() => {
|
||||
<!-- Hidden canvas used for text measurement by the helper -->
|
||||
<canvas bind:this={measureCanvas} class="hidden" width="1" height="1"></canvas>
|
||||
|
||||
<div class="relative">
|
||||
<div
|
||||
bind:this={container}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={Math.round(sliderPos)}
|
||||
aria-label="Font comparison slider"
|
||||
onpointerdown={startDragging}
|
||||
class="
|
||||
group relative w-full py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24 overflow-hidden
|
||||
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
|
||||
select-none touch-none cursor-ew-resize min-h-72 sm:min-h-96 flex flex-col justify-center
|
||||
backdrop-blur-lg bg-linear-to-br from-gray-100/70 via-white/50 to-gray-100/60
|
||||
border border-gray-300/40
|
||||
shadow-[inset_0_4px_12px_0_rgba(0,0,0,0.12),inset_0_2px_4px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(255,255,255,0.8)]
|
||||
before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
|
||||
before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
|
||||
before:-z-10 before:blur-sm
|
||||
"
|
||||
>
|
||||
<!-- Text Rendering Container -->
|
||||
{#if isLoading}
|
||||
<div out:fade={{ duration: 300 }}>
|
||||
<Loader size={24} />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Main container with perspective and fixed height -->
|
||||
<div
|
||||
class="
|
||||
relative w-full flex justify-center items-center
|
||||
perspective-distant perspective-origin-center transform-3d
|
||||
rounded-xl sm:rounded-2xl md:rounded-[2.5rem]
|
||||
min-h-72 sm:min-h-96 lg:min-h-128
|
||||
backdrop-blur-lg bg-linear-to-br from-gray-200/40 via-white/80 to-gray-100/60
|
||||
border border-border-muted
|
||||
shadow-[inset_2px_0_8px_rgba(0,0,0,0.05)]
|
||||
before:absolute before:inset-0 before:rounded-xl sm:before:rounded-2xl md:before:rounded-[2.5rem] before:p-px
|
||||
before:bg-linear-to-br before:from-black/5 before:via-black/2 before:to-transparent
|
||||
before:-z-10 before:blur-sm
|
||||
overflow-hidden
|
||||
[perspective:1500px] perspective-origin-center transform-3d
|
||||
"
|
||||
>
|
||||
{#if isLoading}
|
||||
<div out:fade={{ duration: 300 }}>
|
||||
<Loader size={24} />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Text Plan -->
|
||||
<PerspectivePlan
|
||||
manager={perspective}
|
||||
class="absolute inset-0 flex justify-center origin-right w-full h-full"
|
||||
>
|
||||
<div
|
||||
bind:this={container}
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-valuenow={Math.round(sliderPos)}
|
||||
aria-label="Font comparison slider"
|
||||
onpointerdown={startDragging}
|
||||
class="
|
||||
relative flex flex-col items-center gap-3 sm:gap-4
|
||||
text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold leading-[1.15]
|
||||
z-10 pointer-events-none text-center
|
||||
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
|
||||
relative w-full h-full flex justify-center
|
||||
py-8 px-4 sm:py-12 sm:px-8 md:py-16 md:px-12 lg:py-20 lg:px-24
|
||||
select-none touch-none cursor-ew-resize
|
||||
"
|
||||
style:perspective="1000px"
|
||||
in:fade={{ duration: 300, delay: 300 }}
|
||||
out:fade={{ duration: 300 }}
|
||||
>
|
||||
{#each charComparison.lines as line, lineIndex}
|
||||
<div
|
||||
class="relative w-full whitespace-nowrap"
|
||||
style:height={`${typography.height}em`}
|
||||
style:display="flex"
|
||||
style:align-items="center"
|
||||
style:justify-content="center"
|
||||
>
|
||||
{@render renderLine(line, lineIndex)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div
|
||||
class="
|
||||
relative flex flex-col items-center gap-3 sm:gap-4
|
||||
text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold leading-[1.15]
|
||||
z-10 pointer-events-none text-center
|
||||
drop-shadow-[0_3px_6px_rgba(255,255,255,0.9)]
|
||||
my-auto
|
||||
"
|
||||
in:fade={{ duration: 300, delay: 300 }}
|
||||
out:fade={{ duration: 300 }}
|
||||
>
|
||||
{#each charComparison.lines as line, lineIndex}
|
||||
<div
|
||||
class="relative w-full whitespace-nowrap"
|
||||
style:height={`${typography.height}em`}
|
||||
style:display="flex"
|
||||
style:align-items="center"
|
||||
style:justify-content="center"
|
||||
>
|
||||
{@render renderLine(line, lineIndex)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<SliderLine {sliderPos} {isDragging} />
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Since there're slider controls inside we put them outside the main one -->
|
||||
<Controls {sliderPos} {isDragging} {typographyControls} {container} />
|
||||
<!-- Slider Line - visible in slider mode -->
|
||||
{#if !isInSettingsMode}
|
||||
<SliderLine {sliderPos} {isDragging} />
|
||||
{/if}
|
||||
</div>
|
||||
</PerspectivePlan>
|
||||
|
||||
<Controls
|
||||
class="absolute inset-y-0 left-0 transition-all duration-150"
|
||||
handleToggle={togglePerspective}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
Renders a character with particular styling based on proximity, isPast, weight, fontAName, and fontBName.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { appliedFontsManager } from '$entities/Font';
|
||||
import { getFontUrl } from '$entities/Font/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { comparisonStore } from '../../../model';
|
||||
|
||||
@@ -28,33 +26,6 @@ let { char, proximity, isPast }: Props = $props();
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
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>
|
||||
|
||||
{#if fontA && fontB}
|
||||
@@ -67,13 +38,18 @@ $effect(() => {
|
||||
style:font-weight={typography.weight}
|
||||
style:font-size={`${typography.renderedSize}px`}
|
||||
style:transform="
|
||||
scale({1 + proximity * 0.3})
|
||||
translateY({-proximity * 12}px)
|
||||
rotateY({proximity * 25 * (isPast ? -1 : 1)}deg)
|
||||
"
|
||||
style:filter="brightness({1 + proximity * 0.2}) contrast({1 + proximity * 0.1})"
|
||||
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'}
|
||||
scale({1 + proximity * 0.3}) translateY({-proximity * 12}px) rotateY({proximity *
|
||||
25 *
|
||||
(isPast ? -1 : 1)}deg)
|
||||
"
|
||||
style:filter="brightness({1 + proximity * 0.2}) contrast({1 +
|
||||
proximity * 0.1})"
|
||||
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}
|
||||
</span>
|
||||
@@ -82,9 +58,9 @@ $effect(() => {
|
||||
<style>
|
||||
span {
|
||||
/*
|
||||
Optimize for performance and smooth transitions.
|
||||
step-end logic is effectively handled by binary font switching in JS.
|
||||
*/
|
||||
Optimize for performance and smooth transitions.
|
||||
step-end logic is effectively handled by binary font switching in JS.
|
||||
*/
|
||||
transition:
|
||||
font-family 0.15s ease-out,
|
||||
color 0.2s ease-out,
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
<!--
|
||||
Component: Controls
|
||||
Uses SidebarMenu to show ComparisonSlider's controls:
|
||||
- List of fonts to pick
|
||||
- Input to change text
|
||||
- Sliders for font-weight, font-width, line-height
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { appliedFontsManager } from '$entities/Font';
|
||||
import { getFontUrl } from '$entities/Font/lib';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
Drawer,
|
||||
IconButton,
|
||||
} from '$shared/ui';
|
||||
import SlidersIcon from '@lucide/svelte/icons/sliders-vertical';
|
||||
import { SidebarMenu } from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import Drawer from '$shared/ui/Drawer/Drawer.svelte';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import { getContext } from 'svelte';
|
||||
import { comparisonStore } from '../../../model';
|
||||
import SelectComparedFonts from './SelectComparedFonts.svelte';
|
||||
import FontList from './FontList.svelte';
|
||||
import ToggleMenuButton from './ToggleMenuButton.svelte';
|
||||
import TypographyControls from './TypographyControls.svelte';
|
||||
|
||||
interface Props {
|
||||
sliderPos: number;
|
||||
isDragging: boolean;
|
||||
typographyControls?: HTMLDivElement | null;
|
||||
container: HTMLElement;
|
||||
/**
|
||||
* Additional class
|
||||
*/
|
||||
class?: string;
|
||||
/**
|
||||
* Handler to trigger when menu opens/closes
|
||||
*/
|
||||
handleToggle?: () => void;
|
||||
}
|
||||
|
||||
let { sliderPos, isDragging, typographyControls = $bindable<HTMLDivElement | null>(null), container }: Props = $props();
|
||||
let { class: className, handleToggle }: Props = $props();
|
||||
|
||||
let visible = $state(false);
|
||||
const fontA = $derived(comparisonStore.fontA);
|
||||
const fontB = $derived(comparisonStore.fontB);
|
||||
const isLoading = $derived(comparisonStore.isLoading || !comparisonStore.isReady);
|
||||
const weight = $derived(comparisonStore.typography.weight);
|
||||
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
let menuWrapper = $state<HTMLElement | null>(null);
|
||||
const responsive = getContext<ResponsiveManager>('responsive');
|
||||
|
||||
$effect(() => {
|
||||
@@ -34,6 +44,7 @@ $effect(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const weight = typography.weight;
|
||||
const fontAUrl = getFontUrl(fontA, weight);
|
||||
const fontBUrl = getFontUrl(fontB, weight);
|
||||
|
||||
@@ -41,8 +52,18 @@ $effect(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const fontAConfig = { id: fontA.id, name: fontA.name, url: fontAUrl, weight: weight };
|
||||
const fontBConfig = { id: fontB.id, name: fontB.name, url: fontBUrl, weight: weight };
|
||||
const fontAConfig = {
|
||||
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]);
|
||||
});
|
||||
@@ -50,43 +71,61 @@ $effect(() => {
|
||||
|
||||
{#if responsive.isMobile}
|
||||
<Drawer>
|
||||
{#snippet trigger({ isOpen, onClick })}
|
||||
<IconButton class="absolute right-3 top-3" onclick={onClick}>
|
||||
{#snippet icon({ className })}
|
||||
<SlidersIcon class={className} />
|
||||
{/snippet}
|
||||
</IconButton>
|
||||
{#snippet trigger({ onClick })}
|
||||
<div class={cn('absolute bottom-0.5 left-1/2 -translate-x-1/2 z-50')}>
|
||||
<ToggleMenuButton bind:isActive={visible} {onClick} />
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet content({ className })}
|
||||
<div class="w-full pt-4 grid grid-cols-[1fr_min-content_1fr] gap-2 items-center justify-center">
|
||||
<div class="uppercase text-indigo-500 ml-auto font-semibold tracking-tight text-[0.825rem] whitespace-nowrap">
|
||||
{fontB?.name ?? 'typeface_01'}
|
||||
</div>
|
||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
||||
<div class="uppercase text-neutral-950 mr-auto font-semibold tracking-tight text-[0.825rem] whitespace-nowrap">
|
||||
{fontA?.name ?? 'typeface_02'}
|
||||
</div>
|
||||
</div>
|
||||
<div class={cn(className, 'flex flex-col gap-2 h-[60vh]')}>
|
||||
<Label class="mb-2" text="Available Fonts" align="center" />
|
||||
|
||||
{#snippet content({ isOpen, className })}
|
||||
<div class={cn(className, 'flex flex-col gap-6')}>
|
||||
<SelectComparedFonts {sliderPos} />
|
||||
<TypographyControls
|
||||
{sliderPos}
|
||||
{isDragging}
|
||||
isActive={isOpen}
|
||||
bind:wrapper={typographyControls}
|
||||
containerWidth={container?.clientWidth}
|
||||
staticPosition
|
||||
/>
|
||||
<div class="h-full overflow-hidden">
|
||||
<FontList />
|
||||
</div>
|
||||
<Label class="mb-2" text="Typography Controls" align="center" />
|
||||
|
||||
<div class="mx-4 flex-shrink-0">
|
||||
<TypographyControls />
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Drawer>
|
||||
{:else}
|
||||
{#if !isLoading}
|
||||
<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>
|
||||
{/if}
|
||||
<SidebarMenu
|
||||
class={cn(
|
||||
'w-96 flex flex-col h-full pl-4 lg:pl-6 py-4 sm:py-6 sm:pt-12 gap-0 sm:gap-0 pointer-events-auto overflow-hidden',
|
||||
'relative h-full transition-all duration-700 ease-out',
|
||||
className,
|
||||
)}
|
||||
bind:visible
|
||||
bind:wrapper={menuWrapper}
|
||||
onClickOutside={handleToggle}
|
||||
>
|
||||
{#snippet action()}
|
||||
<!-- Always-visible mode switch -->
|
||||
<div class={cn('absolute top-2 left-0 z-50', visible && 'w-full')}>
|
||||
<ToggleMenuButton bind:isActive={visible} onClick={handleToggle} />
|
||||
</div>
|
||||
{/snippet}
|
||||
<Label class="mb-2 mr-4 lg:mr-6" text="Available Fonts" align="left" />
|
||||
|
||||
{#if !isLoading}
|
||||
<div class="absolute bottom-3 sm:bottom-6 md:bottom-8 inset-x-3 sm:inset-x-6 md:inset-x-12">
|
||||
<SelectComparedFonts {sliderPos} />
|
||||
<div class="mb-2 h-2/3 overflow-hidden">
|
||||
<FontList />
|
||||
</div>
|
||||
{/if}
|
||||
<Label class="mb-2 mr-4 lg:mr-6" text="Typography Controls" align="left" />
|
||||
|
||||
<div class="mr-4 sm:mr-6">
|
||||
<TypographyControls />
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<!--
|
||||
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 h-full"
|
||||
>
|
||||
{#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 sm: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(
|
||||
'text-[0.625rem] sm:text-[0.75rem] 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
|
||||
&& 'bg-[linear-gradient(to_right,theme(colors.indigo.500)_50%,theme(colors.neutral.950)_50%)] bg-clip-text text-transparent',
|
||||
)}
|
||||
>
|
||||
--- {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-gray-300/40 bg-white/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-gray-900',
|
||||
'hover:bg-white/80 hover:border-gray-400/60 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-white/95 backdrop-blur-xl border border-gray-300/50 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-gray-100 font-mono text-[10px] sm:text-[11px] px-2 sm:px-3 py-2 sm:py-2.5 rounded-md cursor-pointer transition-colors"
|
||||
onclick={handleClick}
|
||||
>
|
||||
<FontApplicator
|
||||
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-gray-300/60"></div>
|
||||
<span class="font-mono text-[8px] sm:text-[9px] uppercase tracking-[0.2em] text-gray-500 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-gray-500 font-medium">
|
||||
ch_02
|
||||
</span>
|
||||
<div class="w-px h-2 sm:h-2.5 bg-gray-300/60"></div>
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-gray-900 shadow-[0_0_6px_rgba(0,0,0,0.4)]"></div>
|
||||
</div>
|
||||
{#if fontA && fontAUrl}
|
||||
{@render fontSelector(fontA, fontList, fontAUrl, selectFontA, 'end')}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,9 +19,10 @@ interface Props {
|
||||
}
|
||||
let { sliderPos, isDragging }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'absolute inset-y-2 sm:inset-y-4 pointer-events-none -translate-x-1/2 z-50 flex flex-col justify-center items-center',
|
||||
'absolute top-2 bottom-8 sm:top-4 sm:bottom-4 pointer-events-none -translate-x-1/2 z-50 flex flex-col justify-center items-center',
|
||||
// Force GPU layer with translateZ
|
||||
'translate-z-0',
|
||||
// Only transition left when NOT dragging
|
||||
@@ -30,6 +31,7 @@ let { sliderPos, isDragging }: Props = $props();
|
||||
style:left="{sliderPos}%"
|
||||
style:will-change={isDragging ? 'left' : 'auto'}
|
||||
in:fade={{ duration: 300, delay: 150, easing: cubicOut }}
|
||||
out:fade={{ duration: 150, easing: cubicOut }}
|
||||
>
|
||||
<!-- We use part of lucide cursor svg icon as a handle -->
|
||||
<svg
|
||||
@@ -51,7 +53,7 @@ let { sliderPos, isDragging }: Props = $props();
|
||||
<div
|
||||
class={cn(
|
||||
'relative h-full rounded-sm transition-all duration-300',
|
||||
'bg-white/3 ',
|
||||
'bg-background-3 ',
|
||||
// These are the visible "edges" of the glass
|
||||
'shadow-[0_0_40px_rgba(0,0,0,0.1)_inset_0_0_20px_rgba(255,255,255,0.1)]',
|
||||
'shadow-[0_10px_30px_-10px_rgba(0,0,0,0.2),inset_0_1px_1px_rgba(255,255,255,0.4)]',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<!--
|
||||
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(
|
||||
cn(
|
||||
'size-4 stroke-[1.5] stroke-gray-500',
|
||||
!isActive && 'rotate-90 sm:rotate-0',
|
||||
),
|
||||
)}
|
||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
||||
<div class="text-[0.75rem] sm:text-xs transition-all delay-150 group-hover:text-semibold text-indigo-500 text-right whitespace-nowrap">
|
||||
{fontB?.name}
|
||||
</div>
|
||||
<div class="w-px h-2.5 bg-gray-400/50"></div>
|
||||
<div class="text-[0.75rem] sm:text-xs transition-all delay-150 group-hover:text-semibold text-neural-950 text-left whitespace-nowrap">
|
||||
{fontA?.name}
|
||||
</div>
|
||||
</button>
|
||||
@@ -1,198 +1,55 @@
|
||||
<!--
|
||||
Component: TypographyControls
|
||||
Wrapper for the controls of the slider.
|
||||
- Input to change the text
|
||||
- Three combo controls with inputs and sliders for font-weight, font-size, and line-height
|
||||
Controls for text input and typography settings (size, weight, height).
|
||||
Simplified version for static positioning in settings mode.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import {
|
||||
ComboControlV2,
|
||||
ExpandableWrapper,
|
||||
Input,
|
||||
} from '$shared/ui';
|
||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||
import AArrowUP from '@lucide/svelte/icons/a-arrow-up';
|
||||
import { type Orientation } from 'bits-ui';
|
||||
import { untrack } from 'svelte';
|
||||
import { Spring } from 'svelte/motion';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Ref
|
||||
*/
|
||||
wrapper?: HTMLDivElement | null;
|
||||
/**
|
||||
* Slider position
|
||||
*/
|
||||
sliderPos: number;
|
||||
/**
|
||||
* Whether slider is being dragged
|
||||
*/
|
||||
isDragging: boolean;
|
||||
/** */
|
||||
isActive?: boolean;
|
||||
/**
|
||||
* Container width
|
||||
*/
|
||||
containerWidth: number;
|
||||
/**
|
||||
* Reduced animations flag
|
||||
*/
|
||||
staticPosition?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
sliderPos,
|
||||
isDragging,
|
||||
isActive = $bindable(false),
|
||||
wrapper = $bindable(null),
|
||||
containerWidth = 0,
|
||||
staticPosition = false,
|
||||
}: Props = $props();
|
||||
|
||||
const typography = $derived(comparisonStore.typography);
|
||||
const panelWidth = $derived(wrapper?.clientWidth ?? 0);
|
||||
const margin = 24;
|
||||
let side = $state<'left' | 'right'>('left');
|
||||
// Unified active state for the entire wrapper
|
||||
let timeoutId = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const xSpring = new Spring(0, {
|
||||
stiffness: 0.14, // Lower is slower
|
||||
damping: 0.5, // Settle
|
||||
});
|
||||
|
||||
const rotateSpring = new Spring(0, {
|
||||
stiffness: 0.12,
|
||||
damping: 0.55,
|
||||
});
|
||||
|
||||
function handleInputFocus() {
|
||||
isActive = true;
|
||||
}
|
||||
|
||||
// Movement Logic
|
||||
$effect(() => {
|
||||
if (containerWidth === 0 || panelWidth === 0 || staticPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sliderX = (sliderPos / 100) * containerWidth;
|
||||
const buffer = 40;
|
||||
const leftTrigger = margin + panelWidth + buffer;
|
||||
const rightTrigger = containerWidth - (margin + panelWidth + buffer);
|
||||
|
||||
if (side === 'left' && sliderX < leftTrigger) {
|
||||
side = 'right';
|
||||
} else if (side === 'right' && sliderX > rightTrigger) {
|
||||
side = 'left';
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Trigger only when side changes
|
||||
const currentSide = side;
|
||||
|
||||
untrack(() => {
|
||||
if (containerWidth > 0 && panelWidth > 0) {
|
||||
const targetX = currentSide === 'right'
|
||||
? containerWidth - panelWidth - margin * 2
|
||||
: 0;
|
||||
|
||||
// On side change set the position and the rotation
|
||||
xSpring.target = targetX;
|
||||
rotateSpring.target = currentSide === 'right' ? 3.5 : -3.5;
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
rotateSpring.target = 0;
|
||||
}, 600);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet InputComponent(className: string)}
|
||||
<Input
|
||||
class={className}
|
||||
bind:value={comparisonStore.text}
|
||||
disabled={isDragging}
|
||||
onfocusin={handleInputFocus}
|
||||
placeholder="The quick brown fox..."
|
||||
/>
|
||||
{/snippet}
|
||||
<!-- Text input -->
|
||||
<Input
|
||||
bind:value={comparisonStore.text}
|
||||
size="sm"
|
||||
label="Text"
|
||||
placeholder="The quick brown fox..."
|
||||
class="w-full h-10 px-3 py-2 sm:mr-4 mb-8 sm:mb-4 rounded-lg border border-border-muted bg-background-60 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
{#snippet Controls(className: string, orientation: Orientation)}
|
||||
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
|
||||
<div class={className}>
|
||||
<ComboControlV2 control={typography.weightControl} {orientation} reduced />
|
||||
<ComboControlV2 control={typography.sizeControl} {orientation} reduced />
|
||||
<ComboControlV2 control={typography.heightControl} {orientation} reduced />
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<!-- Typography controls -->
|
||||
{#if typography.weightControl && typography.sizeControl && typography.heightControl}
|
||||
<div class="flex flex-col mt-1.5">
|
||||
<ComboControlV2
|
||||
control={typography.weightControl}
|
||||
orientation="horizontal"
|
||||
class="sm:py-0 sm:px-0 mb-5 sm:mb-1.5"
|
||||
label="font weight"
|
||||
showScale={false}
|
||||
reduced
|
||||
/>
|
||||
|
||||
<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}
|
||||
<ComboControlV2
|
||||
control={typography.sizeControl}
|
||||
orientation="horizontal"
|
||||
class="sm:py-0 sm:px-0 mb-5 sm:mb-1.5"
|
||||
label="font size"
|
||||
showScale={false}
|
||||
reduced
|
||||
/>
|
||||
|
||||
{#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>
|
||||
<ComboControlV2
|
||||
control={typography.heightControl}
|
||||
orientation="horizontal"
|
||||
class="sm:py-0 sm:px-0"
|
||||
label="line height"
|
||||
showScale={false}
|
||||
reduced
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -33,7 +33,7 @@ interface Props {
|
||||
showFilters?: boolean;
|
||||
}
|
||||
|
||||
let { showFilters = $bindable(false) }: Props = $props();
|
||||
let { showFilters = $bindable(true) }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
/**
|
||||
@@ -77,14 +77,14 @@ function toggleFilters() {
|
||||
|
||||
<div class="absolute right-4 top-1/2 translate-y-[-50%] z-10">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-px h-5 bg-gray-300/60"></div>
|
||||
<div class="w-px h-5 bg-border-subtle"></div>
|
||||
<div style:transform="scale({transform.current.scale})">
|
||||
<IconButton onclick={toggleFilters}>
|
||||
{#snippet icon({ className })}
|
||||
<SlidersHorizontalIcon
|
||||
class={cn(
|
||||
className,
|
||||
showFilters ? 'stroke-gray-900 stroke-3' : 'stroke-gray-500',
|
||||
showFilters ? 'stroke-foreground stroke-3' : 'stroke-text-muted',
|
||||
)}
|
||||
/>
|
||||
{/snippet}
|
||||
@@ -102,14 +102,14 @@ function toggleFilters() {
|
||||
<div
|
||||
class="
|
||||
p-3 sm:p-4 md:p-5 rounded-xl
|
||||
backdrop-blur-md bg-white/80
|
||||
border border-gray-300/50
|
||||
backdrop-blur-md bg-background-80
|
||||
border border-border-muted
|
||||
shadow-[0_1px_3px_rgba(0,0,0,0.04)]
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2 sm:gap-2.5 mb-3 sm:mb-4">
|
||||
<div class="w-1 h-1 rounded-full bg-gray-900 opacity-70"></div>
|
||||
<div class="w-px h-2.5 bg-gray-300/60"></div>
|
||||
<div class="w-1 h-1 rounded-full bg-foreground opacity-70"></div>
|
||||
<div class="w-px h-2.5 bg-border-subtle"></div>
|
||||
<Footnote>
|
||||
filter_params
|
||||
</Footnote>
|
||||
@@ -119,7 +119,7 @@ function toggleFilters() {
|
||||
<Filters />
|
||||
</div>
|
||||
|
||||
<div class="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-300/40">
|
||||
<div class="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-border-subtle">
|
||||
<FilterControls class="m-auto w-fit" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
TypographyMenu,
|
||||
controlManager,
|
||||
} from '$features/SetupFont';
|
||||
import { throttle } from '$shared/lib/utils';
|
||||
import { Skeleton } from '$shared/ui';
|
||||
|
||||
let text = $state('The quick brown fox jumps over the lazy dog...');
|
||||
let wrapper = $state<HTMLDivElement | null>(null);
|
||||
@@ -23,36 +25,6 @@ let innerHeight = $state(0);
|
||||
// Is the component above the middle of the viewport?
|
||||
let isAboveMiddle = $state(false);
|
||||
|
||||
const isLoading = $derived(unifiedFontStore.isFetching || unifiedFontStore.isLoading);
|
||||
|
||||
/**
|
||||
* Load more fonts by moving to the next page
|
||||
*/
|
||||
function loadMore() {
|
||||
if (
|
||||
!unifiedFontStore.pagination.hasMore
|
||||
|| unifiedFontStore.isFetching
|
||||
) {
|
||||
return;
|
||||
}
|
||||
unifiedFontStore.nextPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle scroll near bottom - auto-load next page
|
||||
*
|
||||
* Triggered by VirtualList when the user scrolls within 5 items of the end
|
||||
* of the loaded items. Only fetches if there are more pages available.
|
||||
*/
|
||||
function handleNearBottom(_lastVisibleIndex: number) {
|
||||
const { hasMore } = unifiedFontStore.pagination;
|
||||
|
||||
// VirtualList already checks if we're near the bottom of loaded items
|
||||
if (hasMore && !unifiedFontStore.isFetching) {
|
||||
loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate display range for pagination info
|
||||
*/
|
||||
@@ -62,16 +34,30 @@ const displayRange = $derived.by(() => {
|
||||
return `Showing ${loadedCount} of ${total} fonts`;
|
||||
});
|
||||
|
||||
function checkPosition() {
|
||||
const checkPosition = throttle(() => {
|
||||
if (!wrapper) return;
|
||||
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const viewportMiddle = innerHeight / 2;
|
||||
|
||||
isAboveMiddle = rect.top < viewportMiddle;
|
||||
}
|
||||
}, 100);
|
||||
</script>
|
||||
|
||||
{#snippet skeleton()}
|
||||
<div class="flex flex-col gap-3 sm:gap-4 p-3 sm:p-4">
|
||||
{#each Array(5) as _, i}
|
||||
<div class="flex flex-col gap-1.5 sm:gap-2 p-3 sm:p-4 border rounded-lg sm:rounded-xl border-border-subtle bg-background-40">
|
||||
<div class="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<Skeleton class="h-6 sm:h-8 w-1/3" />
|
||||
<Skeleton class="h-6 sm:h-8 w-6 sm:w-8 rounded-full" />
|
||||
</div>
|
||||
<Skeleton class="h-24 sm:h-32 w-full" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<svelte:window
|
||||
bind:innerHeight
|
||||
onscroll={checkPosition}
|
||||
@@ -80,13 +66,10 @@ function checkPosition() {
|
||||
|
||||
<div bind:this={wrapper}>
|
||||
<FontVirtualList
|
||||
items={unifiedFontStore.fonts}
|
||||
total={unifiedFontStore.pagination.total}
|
||||
onNearBottom={handleNearBottom}
|
||||
itemHeight={220}
|
||||
useWindowScroll={true}
|
||||
weight={controlManager.weight}
|
||||
{isLoading}
|
||||
{skeleton}
|
||||
>
|
||||
{#snippet children({
|
||||
item: font,
|
||||
@@ -95,7 +78,12 @@ function checkPosition() {
|
||||
proximity,
|
||||
index,
|
||||
})}
|
||||
<FontListItem {font} {isFullyVisible} {isPartiallyVisible} {proximity}>
|
||||
<FontListItem
|
||||
{font}
|
||||
{isFullyVisible}
|
||||
{isPartiallyVisible}
|
||||
{proximity}
|
||||
>
|
||||
<FontSampler {font} bind:text {index} />
|
||||
</FontListItem>
|
||||
{/snippet}
|
||||
|
||||
Reference in New Issue
Block a user