diff --git a/src/app/ui/Layout.svelte b/src/app/ui/Layout.svelte index 920662e..1764df0 100644 --- a/src/app/ui/Layout.svelte +++ b/src/app/ui/Layout.svelte @@ -24,6 +24,8 @@ let { children } = $props(); + +
diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts new file mode 100644 index 0000000..6e8fa63 --- /dev/null +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -0,0 +1,143 @@ +import { SvelteMap } from 'svelte/reactivity'; + +export type FontStatus = 'loading' | 'loaded' | 'error'; + +class AppliedFontsManager { + // Stores: slug -> timestamp of last visibility + #usageTracker = new Map(); + // Stores: slug -> batchId + #slugToBatch = new Map(); + // Stores: batchId -> HTMLLinkElement (for physical cleanup) + #batchElements = new Map(); + + #queue = new Set(); + #timeoutId: ReturnType | null = null; + #PURGE_INTERVAL = 60000; // Check every minute + #TTL = 5 * 60 * 1000; // 5 minutes + #CHUNK_SIZE = 3; + + 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. + */ + touch(slugs: string[]) { + const now = Date.now(); + const toRegister: string[] = []; + + slugs.forEach(slug => { + this.#usageTracker.set(slug, now); + if (!this.#slugToBatch.has(slug)) { + toRegister.push(slug); + } + }); + + 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; + + newSlugs.forEach(s => this.#queue.add(s)); + + if (this.#timeoutId) clearTimeout(this.#timeoutId); + this.#timeoutId = setTimeout(() => this.#processQueue(), 50); + } + + getFontStatus(slug: string) { + return this.statuses.get(slug); + } + + #processQueue() { + const fullQueue = Array.from(this.#queue); + if (fullQueue.length === 0) return; + + for (let i = 0; i < fullQueue.length; i += this.#CHUNK_SIZE) { + this.#createBatch(fullQueue.slice(i, i + this.#CHUNK_SIZE)); + } + + this.#queue.clear(); + this.#timeoutId = null; + } + + #createBatch(slugs: 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('&'); + const url = `https://api.fontshare.com/v2/css?${query}&display=swap`; + + // Mark all as loading immediately + slugs.forEach(slug => this.statuses.set(slug, 'loading')); + + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = url; + link.dataset.batchId = batchId; + 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}"`) + .then(loadedFonts => { + if (loadedFonts.length > 0) { + this.statuses.set(slug, 'loaded'); + } else { + this.statuses.set(slug, 'error'); + } + }) + .catch(() => { + this.statuses.set(slug, 'error'); + }); + }); + } + + #purgeUnused() { + const now = Date.now(); + const batchesToPotentialDelete = new Set(); + const slugsToDelete: string[] = []; + + // Identify expired slugs + for (const [slug, lastUsed] of this.#usageTracker.entries()) { + if (now - lastUsed > this.#TTL) { + const batchId = this.#slugToBatch.get(slug); + if (batchId) batchesToPotentialDelete.add(batchId); + slugsToDelete.push(slug); + } + } + + // Only remove a batch if ALL fonts in that batch are expired + batchesToPotentialDelete.forEach(batchId => { + const batchSlugs = Array.from(this.#slugToBatch.entries()) + .filter(([_, bId]) => bId === batchId) + .map(([slug]) => slug); + + const allExpired = batchSlugs.every(s => slugsToDelete.includes(s)); + + if (allExpired) { + this.#batchElements.get(batchId)?.remove(); + this.#batchElements.delete(batchId); + batchSlugs.forEach(s => { + this.#slugToBatch.delete(s); + this.#usageTracker.delete(s); + }); + } + }); + } +} + +export const appliedFontsManager = new AppliedFontsManager();