Files
frontend-svelte/src/widgets/ComparisonView/model/stores/comparisonStore.svelte.ts
T

382 lines
11 KiB
TypeScript
Raw Normal View History

/**
* 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 FontsByIdsStore + TanStack Query
* - Sample text management
* - Typography controls (size, weight, line height, spacing)
* - Slider position for character-by-character morphing
*/
import {
type FontCatalogStore,
type FontLifecycleManager,
type FontLoadRequestConfig,
FontsByIdsStore,
type UnifiedFont,
getFontCatalog,
getFontLifecycleManager,
getFontUrl,
} from '$entities/Font';
import {
type TypographySettingsStore,
getTypographySettingsStore,
} from '$features/AdjustTypography/model';
import { createPersistentStore } from '$shared/lib';
import { untrack } from 'svelte';
import { getPretextFontString } from '../../lib';
/**
* Storage schema for comparison state
*/
interface ComparisonState {
2026-04-17 12:14:55 +03:00
/**
* Unique identifier for the primary font being compared (Side A)
*/
fontAId: string | null;
2026-04-17 12:14:55 +03:00
/**
* Unique identifier for the secondary font being compared (Side B)
*/
fontBId: string | null;
}
export type Side = 'A' | 'B';
const STORAGE_KEY = 'glyphdiff:comparison';
/**
* Max time the UI waits after a font-load failure before unblocking
* (#fontsReady = true). Acts as a safety net so a transient load error
* can't strand the comparison view in a permanent loading state.
*/
const FONT_READY_FALLBACK_MS = 1000;
// Persistent storage for selected comparison fonts
const storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
fontAId: null,
fontBId: null,
});
/**
* Store for managing font comparison state.
*
* Uses FontsByIdsStore (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 {
2026-04-17 12:14:55 +03:00
/**
* The primary font model for Side A (left/top)
*/
#fontA = $state<UnifiedFont | undefined>();
2026-04-17 12:14:55 +03:00
/**
* The secondary font model for Side B (right/bottom)
*/
#fontB = $state<UnifiedFont | undefined>();
2026-04-17 12:14:55 +03:00
/**
* The preview text string displayed in the comparison area
*/
#sampleText = $state('The quick brown fox jumps over the lazy dog');
2026-04-17 12:14:55 +03:00
/**
* Flag indicating if both fonts are successfully loaded and ready for rendering
*/
#fontsReady = $state(false);
2026-04-17 12:14:55 +03:00
/**
* Currently active side (A or B) for single-font adjustments
*/
#side = $state<Side>('A');
2026-04-17 12:14:55 +03:00
/**
* Interactive slider position (0-100) used for morphing/layout transitions
*/
#sliderPosition = $state(50);
2026-04-17 12:14:55 +03:00
/**
* TanStack Query-backed store for efficient batch font retrieval
*/
#fontsByIdsStore: FontsByIdsStore;
#fontCatalog: FontCatalogStore;
#typography: TypographySettingsStore;
#lifecycle: FontLifecycleManager;
constructor() {
// Synchronously seed the batch store with any IDs already in storage
const { fontAId, fontBId } = storage.value;
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
this.#fontCatalog = getFontCatalog();
this.#typography = getTypographySettingsStore();
this.#lifecycle = getFontLifecycleManager();
$effect.root(() => {
// Sync batch results → fontA / fontB
$effect(() => {
const fonts = this.#fontsByIdsStore.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;
}
}
});
// 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) {
this.#lifecycle.touch(configs);
this.#checkFontsLoaded();
}
});
// Set default fonts when storage is empty
$effect(() => {
if (this.#fontA && this.#fontB) {
return;
}
// Don't clobber a pending rehydration - only seed when storage is empty.
// Untracked: only the catalog load should drive this effect, not the
// user's storage writes that happen as a result of normal selection.
const hasStoredSelection = untrack(() => {
return storage.value.fontAId !== null || storage.value.fontBId !== null;
});
if (hasStoredSelection) {
return;
}
const fonts = this.#fontCatalog.fonts;
if (fonts.length < 2) {
return;
}
untrack(() => {
const id1 = fonts[0].id;
const id2 = fonts[fonts.length - 1].id;
storage.value = { fontAId: id1, fontBId: id2 };
this.#fontsByIdsStore.setIds([id1, id2]);
});
});
// Pin fontA/fontB so eviction never removes on-screen fonts
$effect(() => {
const fa = this.#fontA;
const fb = this.#fontB;
const w = this.#typography.weight;
if (fa) {
this.#lifecycle.pin(fa.id, w, fa.features?.isVariable);
}
if (fb) {
this.#lifecycle.pin(fb.id, w, fb.features?.isVariable);
}
return () => {
if (fa) {
this.#lifecycle.unpin(fa.id, w, fa.features?.isVariable);
}
if (fb) {
this.#lifecycle.unpin(fb.id, w, fb.features?.isVariable);
}
};
});
});
}
/**
* 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 = getPretextFontString(weight, size, fontAName);
const fontBString = getPretextFontString(weight, size, 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), FONT_READY_FALLBACK_MS);
}
}
/**
* Updates persistent storage with the current font selection.
*/
private updateStorage() {
storage.value = {
fontAId: this.#fontA?.id ?? null,
fontBId: this.#fontB?.id ?? null,
};
}
2026-04-17 12:14:55 +03:00
/**
* Primary font for comparison (reactive)
*/
get fontA() {
return this.#fontA;
}
set fontA(font: UnifiedFont | undefined) {
this.#fontA = font;
this.updateStorage();
}
2026-04-17 12:14:55 +03:00
/**
* Secondary font for comparison (reactive)
*/
get fontB() {
return this.#fontB;
}
set fontB(font: UnifiedFont | undefined) {
this.#fontB = font;
this.updateStorage();
}
2026-04-17 12:14:55 +03:00
/**
* Shared preview text string (reactive)
*/
get text() {
return this.#sampleText;
}
set text(value: string) {
this.#sampleText = value;
}
2026-04-17 12:14:55 +03:00
/**
* Side currently selected for focused manipulation (reactive)
*/
get side() {
return this.#side;
}
set side(value: Side) {
this.#side = value;
}
2026-04-17 12:14:55 +03:00
/**
* Morphing slider position (0-100) used by Character components (reactive)
*/
get sliderPosition() {
return this.#sliderPosition;
}
set sliderPosition(value: number) {
this.#sliderPosition = value;
}
2026-04-17 12:14:55 +03:00
/**
* True if both fonts are ready for side-by-side display (reactive)
*/
get isReady() {
return !!this.#fontA && !!this.#fontB && this.#fontsReady;
}
2026-04-17 12:14:55 +03:00
/**
* True if any font is currently being fetched or loaded (reactive)
*/
get isLoading() {
return this.#fontsByIdsStore.isLoading || !this.#fontsReady;
}
/**
* Resets all state, clears storage, and disables the batch query.
*/
resetAll() {
this.#fontA = undefined;
this.#fontB = undefined;
this.#fontsByIdsStore.setIds([]);
storage.clear();
this.#typography.reset();
}
}
let _comparisonStore: ComparisonStore | undefined;
export function getComparisonStore(): ComparisonStore {
return (_comparisonStore ??= new ComparisonStore());
}
// test-only reset, so specs don't share a live observer
export function __resetComparisonStore() {
_comparisonStore?.resetAll();
_comparisonStore = undefined;
}