/** * Font comparison store — TanStack Query refactor * * Manages the state for comparing two fonts character by character. * Persists font selection to localStorage and handles font loading * with the CSS Font Loading API to prevent Flash of Unstyled Text (FOUT). * * Features: * - Persistent font selection (survives page refresh) * - Font loading state tracking via BatchFontStore + TanStack Query * - Sample text management * - Typography controls (size, weight, line height, spacing) * - Slider position for character-by-character morphing */ import { BatchFontStore, type FontLoadRequestConfig, type UnifiedFont, appliedFontsManager, fontStore, getFontUrl, } from '$entities/Font'; import { DEFAULT_TYPOGRAPHY_CONTROLS_DATA, createTypographyControlManager, } from '$features/SetupFont'; import { createPersistentStore } from '$shared/lib'; import { untrack } from 'svelte'; /** * Storage schema for comparison state */ interface ComparisonState { /** Font ID for side A (left/top) */ fontAId: string | null; /** Font ID for side B (right/bottom) */ fontBId: string | null; } export type Side = 'A' | 'B'; // Persistent storage for selected comparison fonts const storage = createPersistentStore('glyphdiff:comparison', { fontAId: null, fontBId: null, }); /** * Store for managing font comparison state. * * Uses BatchFontStore (TanStack Query) to fetch fonts by ID, replacing * the previous hand-rolled async fetch approach. Three reactive effects * handle: (1) syncing batch results into fontA/fontB, (2) triggering the * CSS Font Loading API, and (3) falling back to default fonts when * storage is empty. */ export class ComparisonStore { /** Font for side A */ #fontA = $state(); /** Font for side B */ #fontB = $state(); /** Sample text to display */ #sampleText = $state('The quick brown fox jumps over the lazy dog'); /** Whether fonts are loaded and ready to display */ #fontsReady = $state(false); /** Active side for single-font operations */ #side = $state('A'); /** Slider position for character morphing (0-100) */ #sliderPosition = $state(50); /** Typography controls for this comparison */ #typography = createTypographyControlManager(DEFAULT_TYPOGRAPHY_CONTROLS_DATA, 'glyphdiff:comparison:typography'); /** TanStack Query-backed batch font fetcher */ #batchStore: BatchFontStore; constructor() { // Synchronously seed the batch store with any IDs already in storage const { fontAId, fontBId } = storage.value; this.#batchStore = new BatchFontStore(fontAId && fontBId ? [fontAId, fontBId] : []); $effect.root(() => { // Effect 1: Sync batch results → fontA / fontB $effect(() => { const fonts = this.#batchStore.fonts; if (fonts.length === 0) return; const { fontAId: aId, fontBId: bId } = storage.value; if (aId) { const fa = fonts.find(f => f.id === aId); if (fa) this.#fontA = fa; } if (bId) { const fb = fonts.find(f => f.id === bId); if (fb) this.#fontB = fb; } }); // Effect 2: Trigger font loading whenever selection or weight changes $effect(() => { const fa = this.#fontA; const fb = this.#fontB; const weight = this.#typography.weight; if (!fa || !fb) return; const configs: FontLoadRequestConfig[] = []; [fa, fb].forEach(f => { const url = getFontUrl(f, weight); if (url) { configs.push({ id: f.id, name: f.name, weight, url, isVariable: f.features?.isVariable, }); } }); if (configs.length > 0) { appliedFontsManager.touch(configs); this.#checkFontsLoaded(); } }); // Effect 3: Set default fonts when storage is empty $effect(() => { if (this.#fontA && this.#fontB) return; const fonts = fontStore.fonts; if (fonts.length >= 2) { untrack(() => { const id1 = fonts[0].id; const id2 = fonts[fonts.length - 1].id; storage.value = { fontAId: id1, fontBId: id2 }; this.#batchStore.setIds([id1, id2]); }); } }); }); } /** * Checks if fonts are actually loaded in the browser at current weight. * * Uses CSS Font Loading API to prevent FOUT. Waits for fonts to load * and forces a layout/paint cycle before marking as ready. */ async #checkFontsLoaded() { if (!('fonts' in document)) { this.#fontsReady = true; return; } const weight = this.#typography.weight; const size = this.#typography.renderedSize; const fontAName = this.#fontA?.name; const fontBName = this.#fontB?.name; if (!fontAName || !fontBName) return; const fontAString = `${weight} ${size}px "${fontAName}"`; const fontBString = `${weight} ${size}px "${fontBName}"`; // Check if already loaded to avoid UI flash const isALoaded = document.fonts.check(fontAString); const isBLoaded = document.fonts.check(fontBString); if (isALoaded && isBLoaded) { this.#fontsReady = true; return; } this.#fontsReady = false; try { await Promise.all([ document.fonts.load(fontAString), document.fonts.load(fontBString), ]); await document.fonts.ready; await new Promise(resolve => { requestAnimationFrame(() => { requestAnimationFrame(resolve); }); }); this.#fontsReady = true; } catch (error) { console.warn('[ComparisonStore] Font loading failed:', error); setTimeout(() => (this.#fontsReady = true), 1000); } } /** * Updates persistent storage with the current font selection. */ private updateStorage() { storage.value = { fontAId: this.#fontA?.id ?? null, fontBId: this.#fontB?.id ?? null, }; } // ── Getters / Setters ───────────────────────────────────────────────────── /** Typography control manager */ get typography() { return this.#typography; } /** Font for side A */ get fontA() { return this.#fontA; } set fontA(font: UnifiedFont | undefined) { this.#fontA = font; this.updateStorage(); } /** Font for side B */ get fontB() { return this.#fontB; } set fontB(font: UnifiedFont | undefined) { this.#fontB = font; this.updateStorage(); } /** Sample text to display */ get text() { return this.#sampleText; } set text(value: string) { this.#sampleText = value; } /** Active side for single-font operations */ get side() { return this.#side; } set side(value: Side) { this.#side = value; } /** Slider position (0-100) for character morphing */ get sliderPosition() { return this.#sliderPosition; } set sliderPosition(value: number) { this.#sliderPosition = value; } /** Whether both fonts are selected and loaded */ get isReady() { return !!this.#fontA && !!this.#fontB && this.#fontsReady; } /** Whether currently loading (batch fetch in flight or fonts not yet painted) */ get isLoading() { return this.#batchStore.isLoading || !this.#fontsReady; } /** * Resets all state, clears storage, and disables the batch query. */ resetAll() { this.#fontA = undefined; this.#fontB = undefined; this.#batchStore.setIds([]); storage.clear(); this.#typography.reset(); } } /** * Singleton comparison store instance */ export const comparisonStore = new ComparisonStore();