diff --git a/src/entities/Font/lib/getFontUrl/getFontUrl.test.ts b/src/entities/Font/lib/getFontUrl/getFontUrl.test.ts new file mode 100644 index 0000000..3902421 --- /dev/null +++ b/src/entities/Font/lib/getFontUrl/getFontUrl.test.ts @@ -0,0 +1,592 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import type { UnifiedFont } from '../../model/types'; +import { getFontUrl } from './getFontUrl'; + +/** + * Helper function to create a minimal UnifiedFont mock for testing + */ +function createMockFont( + overrides: Partial = {}, +): UnifiedFont { + const baseFont: UnifiedFont = { + id: 'test-font', + name: 'Test Font', + provider: 'google', + category: 'sans-serif', + subsets: ['latin'], + variants: [], + styles: {}, + metadata: { + cachedAt: Date.now(), + }, + features: { + isVariable: false, + tags: [], + }, + }; + + return { ...baseFont, ...overrides }; +} + +describe('getFontUrl', () => { + describe('basic logic', () => { + it('returns URL for exact weight match in variants', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + '700': 'https://example.com/font-700.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBe('https://example.com/font-400.woff2'); + }); + + it('returns URL for weight 700', () => { + const font = createMockFont({ + styles: { + variants: { + '700': 'https://example.com/font-700.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-700.woff2'); + }); + + it('returns URL for weight 100 (lightest)', () => { + const font = createMockFont({ + styles: { + variants: { + '100': 'https://example.com/font-100.woff2', + }, + }, + }); + + const result = getFontUrl(font, 100); + + expect(result).toBe('https://example.com/font-100.woff2'); + }); + + it('returns URL for weight 900 (boldest)', () => { + const font = createMockFont({ + styles: { + variants: { + '900': 'https://example.com/font-900.woff2', + }, + }, + }); + + const result = getFontUrl(font, 900); + + expect(result).toBe('https://example.com/font-900.woff2'); + }); + + it('returns URL for variable font (backend maps weight to VF URL)', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-variable.woff2', + '700': 'https://example.com/font-variable.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + + expect(result400).toBe('https://example.com/font-variable.woff2'); + expect(result700).toBe('https://example.com/font-variable.woff2'); + }); + }); + + describe('fallback logic', () => { + it('falls back to regular when exact weight not found', () => { + const font = createMockFont({ + styles: { + regular: 'https://example.com/font-regular.woff2', + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('falls back to variant 400 when exact weight and regular not found', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-400.woff2'); + }); + + it('falls back to variant regular when exact weight, regular, and 400 not found', () => { + const font = createMockFont({ + styles: { + variants: { + '700': 'https://example.com/font-700.woff2', + 'regular': 'https://example.com/font-regular.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('prefers regular over variants.400 for fallback', () => { + const font = createMockFont({ + styles: { + regular: 'https://example.com/font-regular.woff2', + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('returns undefined when no fallback options available', () => { + const font = createMockFont({ + styles: { + variants: { + '700': 'https://example.com/font-700.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for font with empty styles', () => { + const font = createMockFont({ + styles: {}, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBeUndefined(); + }); + + it('throws error for font with undefined styles (invalid font data)', () => { + const font = createMockFont({ + styles: undefined as any, + }); + + expect(() => getFontUrl(font, 400)).toThrow(); + }); + }); + + describe('edge cases', () => { + it('handles font with only regular URL (legacy format)', () => { + const font = createMockFont({ + styles: { + regular: 'https://example.com/font-regular.woff2', + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('handles font with only variants object', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + '700': 'https://example.com/font-700.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + + expect(result400).toBe('https://example.com/font-400.woff2'); + expect(result700).toBe('https://example.com/font-700.woff2'); + }); + + it('handles font with variants but no requested weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://example.com/font-400.woff2'); + }); + + it('handles Google Fonts style with legacy URLs', () => { + const font = createMockFont({ + styles: { + regular: 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2', + bold: 'https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1Mu72xWUlvAx05IsDqlA.woff2', + }, + }); + + const result = getFontUrl(font, 700); + + expect(result).toBe('https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2'); + }); + + it('handles Fontshare fonts with multiple weights', () => { + const font = createMockFont({ + styles: { + variants: { + '100': 'https://cdn.fontshare.com/wf/font-100.woff2', + '200': 'https://cdn.fontshare.com/wf/font-200.woff2', + '300': 'https://cdn.fontshare.com/wf/font-300.woff2', + '400': 'https://cdn.fontshare.com/wf/font-400.woff2', + '500': 'https://cdn.fontshare.com/wf/font-500.woff2', + '600': 'https://cdn.fontshare.com/wf/font-600.woff2', + '700': 'https://cdn.fontshare.com/wf/font-700.woff2', + '800': 'https://cdn.fontshare.com/wf/font-800.woff2', + '900': 'https://cdn.fontshare.com/wf/font-900.woff2', + }, + }, + }); + + // Test all valid weights + for (const weight of [100, 200, 300, 400, 500, 600, 700, 800, 900]) { + const result = getFontUrl(font, weight); + expect(result).toBe(`https://cdn.fontshare.com/wf/font-${weight}.woff2`); + } + }); + + it('handles font with partial weight coverage', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-regular.woff2', + '700': 'https://example.com/font-bold.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + const result500 = getFontUrl(font, 500); + + expect(result400).toBe('https://example.com/font-regular.woff2'); + expect(result700).toBe('https://example.com/font-bold.woff2'); + expect(result500).toBe('https://example.com/font-regular.woff2'); // Fallback + }); + + it('handles font with variants.regular as fallback', () => { + const font = createMockFont({ + styles: { + variants: { + '700': 'https://example.com/font-bold.woff2', + 'regular': 'https://example.com/font-regular.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBe('https://example.com/font-regular.woff2'); + }); + + it('handles empty variants object', () => { + const font = createMockFont({ + styles: { + variants: {}, + }, + }); + + const result = getFontUrl(font, 400); + + expect(result).toBeUndefined(); + }); + + it('returns undefined when variant URL is null and no fallback available', () => { + const font = createMockFont({ + styles: { + variants: { + '400': null as any, + '700': 'https://example.com/font-bold.woff2', + }, + }, + }); + + const result = getFontUrl(font, 400); + + // null is falsy, so it falls back to regular, 400, and then regular variant + // All are undefined, so returns undefined + expect(result).toBeUndefined(); + }); + }); + + describe('boundary tests', () => { + it('handles lowest valid weight (100)', () => { + const font = createMockFont({ + styles: { + variants: { + '100': 'https://example.com/font-100.woff2', + }, + }, + }); + + const result = getFontUrl(font, 100); + + expect(result).toBe('https://example.com/font-100.woff2'); + }); + + it('handles highest valid weight (900)', () => { + const font = createMockFont({ + styles: { + variants: { + '900': 'https://example.com/font-900.woff2', + }, + }, + }); + + const result = getFontUrl(font, 900); + + expect(result).toBe('https://example.com/font-900.woff2'); + }); + + it('handles middle weight (500)', () => { + const font = createMockFont({ + styles: { + variants: { + '500': 'https://example.com/font-500.woff2', + }, + }, + }); + + const result = getFontUrl(font, 500); + + expect(result).toBe('https://example.com/font-500.woff2'); + }); + }); + + describe('invalid weights', () => { + it('throws error for weight below 100', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 99)).toThrow('Invalid weight: 99'); + }); + + it('throws error for weight above 900', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 901)).toThrow('Invalid weight: 901'); + }); + + it('throws error for weight 0', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 0)).toThrow('Invalid weight: 0'); + }); + + it('throws error for negative weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, -100)).toThrow('Invalid weight: -100'); + }); + + it('throws error for non-numeric weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + // @ts-ignore - Testing invalid input type + expect(() => getFontUrl(font, '400' as any)).toThrow('Invalid weight: 400'); + }); + + it('throws error for decimal weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 450.5)).toThrow('Invalid weight: 450.5'); + }); + + it('throws error for weight with step of 50 (not supported)', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 450)).toThrow('Invalid weight: 450'); + }); + + it('throws error for weight with step of 10 (not supported)', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, 410)).toThrow('Invalid weight: 410'); + }); + + it('throws error for NaN weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, NaN)).toThrow('Invalid weight: NaN'); + }); + + it('throws error for Infinity weight', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + expect(() => getFontUrl(font, Infinity)).toThrow('Invalid weight: Infinity'); + }); + + it('throws descriptive error message', () => { + const font = createMockFont({ + styles: { + variants: { + '400': 'https://example.com/font-400.woff2', + }, + }, + }); + + try { + getFontUrl(font, 999); + expect.fail('Expected function to throw'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('Invalid weight: 999'); + } + }); + }); + + describe('provider-specific tests', () => { + it('handles Google Fonts with variable fonts', () => { + const font = createMockFont({ + provider: 'google', + styles: { + variants: { + '400': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2', + '700': 'https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKOzY.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + + // Variable fonts return the same URL for all weights + expect(result400).toBe(result700); + }); + + it('handles Fontshare fonts with static weights', () => { + const font = createMockFont({ + provider: 'fontshare', + styles: { + variants: { + '400': 'https://cdn.fontshare.com/wf/satoshi-regular.woff2', + '700': 'https://cdn.fontshare.com/wf/satoshi-bold.woff2', + }, + }, + }); + + const result400 = getFontUrl(font, 400); + const result700 = getFontUrl(font, 700); + + expect(result400).toBe('https://cdn.fontshare.com/wf/satoshi-regular.woff2'); + expect(result700).toBe('https://cdn.fontshare.com/wf/satoshi-bold.woff2'); + expect(result400).not.toBe(result700); + }); + }); + + describe('all valid weights test', () => { + it('handles all valid weight values', () => { + const validWeights = [100, 200, 300, 400, 500, 600, 700, 800, 900]; + + validWeights.forEach(weight => { + const font = createMockFont({ + styles: { + variants: { + [weight.toString()]: `https://example.com/font-${weight}.woff2`, + }, + }, + }); + + const result = getFontUrl(font, weight); + expect(result).toBe(`https://example.com/font-${weight}.woff2`); + }); + }); + }); +}); diff --git a/src/entities/Font/lib/getFontUrl/getFontUrl.ts b/src/entities/Font/lib/getFontUrl/getFontUrl.ts new file mode 100644 index 0000000..667cf37 --- /dev/null +++ b/src/entities/Font/lib/getFontUrl/getFontUrl.ts @@ -0,0 +1,29 @@ +import type { + FontWeight, + UnifiedFont, +} from '../../model'; + +const SIZES = [100, 200, 300, 400, 500, 600, 700, 800, 900]; + +/** + * Constructs a URL for a font based on the provided font and weight. + * @param font - The font object. + * @param weight - The weight of the font. + * @returns The URL for the font. + */ +export function getFontUrl(font: UnifiedFont, weight: number): string | undefined { + if (!SIZES.includes(weight)) { + throw new Error(`Invalid weight: ${weight}`); + } + + const weightKey = weight.toString() as FontWeight; + + // 1. Try exact match (Backend now maps "100".."900" to VF URL if variable) + if (font.styles.variants?.[weightKey]) { + return font.styles.variants[weightKey]; + } + + // 2. Fallbacks for Static Fonts (if exact weight missing) + // Try 'regular' or '400' as safe defaults + return font.styles.regular || font.styles.variants?.['400'] || font.styles.variants?.['regular']; +} diff --git a/src/entities/Font/lib/index.ts b/src/entities/Font/lib/index.ts index d2b3e0d..8d7bad8 100644 --- a/src/entities/Font/lib/index.ts +++ b/src/entities/Font/lib/index.ts @@ -4,3 +4,5 @@ export { normalizeGoogleFont, normalizeGoogleFonts, } from './normalize/normalize'; + +export { getFontUrl } from './getFontUrl/getFontUrl'; diff --git a/src/entities/Font/lib/normalize/normalize.ts b/src/entities/Font/lib/normalize/normalize.ts index 981e951..3ad73e5 100644 --- a/src/entities/Font/lib/normalize/normalize.ts +++ b/src/entities/Font/lib/normalize/normalize.ts @@ -12,6 +12,7 @@ import type { FontshareFont, GoogleFontItem, UnifiedFont, + UnifiedFontVariant, } from '../../model/types'; /** @@ -186,7 +187,7 @@ export function normalizeFontshareFont(apiFont: FontshareFont): UnifiedFont { const variants = apiFont.styles.map(style => { const weightLabel = style.weight.label; const isItalic = style.is_italic; - return isItalic ? `${weightLabel}italic` : weightLabel; + return (isItalic ? `${weightLabel}italic` : weightLabel) as UnifiedFontVariant; }); // Map styles to URLs diff --git a/src/entities/Font/model/index.ts b/src/entities/Font/model/index.ts index 7fa38b2..47daa00 100644 --- a/src/entities/Font/model/index.ts +++ b/src/entities/Font/model/index.ts @@ -37,6 +37,7 @@ export type { export { appliedFontsManager, createUnifiedFontStore, + type FontConfigRequest, selectedFontsStore, type UnifiedFontStore, unifiedFontStore, 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/model/store/baseFontStore.svelte.ts b/src/entities/Font/model/store/baseFontStore.svelte.ts index 21d62cb..6f38f97 100644 --- a/src/entities/Font/model/store/baseFontStore.svelte.ts +++ b/src/entities/Font/model/store/baseFontStore.svelte.ts @@ -9,7 +9,6 @@ import type { UnifiedFont } from '../types'; /** */ export abstract class BaseFontStore> { - // params = $state({} as TParams); cleanup: () => void; #bindings = $state<(() => Partial)[]>([]); @@ -18,9 +17,11 @@ export abstract class BaseFontStore> { params = $derived.by(() => { let merged = { ...this.#internalParams }; + // Loop through every "Cable" plugged into the store // Loop through every "Cable" plugged into the store for (const getter of this.#bindings) { - merged = { ...merged, ...getter() }; + const bindingResult = getter(); + merged = { ...merged, ...bindingResult }; } return merged as TParams; @@ -54,7 +55,7 @@ export abstract class BaseFontStore> { protected abstract getQueryKey(params: TParams): QueryKey; protected abstract fetchFn(params: TParams): Promise; - private getOptions(params = this.params): QueryObserverOptions { + protected getOptions(params = this.params): QueryObserverOptions { return { queryKey: this.getQueryKey(params), queryFn: () => this.fetchFn(params), diff --git a/src/entities/Font/model/store/index.ts b/src/entities/Font/model/store/index.ts index cd890ba..eacd64e 100644 --- a/src/entities/Font/model/store/index.ts +++ b/src/entities/Font/model/store/index.ts @@ -14,7 +14,10 @@ export { } from './unifiedFontStore.svelte'; // Applied fonts manager (CSS loading - unchanged) -export { appliedFontsManager } from './appliedFontsStore/appliedFontsStore.svelte'; +export { + appliedFontsManager, + type FontConfigRequest, +} from './appliedFontsStore/appliedFontsStore.svelte'; // Selected fonts store (user selection - unchanged) export { selectedFontsStore } from './selectedFontsStore/selectedFontsStore.svelte'; diff --git a/src/entities/Font/model/store/unifiedFontStore.svelte.ts b/src/entities/Font/model/store/unifiedFontStore.svelte.ts index b229709..da718c5 100644 --- a/src/entities/Font/model/store/unifiedFontStore.svelte.ts +++ b/src/entities/Font/model/store/unifiedFontStore.svelte.ts @@ -12,6 +12,7 @@ * - Provider-specific shortcuts for common operations */ +import type { QueryObserverOptions } from '@tanstack/query-core'; import type { ProxyFontsParams } from '../../api'; import { fetchProxyFonts } from '../../api'; import type { UnifiedFont } from '../types'; @@ -121,6 +122,19 @@ export class UnifiedFontStore extends BaseFontStore { this.#previousFilterParams = filterParams; } }); + + // Effect: Sync state from Query result (Handles Cache Hits) + $effect(() => { + const data = this.result.data; + const offset = this.params.offset || 0; + + // When we have data and we are at the start (offset 0), + // we must ensure accumulatedFonts matches the fresh (or cached) data. + // This fixes the issue where cache hits skip fetchFn side-effects. + if (offset === 0 && data && data.length > 0) { + this.#accumulatedFonts = data; + } + }); }); } @@ -145,15 +159,26 @@ export class UnifiedFontStore extends BaseFontStore { protected getQueryKey(params: ProxyFontsParams) { // Normalize params to treat empty arrays/strings as undefined const normalized = Object.entries(params).reduce((acc, [key, value]) => { - if (value === '' || (Array.isArray(value) && value.length === 0)) { + if (value === '' || value === undefined || (Array.isArray(value) && value.length === 0)) { return acc; } return { ...acc, [key]: value }; }, {}); + // Return a consistent key return ['unifiedFonts', normalized] as const; } + protected getOptions(params = this.params): QueryObserverOptions { + const hasFilters = !!(params.q || params.provider || params.category || params.subset); + return { + queryKey: this.getQueryKey(params), + queryFn: () => this.fetchFn(params), + staleTime: hasFilters ? 0 : 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }; + } + /** * Fetch function that calls the proxy API * Returns the full response including pagination metadata @@ -187,11 +212,10 @@ export class UnifiedFontStore extends BaseFontStore { }; // Accumulate fonts for infinite scroll - if (params.offset === 0) { - // Reset when starting from beginning (new search/filter) - this.#accumulatedFonts = response.fonts; - } else { - // Append new fonts to existing ones + // Note: For offset === 0, we rely on the $effect above to handle the reset/init + // This prevents race conditions and double-setting. + if (params.offset !== 0) { + // Append new fonts to existing ones only for pagination this.#accumulatedFonts = [...this.#accumulatedFonts, ...response.fonts]; } diff --git a/src/entities/Font/model/types/common.ts b/src/entities/Font/model/types/common.ts index eddcb80..bd6536e 100644 --- a/src/entities/Font/model/types/common.ts +++ b/src/entities/Font/model/types/common.ts @@ -32,3 +32,27 @@ export interface FontFilters { export type CheckboxFilter = 'providers' | 'categories' | 'subsets'; export type FilterType = CheckboxFilter | 'searchQuery'; + +/** + * Standard font weights + */ +export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; + +/** + * Italic variant format: e.g., "100italic", "400italic", "700italic" + */ +export type FontWeightItalic = `${FontWeight}italic`; + +/** + * All possible font variants + * - Numeric weights: "400", "700", etc. + * - Italic variants: "400italic", "700italic", etc. + * - Legacy names: "regular", "italic", "bold", "bolditalic" + */ +export type FontVariant = + | FontWeight + | FontWeightItalic + | 'regular' + | 'italic' + | 'bold' + | 'bolditalic'; diff --git a/src/entities/Font/model/types/google.ts b/src/entities/Font/model/types/google.ts index c69c54d..d42ab9f 100644 --- a/src/entities/Font/model/types/google.ts +++ b/src/entities/Font/model/types/google.ts @@ -4,6 +4,8 @@ * ============================================================================ */ +import type { FontVariant } from './common'; + export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'; /** @@ -86,30 +88,6 @@ export interface FontItem { */ export type GoogleFontItem = FontItem; -/** - * Standard font weights that can appear in Google Fonts API - */ -export type FontWeight = '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; - -/** - * Italic variant format: e.g., "100italic", "400italic", "700italic" - */ -export type FontWeightItalic = `${FontWeight}italic`; - -/** - * All possible font variants in Google Fonts API - * - Numeric weights: "400", "700", etc. - * - Italic variants: "400italic", "700italic", etc. - * - Legacy names: "regular", "italic", "bold", "bolditalic" - */ -export type FontVariant = - | FontWeight - | FontWeightItalic - | 'regular' - | 'italic' - | 'bold' - | 'bolditalic'; - /** * Google Fonts API file mapping * Dynamic keys that match the variants array diff --git a/src/entities/Font/model/types/index.ts b/src/entities/Font/model/types/index.ts index 80460aa..5dfa1c9 100644 --- a/src/entities/Font/model/types/index.ts +++ b/src/entities/Font/model/types/index.ts @@ -12,15 +12,15 @@ export type { FontCategory, FontProvider, FontSubset, + FontVariant, + FontWeight, + FontWeightItalic, } from './common'; // Google Fonts API types export type { FontFiles, FontItem, - FontVariant, - FontWeight, - FontWeightItalic, GoogleFontItem, GoogleFontsApiModel, } from './google'; diff --git a/src/entities/Font/model/types/normalize.ts b/src/entities/Font/model/types/normalize.ts index 91f58eb..954b8ae 100644 --- a/src/entities/Font/model/types/normalize.ts +++ b/src/entities/Font/model/types/normalize.ts @@ -8,17 +8,18 @@ import type { FontCategory, FontProvider, FontSubset, + FontVariant, } from './common'; /** * Font variant types (standardized) */ -export type UnifiedFontVariant = string; +export type UnifiedFontVariant = FontVariant; /** * Font style URLs */ -export interface FontStyleUrls { +export interface LegacyFontStyleUrls { /** Regular weight URL */ regular?: string; /** Italic URL */ @@ -29,6 +30,10 @@ export interface FontStyleUrls { boldItalic?: string; } +export interface FontStyleUrls extends LegacyFontStyleUrls { + variants?: Partial>; +} + /** * Font metadata */ 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(