From 55a560b78531d9a4b4216671449261b0174a0b8f Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Tue, 20 Jan 2026 14:17:41 +0300 Subject: [PATCH] feat(appliedFontsStore): implement the logic to update font link when font weight changes --- .../appliedFontsStore.svelte.ts | 130 +++++++++++------- .../components/SliderLine.svelte | 51 +++++-- 2 files changed, 118 insertions(+), 63 deletions(-) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 3394b6f..169877a 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -2,66 +2,83 @@ import { SvelteMap } from 'svelte/reactivity'; export type FontStatus = 'loading' | 'loaded' | 'error'; +export interface FontConfigRequest { + slug: string; + weight: number; + isVariable?: boolean; +} + /** - * Manager that handles loading of the fonts - * Adds tags to - * - Uses batch loading to reduce the number of requests - * - Uses a queue to prevent too many requests at once - * - Purges unused fonts after a certain time + * Manager that handles loading of fonts from Fontshare. + * Logic: + * - Variable fonts: Loaded once per slug (covers all weights). + * - Static fonts: Loaded per slug + weight combination. */ class AppliedFontsManager { - // Stores: slug -> timestamp of last visibility + // Tracking usage: Map where key is "slug" or "slug@weight" #usageTracker = new Map(); - // Stores: slug -> batchId + // Map: key -> batchId #slugToBatch = new Map(); - // Stores: batchId -> HTMLLinkElement (for physical cleanup) + // Map: batchId -> HTMLLinkElement #batchElements = new Map(); #queue = new Set(); #timeoutId: ReturnType | null = null; - #PURGE_INTERVAL = 60000; // Check every minute - #TTL = 5 * 60 * 1000; // 5 minutes + + #PURGE_INTERVAL = 60000; + #TTL = 5 * 60 * 1000; #CHUNK_SIZE = 3; + // Reactive status map for UI feedback statuses = new SvelteMap(); constructor() { if (typeof window !== 'undefined') { - // Start the "Janitor" loop setInterval(() => this.#purgeUnused(), this.#PURGE_INTERVAL); } } /** - * Updates the 'last seen' timestamp for fonts. - * Prevents them from being purged while they are on screen. + * Resolves a unique key for the font asset. */ - touch(slugs: string[]) { + #getFontKey(slug: string, weight: number, isVariable: boolean): string { + const s = slug.toLowerCase(); + // Variable fonts only need one entry regardless of weight + return isVariable ? s : `${s}@${weight}`; + } + + /** + * Call this when a font is rendered on screen. + */ + touch(configs: FontConfigRequest[]) { const now = Date.now(); const toRegister: string[] = []; - slugs.forEach(slug => { - this.#usageTracker.set(slug, now); - if (!this.#slugToBatch.has(slug)) { - toRegister.push(slug); + configs.forEach(({ slug, weight, isVariable = false }) => { + const key = this.#getFontKey(slug, weight, isVariable); + + this.#usageTracker.set(key, now); + + if (!this.#slugToBatch.has(key)) { + toRegister.push(key); } }); if (toRegister.length > 0) this.registerFonts(toRegister); } - registerFonts(slugs: string[]) { - const newSlugs = slugs.filter(s => !this.#slugToBatch.has(s) && !this.#queue.has(s)); - if (newSlugs.length === 0) return; + registerFonts(keys: string[]) { + const newKeys = keys.filter(k => !this.#slugToBatch.has(k) && !this.#queue.has(k)); + if (newKeys.length === 0) return; - newSlugs.forEach(s => this.#queue.add(s)); + newKeys.forEach(k => this.#queue.add(k)); if (this.#timeoutId) clearTimeout(this.#timeoutId); this.#timeoutId = setTimeout(() => this.#processQueue(), 50); } - getFontStatus(slug: string) { - return this.statuses.get(slug); + getFontStatus(slug: string, weight: number, isVariable: boolean) { + return this.statuses.get(this.#getFontKey(slug, weight, isVariable)); } #processQueue() { @@ -76,16 +93,23 @@ class AppliedFontsManager { this.#timeoutId = null; } - #createBatch(slugs: string[]) { + #createBatch(keys: string[]) { if (typeof document === 'undefined') return; const batchId = crypto.randomUUID(); - // font-display=swap included for better UX - const query = slugs.map(s => `f[]=${s.toLowerCase()}@400`).join('&'); + + /** + * Fontshare API Logic: + * - If key contains '@', it's static (e.g., satoshi@700) + * - If it's a plain slug, it's variable. We append '@1,2' for variable assets. + */ + const query = keys.map(k => { + return k.includes('@') ? `f[]=${k}` : `f[]=${k}@1,2`; + }).join('&'); + const url = `https://api.fontshare.com/v2/css?${query}&display=swap`; - // Mark all as loading immediately - slugs.forEach(slug => this.statuses.set(slug, 'loading')); + keys.forEach(key => this.statuses.set(key, 'loading')); const link = document.createElement('link'); link.rel = 'stylesheet'; @@ -94,21 +118,24 @@ class AppliedFontsManager { document.head.appendChild(link); this.#batchElements.set(batchId, link); - slugs.forEach(slug => { - this.#slugToBatch.set(slug, batchId); - // Use the Native Font Loading API - // format: "font-size font-family" - document.fonts.load(`1em "${slug}"`) + keys.forEach(key => { + this.#slugToBatch.set(key, batchId); + + // Determine what to check in the Font Loading API + const isVariable = !key.includes('@'); + const [family, staticWeight] = key.split('@'); + + // For variable fonts, we check a standard weight; + // for static, we check the specific numeric weight requested. + const weightToCheck = isVariable ? '400' : staticWeight; + + document.fonts.load(`${weightToCheck} 1em "${family}"`) .then(loadedFonts => { - if (loadedFonts.length > 0) { - this.statuses.set(slug, 'loaded'); - } else { - this.statuses.set(slug, 'error'); - } + this.statuses.set(key, loadedFonts.length > 0 ? 'loaded' : 'error'); }) .catch(() => { - this.statuses.set(slug, 'error'); + this.statuses.set(key, 'error'); }); }); } @@ -116,31 +143,30 @@ class AppliedFontsManager { #purgeUnused() { const now = Date.now(); const batchesToPotentialDelete = new Set(); - const slugsToDelete: string[] = []; + const keysToDelete: string[] = []; - // Identify expired slugs - for (const [slug, lastUsed] of this.#usageTracker.entries()) { + for (const [key, lastUsed] of this.#usageTracker.entries()) { if (now - lastUsed > this.#TTL) { - const batchId = this.#slugToBatch.get(slug); + const batchId = this.#slugToBatch.get(key); if (batchId) batchesToPotentialDelete.add(batchId); - slugsToDelete.push(slug); + keysToDelete.push(key); } } - // Only remove a batch if ALL fonts in that batch are expired batchesToPotentialDelete.forEach(batchId => { - const batchSlugs = Array.from(this.#slugToBatch.entries()) + const batchKeys = Array.from(this.#slugToBatch.entries()) .filter(([_, bId]) => bId === batchId) - .map(([slug]) => slug); + .map(([key]) => key); - const allExpired = batchSlugs.every(s => slugsToDelete.includes(s)); + const allExpired = batchKeys.every(k => keysToDelete.includes(k)); if (allExpired) { this.#batchElements.get(batchId)?.remove(); this.#batchElements.delete(batchId); - batchSlugs.forEach(s => { - this.#slugToBatch.delete(s); - this.#usageTracker.delete(s); + batchKeys.forEach(k => { + this.#slugToBatch.delete(k); + this.#usageTracker.delete(k); + this.statuses.delete(k); }); } }); diff --git a/src/shared/ui/ComparisonSlider/components/SliderLine.svelte b/src/shared/ui/ComparisonSlider/components/SliderLine.svelte index 749ab3d..47f1375 100644 --- a/src/shared/ui/ComparisonSlider/components/SliderLine.svelte +++ b/src/shared/ui/ComparisonSlider/components/SliderLine.svelte @@ -1,26 +1,55 @@ -
- -
+ +
- -
-
+ +
- -
-
+ +
+
+
+ + +
+
+ +