Compare commits
10 Commits
940e20515b
...
ad6e1da292
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad6e1da292 | ||
|
|
ac8f0456b0 | ||
|
|
77668f507c | ||
|
|
23831efbe6 | ||
|
|
42854b4950 | ||
|
|
c45429f38d | ||
|
|
4d57f2084c | ||
|
|
bee529dff8 | ||
|
|
1f793278d1 | ||
|
|
4f76a03e33 |
@@ -1,67 +1,109 @@
|
|||||||
import { SvelteMap } from 'svelte/reactivity';
|
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';
|
export type FontStatus = 'loading' | 'loaded' | 'error';
|
||||||
|
|
||||||
|
/** Configuration for a font load request. */
|
||||||
export interface FontConfigRequest {
|
export interface FontConfigRequest {
|
||||||
/**
|
/**
|
||||||
* Font id
|
* Unique identifier for the font (e.g., "lato", "roboto").
|
||||||
*/
|
*/
|
||||||
id: string;
|
id: string;
|
||||||
/**
|
/**
|
||||||
* Real font name (e.g. "Lato")
|
* Actual font family name recognized by the browser (e.g., "Lato", "Roboto").
|
||||||
*/
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
/**
|
/**
|
||||||
* The .ttf URL
|
* URL pointing to the font file (typically .ttf or .woff2).
|
||||||
*/
|
*/
|
||||||
url: string;
|
url: string;
|
||||||
/**
|
/**
|
||||||
* Font weight
|
* Numeric weight (100-900). Variable fonts load once per ID regardless of weight.
|
||||||
*/
|
*/
|
||||||
weight: number;
|
weight: number;
|
||||||
/**
|
/**
|
||||||
* Flag of the variable weight
|
* Variable fonts load once per ID; static fonts load per weight.
|
||||||
*/
|
*/
|
||||||
isVariable?: boolean;
|
isVariable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manager that handles loading of fonts.
|
* Manages web font loading with caching, adaptive concurrency, and automatic cleanup.
|
||||||
* Logic:
|
*
|
||||||
* - Variable fonts: Loaded once per id (covers all weights).
|
* **Two-Phase Loading Strategy:**
|
||||||
* - Static fonts: Loaded per id + weight combination.
|
* 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 {
|
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>();
|
#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>();
|
#usageTracker = new Map<string, number>();
|
||||||
|
|
||||||
|
// Fonts queued for loading by `touch()`, processed by `#processQueue()`
|
||||||
#queue = new Map<string, FontConfigRequest>();
|
#queue = new Map<string, FontConfigRequest>();
|
||||||
|
|
||||||
|
// Handle for scheduled queue processing (requestIdleCallback or setTimeout)
|
||||||
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
#timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
readonly #PURGE_INTERVAL = 60000;
|
// Interval handle for periodic cleanup (runs every PURGE_INTERVAL)
|
||||||
readonly #TTL = 5 * 60 * 1000;
|
#intervalId: ReturnType<typeof setInterval> | null = null;
|
||||||
readonly #CHUNK_SIZE = 5;
|
|
||||||
|
|
||||||
|
// 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>();
|
statuses = new SvelteMap<string, FontStatus>();
|
||||||
|
|
||||||
|
// Starts periodic cleanup timer (browser-only).
|
||||||
constructor() {
|
constructor() {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// Using a weak reference style approach isn't possible for DOM,
|
this.#intervalId = setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL);
|
||||||
// so we stick to the interval but make it highly efficient.
|
|
||||||
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 {
|
#getFontKey(id: string, weight: number, isVariable: boolean): string {
|
||||||
return isVariable ? `${id.toLowerCase()}@vf` : `${id.toLowerCase()}@${weight}`;
|
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[]) {
|
touch(configs: FontConfigRequest[]) {
|
||||||
|
if (this.#abortController.signal.aborted) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let hasNewItems = false;
|
let hasNewItems = false;
|
||||||
|
|
||||||
@@ -69,105 +111,244 @@ export class AppliedFontsManager {
|
|||||||
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
|
const key = this.#getFontKey(config.id, config.weight, !!config.isVariable);
|
||||||
this.#usageTracker.set(key, now);
|
this.#usageTracker.set(key, now);
|
||||||
|
|
||||||
if (this.statuses.get(key) === 'loaded' || this.statuses.get(key) === 'loading' || this.#queue.has(key)) {
|
const status = this.statuses.get(key);
|
||||||
continue;
|
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);
|
this.#queue.set(key, config);
|
||||||
hasNewItems = true;
|
hasNewItems = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// IMPROVEMENT: Only trigger timer if not already pending
|
|
||||||
if (hasNewItems && !this.#timeoutId) {
|
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() {
|
/** Yields to main thread during CPU-intensive parsing. Uses scheduler.yield() (Chrome/Edge) or MessageChannel fallback. */
|
||||||
this.#timeoutId = null;
|
async #yieldToMain(): Promise<void> {
|
||||||
const entries = Array.from(this.#queue.entries());
|
// @ts-expect-error - scheduler not in TypeScript lib yet
|
||||||
if (entries.length === 0) return;
|
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();
|
this.#queue.clear();
|
||||||
|
|
||||||
// Process in chunks to keep the UI responsive
|
if (this.#shouldDeferNonCritical()) {
|
||||||
for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) {
|
entries = entries.filter(([, c]) => c.isVariable || [400, 700].includes(c.weight));
|
||||||
this.#applyBatch(entries.slice(i, i + this.#CHUNK_SIZE));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async #applyBatch(batchEntries: [string, FontConfigRequest][]) {
|
// Phase 1: Concurrent fetching (I/O bound, non-blocking)
|
||||||
if (typeof document === 'undefined') return;
|
const concurrency = this.#getEffectiveConcurrency();
|
||||||
|
const buffers = new Map<string, ArrayBuffer>();
|
||||||
|
|
||||||
const batchId = crypto.randomUUID();
|
for (let i = 0; i < entries.length; i += concurrency) {
|
||||||
const keysInBatch = new Set<string>();
|
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]) => {
|
for (let j = 0; j < results.length; j++) {
|
||||||
this.statuses.set(key, 'loading');
|
if (results[j].status === 'rejected') {
|
||||||
this.#keyToBatch.set(key, batchId);
|
const [key, config] = chunk[j];
|
||||||
keysInBatch.add(key);
|
console.error(`Font fetch failed: ${config.name}`, (results[j] as PromiseRejectedResult).reason);
|
||||||
|
|
||||||
// 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);
|
|
||||||
this.statuses.set(key, 'error');
|
this.statuses.set(key, 'error');
|
||||||
});
|
this.#retryCounts.set(key, (this.#retryCounts.get(key) ?? 0) + 1);
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (canPurgeBatch) {
|
// Phase 2: Sequential parsing (CPU-intensive, yields periodically)
|
||||||
keys.forEach(key => {
|
const hasInputPending = !!(navigator as any).scheduling?.isInputPending;
|
||||||
const font = this.#loadedFonts.get(key);
|
let lastYield = performance.now();
|
||||||
if (font) document.fonts.delete(font);
|
const YIELD_INTERVAL = 8; // ms
|
||||||
|
|
||||||
this.#loadedFonts.delete(key);
|
for (const [key, config] of entries) {
|
||||||
this.#keyToBatch.delete(key);
|
const buffer = buffers.get(key);
|
||||||
this.#usageTracker.delete(key);
|
if (!buffer) continue;
|
||||||
this.statuses.delete(key);
|
|
||||||
|
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) {
|
getFontStatus(id: string, weight: number, isVariable = false) {
|
||||||
return this.statuses.get(this.#getFontKey(id, weight, isVariable));
|
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();
|
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
|
// Note: For offset === 0, we rely on the $effect above to handle the reset/init
|
||||||
// This prevents race conditions and double-setting.
|
// This prevents race conditions and double-setting.
|
||||||
if (params.offset !== 0) {
|
if (params.offset !== 0) {
|
||||||
// Append new fonts to existing ones only for pagination
|
|
||||||
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ import {
|
|||||||
Skeleton,
|
Skeleton,
|
||||||
VirtualList,
|
VirtualList,
|
||||||
} from '$shared/ui';
|
} from '$shared/ui';
|
||||||
import type { ComponentProps } from 'svelte';
|
import type {
|
||||||
|
ComponentProps,
|
||||||
|
Snippet,
|
||||||
|
} from 'svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { getFontUrl } from '../../lib';
|
import { getFontUrl } from '../../lib';
|
||||||
import {
|
import {
|
||||||
@@ -28,19 +31,21 @@ interface Props extends
|
|||||||
* Callback for when visible items change
|
* Callback for when visible items change
|
||||||
*/
|
*/
|
||||||
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
|
onVisibleItemsChange?: (items: UnifiedFont[]) => void;
|
||||||
/**
|
|
||||||
* Weight of the font
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Weight of the font
|
* Weight of the font
|
||||||
*/
|
*/
|
||||||
weight: number;
|
weight: number;
|
||||||
|
/**
|
||||||
|
* Skeleton snippet
|
||||||
|
*/
|
||||||
|
skeleton?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
children,
|
children,
|
||||||
onVisibleItemsChange,
|
onVisibleItemsChange,
|
||||||
weight,
|
weight,
|
||||||
|
skeleton,
|
||||||
...rest
|
...rest
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -100,32 +105,25 @@ function handleNearBottom(_lastVisibleIndex: number) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#key isLoading}
|
<div class="relative w-full h-full">
|
||||||
<div class="relative w-full h-full" transition:fade={{ duration: 300 }}>
|
{#if skeleton && isLoading && unifiedFontStore.fonts.length === 0}
|
||||||
{#if isLoading}
|
<!-- Show skeleton only on initial load when no fonts are loaded yet -->
|
||||||
<div class="flex flex-col gap-3 sm:gap-4 p-3 sm:p-4">
|
<div transition:fade={{ duration: 300 }}>
|
||||||
{#each Array(5) as _, i}
|
{@render skeleton()}
|
||||||
<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>
|
||||||
<div class="flex items-center justify-between mb-3 sm:mb-4">
|
{:else}
|
||||||
<Skeleton class="h-6 sm:h-8 w-1/3" />
|
<!-- VirtualList persists during pagination - no destruction/recreation -->
|
||||||
<Skeleton class="h-6 sm:h-8 w-6 sm:w-8 rounded-full" />
|
<VirtualList
|
||||||
</div>
|
items={unifiedFontStore.fonts}
|
||||||
<Skeleton class="h-24 sm:h-32 w-full" />
|
total={unifiedFontStore.pagination.total}
|
||||||
</div>
|
isLoading={isLoading}
|
||||||
{/each}
|
onVisibleItemsChange={handleInternalVisibleChange}
|
||||||
</div>
|
onNearBottom={handleNearBottom}
|
||||||
{:else}
|
{...rest}
|
||||||
<VirtualList
|
>
|
||||||
items={unifiedFontStore.fonts}
|
{#snippet children(scope)}
|
||||||
total={unifiedFontStore.pagination.total}
|
{@render children(scope)}
|
||||||
onVisibleItemsChange={handleInternalVisibleChange}
|
{/snippet}
|
||||||
onNearBottom={handleNearBottom}
|
</VirtualList>
|
||||||
{...rest}
|
{/if}
|
||||||
>
|
</div>
|
||||||
{#snippet children(scope)}
|
|
||||||
{@render children(scope)}
|
|
||||||
{/snippet}
|
|
||||||
</VirtualList>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
* Interface representing a line of text with its measured width.
|
* Interface representing a line of text with its measured width.
|
||||||
*/
|
*/
|
||||||
export interface LineData {
|
export interface LineData {
|
||||||
|
/**
|
||||||
|
* Line's text
|
||||||
|
*/
|
||||||
text: string;
|
text: string;
|
||||||
|
/**
|
||||||
|
* It's width
|
||||||
|
*/
|
||||||
width: number;
|
width: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,16 +86,23 @@ export function createCharacterComparison<
|
|||||||
container: HTMLElement | undefined,
|
container: HTMLElement | undefined,
|
||||||
measureCanvas: HTMLCanvasElement | undefined,
|
measureCanvas: HTMLCanvasElement | undefined,
|
||||||
) {
|
) {
|
||||||
if (!container || !measureCanvas || !fontA() || !fontB()) return;
|
if (!container || !measureCanvas || !fontA() || !fontB()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
// Use offsetWidth instead of getBoundingClientRect() to avoid CSS transform scaling issues
|
||||||
containerWidth = rect.width;
|
// 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
|
// Padding considerations - matches the container padding
|
||||||
const padding = window.innerWidth < 640 ? 48 : 96;
|
const padding = window.innerWidth < 640 ? 48 : 96;
|
||||||
const availableWidth = rect.width - padding;
|
const availableWidth = width - padding;
|
||||||
const ctx = measureCanvas.getContext('2d');
|
const ctx = measureCanvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const controlledFontSize = size();
|
const controlledFontSize = size();
|
||||||
const fontSize = getFontSize();
|
const fontSize = getFontSize();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*
|
*
|
||||||
* Used to render visible items with absolute positioning based on computed offsets.
|
* Used to render visible items with absolute positioning based on computed offsets.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface VirtualItem {
|
export interface VirtualItem {
|
||||||
/**
|
/**
|
||||||
* Index of the item in the data array
|
* Index of the item in the data array
|
||||||
@@ -132,6 +133,7 @@ export function createVirtualizer<T>(
|
|||||||
// Accessing measuredSizes here creates the subscription
|
// Accessing measuredSizes here creates the subscription
|
||||||
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,10 +191,13 @@ export function createVirtualizer<T>(
|
|||||||
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
const isFullyVisible = itemStart >= scrollOffset && itemEnd <= viewportEnd;
|
||||||
|
|
||||||
// Proximity calculation: 1.0 at center, 0.0 at edges
|
// 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 itemCenter = itemStart + (itemSize / 2);
|
||||||
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
|
const distanceToCenter = Math.abs(viewportCenter - itemCenter);
|
||||||
const maxDistance = containerHeight / 2;
|
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({
|
result.push({
|
||||||
index: i,
|
index: i,
|
||||||
@@ -206,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;
|
return result;
|
||||||
});
|
});
|
||||||
// Svelte Actions (The DOM Interface)
|
// Svelte Actions (The DOM Interface)
|
||||||
@@ -256,25 +251,19 @@ export function createVirtualizer<T>(
|
|||||||
scrollOffset = scrolledPastTop;
|
scrollOffset = scrolledPastTop;
|
||||||
rafId = null;
|
rafId = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔍 DIAGNOSTIC
|
|
||||||
// console.log('📜 Scroll Event:', {
|
|
||||||
// windowScrollY: window.scrollY,
|
|
||||||
// elementRectTop: rect.top,
|
|
||||||
// scrolledPastTop,
|
|
||||||
// containerHeight
|
|
||||||
// });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
containerHeight = window.innerHeight;
|
containerHeight = window.innerHeight;
|
||||||
cachedOffsetTop = getElementOffset();
|
elementOffsetTop = getElementOffset();
|
||||||
|
cachedOffsetTop = elementOffsetTop;
|
||||||
handleScroll();
|
handleScroll();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial setup
|
// Initial setup
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
cachedOffsetTop = getElementOffset();
|
elementOffsetTop = getElementOffset();
|
||||||
|
cachedOffsetTop = elementOffsetTop;
|
||||||
handleScroll();
|
handleScroll();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,6 +282,11 @@ export function createVirtualizer<T>(
|
|||||||
cancelAnimationFrame(rafId);
|
cancelAnimationFrame(rafId);
|
||||||
rafId = null;
|
rafId = null;
|
||||||
}
|
}
|
||||||
|
// Disconnect shared ResizeObserver
|
||||||
|
if (sharedResizeObserver) {
|
||||||
|
sharedResizeObserver.disconnect();
|
||||||
|
sharedResizeObserver = null;
|
||||||
|
}
|
||||||
elementRef = null;
|
elementRef = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -314,6 +308,11 @@ export function createVirtualizer<T>(
|
|||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener('scroll', handleScroll);
|
node.removeEventListener('scroll', handleScroll);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
// Disconnect shared ResizeObserver
|
||||||
|
if (sharedResizeObserver) {
|
||||||
|
sharedResizeObserver.disconnect();
|
||||||
|
sharedResizeObserver = null;
|
||||||
|
}
|
||||||
elementRef = null;
|
elementRef = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -325,51 +324,64 @@ export function createVirtualizer<T>(
|
|||||||
// Signal to trigger updates when mutating measuredSizes in place
|
// Signal to trigger updates when mutating measuredSizes in place
|
||||||
let _version = $state(0);
|
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.
|
* Svelte action to measure individual item elements for dynamic height support.
|
||||||
*
|
*
|
||||||
* Attaches a ResizeObserver to track actual element height and updates
|
* Uses a single shared ResizeObserver for all items to track actual element heights.
|
||||||
* measured sizes when dimensions change. Requires `data-index` attribute on the element.
|
* Requires `data-index` attribute on the element.
|
||||||
*
|
*
|
||||||
* @param node - The DOM element to measure (should have `data-index` attribute)
|
* @param node - The DOM element to measure (should have `data-index` attribute)
|
||||||
* @returns Object with destroy method for cleanup
|
* @returns Object with destroy method for cleanup
|
||||||
*/
|
*/
|
||||||
function measureElement(node: HTMLElement) {
|
function measureElement(node: HTMLElement) {
|
||||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
// Initialize shared observer on first use
|
||||||
if (!entry) return;
|
if (!sharedResizeObserver) {
|
||||||
const index = parseInt(node.dataset.index || '', 10);
|
sharedResizeObserver = new ResizeObserver(entries => {
|
||||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
// 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)) {
|
if (!isNaN(index)) {
|
||||||
// Accessing the version ensures we have the latest state if needed,
|
const oldHeight = measuredSizes[index];
|
||||||
// though here we just read the raw object.
|
|
||||||
const oldHeight = measuredSizes[index];
|
|
||||||
|
|
||||||
// Only update if the height difference is significant (> 0.5px)
|
// Only update if the height difference is significant (> 0.5px)
|
||||||
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
if (oldHeight === undefined || Math.abs(oldHeight - height) > 0.5) {
|
||||||
// Stuff the measurement into a temporary buffer to batch updates
|
measurementBuffer[index] = height;
|
||||||
measurementBuffer[index] = height;
|
}
|
||||||
|
|
||||||
// Schedule a single update for the next animation frame
|
|
||||||
if (frameId === null) {
|
|
||||||
frameId = requestAnimationFrame(() => {
|
|
||||||
// Mutation in place for performance
|
|
||||||
Object.assign(measuredSizes, measurementBuffer);
|
|
||||||
|
|
||||||
// Trigger reactivity
|
|
||||||
_version += 1;
|
|
||||||
|
|
||||||
// Reset buffer
|
|
||||||
measurementBuffer = {};
|
|
||||||
frameId = null;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
resizeObserver.observe(node);
|
// Schedule a single update for the next animation frame
|
||||||
return { destroy: () => resizeObserver.disconnect() };
|
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
|
// Programmatic Scroll
|
||||||
@@ -409,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 {
|
return {
|
||||||
get scrollOffset() {
|
get scrollOffset() {
|
||||||
return scrollOffset;
|
return scrollOffset;
|
||||||
@@ -430,6 +464,8 @@ export function createVirtualizer<T>(
|
|||||||
measureElement,
|
measureElement,
|
||||||
/** Programmatic scroll method to scroll to a specific item */
|
/** Programmatic scroll method to scroll to a specific item */
|
||||||
scrollToIndex,
|
scrollToIndex,
|
||||||
|
/** Programmatic scroll method to scroll to a specific pixel offset */
|
||||||
|
scrollToOffset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,13 @@
|
|||||||
- Keyboard navigation (ArrowUp/Down, Home, End)
|
- Keyboard navigation (ArrowUp/Down, Home, End)
|
||||||
- Fixed or dynamic item heights
|
- Fixed or dynamic item heights
|
||||||
- ARIA listbox/option pattern with single tab stop
|
- ARIA listbox/option pattern with single tab stop
|
||||||
- Custom shadcn ScrollArea scrollbar
|
- Native browser scroll
|
||||||
-->
|
-->
|
||||||
<script lang="ts" generics="T">
|
<script lang="ts" generics="T">
|
||||||
import { createVirtualizer } from '$shared/lib';
|
import { createVirtualizer } from '$shared/lib';
|
||||||
import { throttle } from '$shared/lib/utils';
|
import { throttle } from '$shared/lib/utils';
|
||||||
import { ScrollArea } from '$shared/shadcn/ui/scroll-area';
|
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { flip } from 'svelte/animate';
|
|
||||||
import { quintOut } from 'svelte/easing';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -136,10 +133,13 @@ let {
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: Props = $props();
|
}: 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);
|
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(() => ({
|
const virtualizer = createVirtualizer(() => ({
|
||||||
|
// Only virtualize loaded items - this keeps positions stable when new items load
|
||||||
count: items.length,
|
count: items.length,
|
||||||
data: items,
|
data: items,
|
||||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||||
@@ -147,6 +147,34 @@ const virtualizer = createVirtualizer(() => ({
|
|||||||
useWindowScroll,
|
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
|
// Attach virtualizer.container action to the viewport when it becomes available
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (viewportRef) {
|
if (viewportRef) {
|
||||||
@@ -157,7 +185,7 @@ $effect(() => {
|
|||||||
|
|
||||||
const throttledVisibleChange = throttle((visibleItems: T[]) => {
|
const throttledVisibleChange = throttle((visibleItems: T[]) => {
|
||||||
onVisibleItemsChange?.(visibleItems);
|
onVisibleItemsChange?.(visibleItems);
|
||||||
}, 150); // 150ms debounce
|
}, 150); // 150ms throttle
|
||||||
|
|
||||||
const throttledNearBottom = throttle((lastVisibleIndex: number) => {
|
const throttledNearBottom = throttle((lastVisibleIndex: number) => {
|
||||||
onNearBottom?.(lastVisibleIndex);
|
onNearBottom?.(lastVisibleIndex);
|
||||||
@@ -170,7 +198,8 @@ $effect(() => {
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
|
// Trigger onNearBottom when user scrolls near the end of loaded items (within 5 items)
|
||||||
if (virtualizer.items.length > 0 && onNearBottom) {
|
// 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];
|
const lastVisibleItem = virtualizer.items[virtualizer.items.length - 1];
|
||||||
// Compare against loaded items length, not total
|
// Compare against loaded items length, not total
|
||||||
const itemsRemaining = items.length - lastVisibleItem.index;
|
const itemsRemaining = items.length - lastVisibleItem.index;
|
||||||
@@ -184,7 +213,7 @@ $effect(() => {
|
|||||||
|
|
||||||
{#if useWindowScroll}
|
{#if useWindowScroll}
|
||||||
<div class={cn('relative w-full', className)} bind:this={viewportRef}>
|
<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)}
|
{#each virtualizer.items as item (item.key)}
|
||||||
<div
|
<div
|
||||||
use:virtualizer.measureElement
|
use:virtualizer.measureElement
|
||||||
@@ -195,7 +224,7 @@ $effect(() => {
|
|||||||
>
|
>
|
||||||
{#if item.index < items.length}
|
{#if item.index < items.length}
|
||||||
{@render children({
|
{@render children({
|
||||||
// TODO: Fix indenation rule for this case
|
// TODO: Fix indentation rule for this case
|
||||||
item: items[item.index],
|
item: items[item.index],
|
||||||
index: item.index,
|
index: item.index,
|
||||||
isFullyVisible: item.isFullyVisible,
|
isFullyVisible: item.isFullyVisible,
|
||||||
@@ -208,16 +237,16 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ScrollArea
|
<div
|
||||||
bind:viewportRef
|
bind:this={viewportRef}
|
||||||
class={cn(
|
class={cn(
|
||||||
'relative rounded-md bg-background',
|
'relative overflow-y-auto overflow-x-hidden',
|
||||||
'h-150 w-full',
|
'rounded-md bg-background',
|
||||||
|
'w-full min-h-[200px]',
|
||||||
className,
|
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)}
|
{#each virtualizer.items as item (item.key)}
|
||||||
<div
|
<div
|
||||||
use:virtualizer.measureElement
|
use:virtualizer.measureElement
|
||||||
@@ -227,7 +256,7 @@ $effect(() => {
|
|||||||
>
|
>
|
||||||
{#if item.index < items.length}
|
{#if item.index < items.length}
|
||||||
{@render children({
|
{@render children({
|
||||||
// TODO: Fix indenation rule for this case
|
// TODO: Fix indentation rule for this case
|
||||||
item: items[item.index],
|
item: items[item.index],
|
||||||
index: item.index,
|
index: item.index,
|
||||||
isFullyVisible: item.isFullyVisible,
|
isFullyVisible: item.isFullyVisible,
|
||||||
@@ -238,5 +267,5 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -8,10 +8,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { appliedFontsManager } from '$entities/Font';
|
import { appliedFontsManager } from '$entities/Font';
|
||||||
import { getFontUrl } from '$entities/Font/lib';
|
import { getFontUrl } from '$entities/Font/lib';
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import { SidebarMenu } from '$shared/ui';
|
import { SidebarMenu } from '$shared/ui';
|
||||||
|
import Drawer from '$shared/ui/Drawer/Drawer.svelte';
|
||||||
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
import { comparisonStore } from '$widgets/ComparisonSlider/model';
|
||||||
import ComparisonList from './FontList.svelte';
|
import { getContext } from 'svelte';
|
||||||
|
import FontList from './FontList.svelte';
|
||||||
import ToggleMenuButton from './ToggleMenuButton.svelte';
|
import ToggleMenuButton from './ToggleMenuButton.svelte';
|
||||||
import TypographyControls from './TypographyControls.svelte';
|
import TypographyControls from './TypographyControls.svelte';
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ const fontA = $derived(comparisonStore.fontA);
|
|||||||
const fontB = $derived(comparisonStore.fontB);
|
const fontB = $derived(comparisonStore.fontB);
|
||||||
const typography = $derived(comparisonStore.typography);
|
const typography = $derived(comparisonStore.typography);
|
||||||
let menuWrapper = $state<HTMLElement | null>(null);
|
let menuWrapper = $state<HTMLElement | null>(null);
|
||||||
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!fontA || !fontB) {
|
if (!fontA || !fontB) {
|
||||||
@@ -64,28 +68,51 @@ $effect(() => {
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SidebarMenu
|
{#if responsive.isMobile}
|
||||||
class={cn(
|
<Drawer>
|
||||||
'w-96 flex flex-col h-full pl-4 lg:pl-6 py-4 sm:py-6 sm:pt-12 gap-4 sm:gap-6 pointer-events-auto overflow-hidden',
|
{#snippet trigger({ onClick })}
|
||||||
'relative h-full transition-all duration-700 ease-out',
|
<div class={cn('absolute bottom-2 inset-x-0 z-50')}>
|
||||||
className,
|
<ToggleMenuButton bind:isActive={visible} {onClick} />
|
||||||
)}
|
</div>
|
||||||
bind:visible
|
{/snippet}
|
||||||
bind:wrapper={menuWrapper}
|
{#snippet content({ className })}
|
||||||
onClickOutside={handleToggle}
|
<div class={cn(className, 'flex flex-col gap-2 h-[60vh]')}>
|
||||||
>
|
<div class="h-full overflow-hidden">
|
||||||
{#snippet action()}
|
<FontList />
|
||||||
<!-- Always-visible mode switch -->
|
</div>
|
||||||
<div class={cn('absolute top-4 left-0 z-50', visible && 'w-full')}>
|
|
||||||
<ToggleMenuButton bind:isActive={visible} onClick={handleToggle} />
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
<div class="h-2/3 overflow-hidden">
|
|
||||||
<ComparisonList />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative flex w-auto border-b border-gray-400/50 px-2 ml-4 mr-8 lg:mr-10"></div>
|
<div class="relative flex w-auto border-b border-gray-400/50 px-2 ml-4 mr-8 lg:mr-10 flex-shrink-0">
|
||||||
<div class="mr-4 sm:mr-6">
|
</div>
|
||||||
<TypographyControls />
|
<div class="mr-4 sm:mr-6 flex-shrink-0">
|
||||||
</div>
|
<TypographyControls />
|
||||||
</SidebarMenu>
|
</div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</Drawer>
|
||||||
|
{:else}
|
||||||
|
<SidebarMenu
|
||||||
|
class={cn(
|
||||||
|
'w-96 flex flex-col h-full pl-4 lg:pl-6 py-4 sm:py-6 sm:pt-12 gap-4 sm:gap-6 pointer-events-auto overflow-hidden',
|
||||||
|
'relative h-full transition-all duration-700 ease-out',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
bind:visible
|
||||||
|
bind:wrapper={menuWrapper}
|
||||||
|
onClickOutside={handleToggle}
|
||||||
|
>
|
||||||
|
{#snippet action()}
|
||||||
|
<!-- Always-visible mode switch -->
|
||||||
|
<div class={cn('absolute top-4 left-0 z-50', visible && 'w-full')}>
|
||||||
|
<ToggleMenuButton bind:isActive={visible} onClick={handleToggle} />
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
<div class="h-2/3 overflow-hidden">
|
||||||
|
<FontList />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative flex w-auto border-b border-gray-400/50 px-2 ml-4 mr-8 lg:mr-10"></div>
|
||||||
|
<div class="mr-4 sm:mr-6">
|
||||||
|
<TypographyControls />
|
||||||
|
</div>
|
||||||
|
</SidebarMenu>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ function isFontB(font: UnifiedFont): boolean {
|
|||||||
<FontVirtualList
|
<FontVirtualList
|
||||||
weight={typography.weight}
|
weight={typography.weight}
|
||||||
itemHeight={36}
|
itemHeight={36}
|
||||||
class="bg-transparent"
|
class="bg-transparent h-full"
|
||||||
>
|
>
|
||||||
{#snippet children({ item: font })}
|
{#snippet children({ item: font })}
|
||||||
{@const isSelectedA = isFontA(font)}
|
{@const isSelectedA = isFontA(font)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
controlManager,
|
controlManager,
|
||||||
} from '$features/SetupFont';
|
} from '$features/SetupFont';
|
||||||
import { throttle } from '$shared/lib/utils';
|
import { throttle } from '$shared/lib/utils';
|
||||||
|
import { Skeleton } from '$shared/ui';
|
||||||
|
|
||||||
let text = $state('The quick brown fox jumps over the lazy dog...');
|
let text = $state('The quick brown fox jumps over the lazy dog...');
|
||||||
let wrapper = $state<HTMLDivElement | null>(null);
|
let wrapper = $state<HTMLDivElement | null>(null);
|
||||||
@@ -43,6 +44,20 @@ const checkPosition = throttle(() => {
|
|||||||
}, 100);
|
}, 100);
|
||||||
</script>
|
</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
|
<svelte:window
|
||||||
bind:innerHeight
|
bind:innerHeight
|
||||||
onscroll={checkPosition}
|
onscroll={checkPosition}
|
||||||
@@ -54,6 +69,7 @@ const checkPosition = throttle(() => {
|
|||||||
itemHeight={220}
|
itemHeight={220}
|
||||||
useWindowScroll={true}
|
useWindowScroll={true}
|
||||||
weight={controlManager.weight}
|
weight={controlManager.weight}
|
||||||
|
{skeleton}
|
||||||
>
|
>
|
||||||
{#snippet children({
|
{#snippet children({
|
||||||
item: font,
|
item: font,
|
||||||
|
|||||||
Reference in New Issue
Block a user