/** * Font comparison store for side-by-side font comparison * * 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 * - Sample text management * - Typography controls (size, weight, line height, spacing) * - Slider position for character-by-character morphing */ import { type FontLoadRequestConfig, type UnifiedFont, appliedFontsManager, fetchFontsByIds, 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 * * Handles font selection persistence, fetching, and loading state tracking. * Uses the CSS Font Loading API to ensure fonts are loaded before * showing the comparison interface. */ 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 currently restoring from storage */ #isRestoring = $state(true); /** 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'); constructor() { this.restoreFromStorage(); // Reactively handle font loading and default selection $effect.root(() => { // Effect 1: 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 2: Set defaults if we aren't restoring and have no selection $effect(() => { // Wait until we are done checking storage if (this.#isRestoring) { return; } // If we already have a selection, do nothing if (this.#fontA && this.#fontB) { return; } // Check if fonts are available to set as defaults const fonts = fontStore.fonts; if (fonts.length >= 2) { // We need full objects with all URLs, so we trigger a batch fetch // This is the "batch request" seen on initial load when storage is empty untrack(() => { this.restoreDefaults([fonts[0].id, fonts[fonts.length - 1].id]); }); } }); }); } /** * Set default fonts by fetching full objects from the API */ private async restoreDefaults(ids: string[]) { this.#isRestoring = true; try { const fullFonts = await fetchFontsByIds(ids); if (fullFonts.length >= 2) { this.#fontA = fullFonts[0]; this.#fontB = fullFonts[1]; this.updateStorage(); } } catch (error) { console.warn('[ComparisonStore] Failed to set defaults:', error); } finally { this.#isRestoring = false; } } /** * 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 { // Step 1: Load fonts into memory await Promise.all([ document.fonts.load(fontAString), document.fonts.load(fontBString), ]); // Step 2: Wait for browser to be ready to render await document.fonts.ready; // Step 3: Force a layout/paint cycle (critical!) await new Promise(resolve => { requestAnimationFrame(() => { requestAnimationFrame(resolve); // Double rAF ensures paint completes }); }); this.#fontsReady = true; } catch (error) { console.warn('[ComparisonStore] Font loading failed:', error); setTimeout(() => this.#fontsReady = true, 1000); } } /** * Restore state from persistent storage * * Fetches saved fonts from the API and restores them to the store. */ async restoreFromStorage() { this.#isRestoring = true; const { fontAId, fontBId } = storage.value; if (fontAId && fontBId) { try { // Batch fetch the saved fonts const fonts = await fetchFontsByIds([fontAId, fontBId]); const loadedFontA = fonts.find((f: UnifiedFont) => f.id === fontAId); const loadedFontB = fonts.find((f: UnifiedFont) => f.id === fontBId); if (loadedFontA && loadedFontB) { this.#fontA = loadedFontA; this.#fontB = loadedFontB; } } catch (error) { console.warn('[ComparisonStore] Failed to restore fonts:', error); } } // Mark restoration as complete (whether success or fail) this.#isRestoring = false; } /** * Update storage with current state */ private updateStorage() { // Don't save if we are currently restoring (avoid race) if (this.#isRestoring) return; storage.value = { fontAId: this.#fontA?.id ?? null, fontBId: this.#fontB?.id ?? null, }; } /** 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; } /** * Check if both fonts are selected and loaded */ get isReady() { return !!this.#fontA && !!this.#fontB && this.#fontsReady; } /** Whether currently loading or restoring */ get isLoading() { return this.#isRestoring || !this.#fontsReady; } /** * Public initializer (optional, as constructor starts it) */ initialize() { if (!this.#isRestoring && !this.#fontA && !this.#fontB) { this.restoreFromStorage(); } } /** * Reset all state and clear storage */ resetAll() { this.#fontA = undefined; this.#fontB = undefined; storage.clear(); this.#typography.reset(); } } /** * Singleton comparison store instance */ export const comparisonStore = new ComparisonStore();