diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 169877a..4517c91 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -3,33 +3,47 @@ import { SvelteMap } from 'svelte/reactivity'; export type FontStatus = 'loading' | 'loaded' | 'error'; export interface FontConfigRequest { - slug: string; + /** + * Font id + */ + id: string; + /** + * Real font name (e.g. "Lato") + */ + name: string; + /** + * The .ttf URL + */ + url: string; + /** + * Font weight + */ weight: number; + /** + * Flag of the variable weight + */ isVariable?: boolean; } /** - * Manager that handles loading of fonts from Fontshare. + * Manager that handles loading of fonts. * Logic: - * - Variable fonts: Loaded once per slug (covers all weights). - * - Static fonts: Loaded per slug + weight combination. + * - Variable fonts: Loaded once per id (covers all weights). + * - Static fonts: Loaded per id + weight combination. */ class AppliedFontsManager { - // Tracking usage: Map where key is "slug" or "slug@weight" #usageTracker = new Map(); - // Map: key -> batchId - #slugToBatch = new Map(); - // Map: batchId -> HTMLLinkElement - #batchElements = new Map(); + #idToBatch = new Map(); + // Changed to HTMLStyleElement + #batchElements = new Map(); - #queue = new Set(); + #queue = new Map(); // Track config in queue #timeoutId: ReturnType | null = null; #PURGE_INTERVAL = 60000; #TTL = 5 * 60 * 1000; - #CHUNK_SIZE = 3; + #CHUNK_SIZE = 5; // Can be larger since we're just injecting strings - // Reactive status map for UI feedback statuses = new SvelteMap(); constructor() { @@ -38,137 +52,117 @@ class AppliedFontsManager { } } - /** - * Resolves a unique key for the font asset. - */ - #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}`; + #getFontKey(id: string, weight: number): string { + return `${id.toLowerCase()}@${weight}`; } - /** - * Call this when a font is rendered on screen. - */ touch(configs: FontConfigRequest[]) { const now = Date.now(); - const toRegister: string[] = []; - - configs.forEach(({ slug, weight, isVariable = false }) => { - const key = this.#getFontKey(slug, weight, isVariable); - + configs.forEach(config => { + const key = this.#getFontKey(config.id, config.weight); this.#usageTracker.set(key, now); - if (!this.#slugToBatch.has(key)) { - toRegister.push(key); + if (!this.#idToBatch.has(key) && !this.#queue.has(key)) { + this.#queue.set(key, config); + + if (this.#timeoutId) clearTimeout(this.#timeoutId); + this.#timeoutId = setTimeout(() => this.#processQueue(), 50); } }); - - if (toRegister.length > 0) this.registerFonts(toRegister); } - registerFonts(keys: string[]) { - const newKeys = keys.filter(k => !this.#slugToBatch.has(k) && !this.#queue.has(k)); - if (newKeys.length === 0) return; - - newKeys.forEach(k => this.#queue.add(k)); - - if (this.#timeoutId) clearTimeout(this.#timeoutId); - this.#timeoutId = setTimeout(() => this.#processQueue(), 50); - } - - getFontStatus(slug: string, weight: number, isVariable: boolean) { - return this.statuses.get(this.#getFontKey(slug, weight, isVariable)); + getFontStatus(id: string, weight: number) { + return this.statuses.get(this.#getFontKey(id, weight)); } #processQueue() { - const fullQueue = Array.from(this.#queue); - if (fullQueue.length === 0) return; + const entries = Array.from(this.#queue.entries()); + if (entries.length === 0) return; - for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) { - this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE)); + for (let i = 0; i < entries.length; i += this.#CHUNK_SIZE) { + this.#createBatch(entries.slice(i, i + this.#CHUNK_SIZE)); } this.#queue.clear(); this.#timeoutId = null; } - #createBatch(keys: string[]) { + #createBatch(batchEntries: [string, FontConfigRequest][]) { if (typeof document === 'undefined') return; const batchId = crypto.randomUUID(); + let cssRules = ''; - /** - * 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('&'); + batchEntries.forEach(([key, config]) => { + this.statuses.set(key, 'loading'); + this.#idToBatch.set(key, batchId); - const url = `https://api.fontshare.com/v2/css?${query}&display=swap`; + // Construct the @font-face rule + // Using format('truetype') for .ttf + cssRules += ` + @font-face { + font-family: '${config.name}'; + src: url('${config.url}') format('truetype'); + font-weight: ${config.weight}; + font-style: normal; + font-display: swap; + } + `; + }); - keys.forEach(key => this.statuses.set(key, 'loading')); + // Create and inject the style tag + const style = document.createElement('style'); + style.dataset.batchId = batchId; + style.innerHTML = cssRules; + document.head.appendChild(style); + this.#batchElements.set(batchId, style); - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = url; - link.dataset.batchId = batchId; - document.head.appendChild(link); - - this.#batchElements.set(batchId, link); - - 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 => { - this.statuses.set(key, loadedFonts.length > 0 ? 'loaded' : 'error'); + // Verify loading via Font Loading API + batchEntries.forEach(([key, config]) => { + document.fonts.load(`${config.weight} 1em "${config.name}"`) + .then(loaded => { + this.statuses.set(key, loaded.length > 0 ? 'loaded' : 'error'); }) - .catch(() => { - this.statuses.set(key, 'error'); - }); + .catch(() => this.statuses.set(key, 'error')); }); } #purgeUnused() { const now = Date.now(); - const batchesToPotentialDelete = new Set(); - const keysToDelete: string[] = []; + const batchesToRemove = new Set(); + const keysToRemove: string[] = []; for (const [key, lastUsed] of this.#usageTracker.entries()) { if (now - lastUsed > this.#TTL) { - const batchId = this.#slugToBatch.get(key); - if (batchId) batchesToPotentialDelete.add(batchId); - keysToDelete.push(key); + const batchId = this.#idToBatch.get(key); + if (batchId) { + // Check if EVERY font in this batch is expired + const batchKeys = Array.from(this.#idToBatch.entries()) + .filter(([_, bId]) => bId === batchId) + .map(([k]) => k); + + const canDeleteBatch = batchKeys.every(k => { + const lastK = this.#usageTracker.get(k); + return lastK && (now - lastK > this.#TTL); + }); + + if (canDeleteBatch) { + batchesToRemove.add(batchId); + keysToRemove.push(...batchKeys); + } + } } } - batchesToPotentialDelete.forEach(batchId => { - const batchKeys = Array.from(this.#slugToBatch.entries()) - .filter(([_, bId]) => bId === batchId) - .map(([key]) => key); + batchesToRemove.forEach(id => { + this.#batchElements.get(id)?.remove(); + this.#batchElements.delete(id); + }); - const allExpired = batchKeys.every(k => keysToDelete.includes(k)); - - if (allExpired) { - this.#batchElements.get(batchId)?.remove(); - this.#batchElements.delete(batchId); - batchKeys.forEach(k => { - this.#slugToBatch.delete(k); - this.#usageTracker.delete(k); - this.statuses.delete(k); - }); - } + keysToRemove.forEach(k => { + this.#idToBatch.delete(k); + this.#usageTracker.delete(k); + this.statuses.delete(k); }); } }