From adf6dc93ea5a5ec17a4116bdd346bc15de7fade5 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 5 Feb 2026 11:44:16 +0300 Subject: [PATCH] feat(appliedFontsStore): improvement that allow to use correct urls for variable fonts and fixes font weight problems --- .../appliedFontsStore.svelte.ts | 52 ++++++++++++------- .../ui/FontApplicator/FontApplicator.svelte | 29 ++++++++--- .../ui/FontVirtualList/FontVirtualList.svelte | 26 +++++++--- 3 files changed, 75 insertions(+), 32 deletions(-) diff --git a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts index 4517c91..4629d06 100644 --- a/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts +++ b/src/entities/Font/model/store/appliedFontsStore/appliedFontsStore.svelte.ts @@ -52,16 +52,27 @@ class AppliedFontsManager { } } - #getFontKey(id: string, weight: number): string { - return `${id.toLowerCase()}@${weight}`; + #getFontKey(config: FontConfigRequest): string { + if (config.isVariable) { + // For variable fonts, the ID is unique enough. + // Loading "Roboto" once covers "Roboto 400" and "Roboto 700" + return `${config.id.toLowerCase()}@vf`; + } + // For static fonts, we still need weight separation + return `${config.id.toLowerCase()}@${config.weight}`; } touch(configs: FontConfigRequest[]) { const now = Date.now(); configs.forEach(config => { - const key = this.#getFontKey(config.id, config.weight); + // Pass the whole config to get key + const key = this.#getFontKey(config); + this.#usageTracker.set(key, now); + // If it's already loaded, we don't need to do anything + if (this.statuses.get(key) === 'loaded') return; + if (!this.#idToBatch.has(key) && !this.#queue.has(key)) { this.#queue.set(key, config); @@ -71,8 +82,10 @@ class AppliedFontsManager { }); } - getFontStatus(id: string, weight: number) { - return this.statuses.get(this.#getFontKey(id, weight)); + getFontStatus(id: string, weight: number, isVariable: boolean = false) { + // Construct a temp config to generate key + const key = this.#getFontKey({ id, weight, name: '', url: '', isVariable }); + return this.statuses.get(key); } #processQueue() { @@ -97,27 +110,31 @@ class AppliedFontsManager { this.statuses.set(key, 'loading'); this.#idToBatch.set(key, batchId); - // Construct the @font-face rule - // Using format('truetype') for .ttf + // If variable, allow the full weight range. + // If static, lock it to the specific weight. + const weightRule = config.isVariable + ? '100 900' // Variable range (standard coverage) + : config.weight; + const fontFormat = config.isVariable ? 'truetype-variations' : 'truetype'; + cssRules += ` - @font-face { - font-family: '${config.name}'; - src: url('${config.url}') format('truetype'); - font-weight: ${config.weight}; - font-style: normal; - font-display: swap; - } - `; + @font-face { + font-family: '${config.name}'; + src: url('${config.url}') format('${fontFormat}'); + font-weight: ${weightRule}; + font-style: normal; + font-display: swap; + } + `; }); - // 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); - // Verify loading via Font Loading API + // Use the requested weight for verification, even if the rule covers a range batchEntries.forEach(([key, config]) => { document.fonts.load(`${config.weight} 1em "${config.name}"`) .then(loaded => { @@ -126,7 +143,6 @@ class AppliedFontsManager { .catch(() => this.statuses.set(key, 'error')); }); } - #purgeUnused() { const now = Date.now(); const batchesToRemove = new Set(); diff --git a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte index fbde458..ebfd362 100644 --- a/src/entities/Font/ui/FontApplicator/FontApplicator.svelte +++ b/src/entities/Font/ui/FontApplicator/FontApplicator.svelte @@ -26,6 +26,8 @@ interface Props { * Font weight */ weight?: number; + + isVariable?: boolean; /** * Additional classes */ @@ -36,27 +38,42 @@ interface Props { children?: Snippet; } -let { name, id, url, weight = 400, className, children }: Props = $props(); +let { name, id, url, weight = 400, isVariable = false, className, children }: Props = $props(); let element: Element; // Track if the user has actually scrolled this into view let hasEnteredViewport = $state(false); +const status = $derived(appliedFontsManager.getFontStatus(id, weight, isVariable)); $effect(() => { + if (status === 'loaded' || status === 'error') { + hasEnteredViewport = true; + return; + } + const observer = new IntersectionObserver(entries => { if (entries[0].isIntersecting) { hasEnteredViewport = true; - appliedFontsManager.touch([{ id, weight, name, url }]); - // Once it has entered, we can stop observing to save CPU + // Touch ensures it's in the queue. + // It's safe to call this even if VirtualList called it + // (Manager dedupes based on key) + appliedFontsManager.touch([{ + id, + weight, + name, + url, + isVariable, + }]); + observer.unobserve(element); } }); - observer.observe(element); + + if (element) observer.observe(element); return () => observer.disconnect(); }); -const status = $derived(appliedFontsManager.getFontStatus(id, weight)); // The "Show" condition: Element is in view AND (Font is ready OR it errored out) const shouldReveal = $derived(hasEnteredViewport && (status === 'loaded' || status === 'error')); @@ -69,7 +86,7 @@ const transitionClasses = $derived(