2026-03-02 22:18:05 +03:00
|
|
|
/**
|
2026-04-15 22:25:34 +03:00
|
|
|
* Font comparison store — TanStack Query refactor
|
2026-03-02 22:18:05 +03:00
|
|
|
*
|
|
|
|
|
* 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)
|
2026-05-24 19:41:40 +03:00
|
|
|
* - Font loading state tracking via FontsByIdsStore + TanStack Query
|
2026-03-02 22:18:05 +03:00
|
|
|
* - Sample text management
|
|
|
|
|
* - Typography controls (size, weight, line height, spacing)
|
|
|
|
|
* - Slider position for character-by-character morphing
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import {
|
2026-04-15 11:35:37 +03:00
|
|
|
type FontLoadRequestConfig,
|
2026-03-02 22:18:05 +03:00
|
|
|
type UnifiedFont,
|
2026-05-24 20:00:43 +03:00
|
|
|
fontCatalogStore,
|
|
|
|
|
fontLifecycleManager,
|
2026-04-15 11:35:37 +03:00
|
|
|
getFontUrl,
|
2026-03-02 22:18:05 +03:00
|
|
|
} from '$entities/Font';
|
2026-05-24 18:27:10 +03:00
|
|
|
import { typographySettingsStore } from '$features/AdjustTypography/model';
|
2026-05-24 19:41:40 +03:00
|
|
|
import { FontsByIdsStore } from '$features/FetchFontsByIds';
|
2026-03-02 22:18:05 +03:00
|
|
|
import { createPersistentStore } from '$shared/lib';
|
2026-04-15 11:35:37 +03:00
|
|
|
import { untrack } from 'svelte';
|
2026-04-20 11:13:54 +03:00
|
|
|
import { getPretextFontString } from '../../lib';
|
2026-03-02 22:18:05 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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)
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
fontAId: string | null;
|
2026-04-17 12:14:55 +03:00
|
|
|
/**
|
|
|
|
|
* Unique identifier for the secondary font being compared (Side B)
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
fontBId: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type Side = 'A' | 'B';
|
|
|
|
|
|
2026-05-24 20:30:26 +03:00
|
|
|
const STORAGE_KEY = 'glyphdiff:comparison';
|
|
|
|
|
|
2026-05-24 21:13:38 +03:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
2026-03-02 22:18:05 +03:00
|
|
|
// Persistent storage for selected comparison fonts
|
2026-05-24 20:30:26 +03:00
|
|
|
const storage = createPersistentStore<ComparisonState>(STORAGE_KEY, {
|
2026-03-02 22:18:05 +03:00
|
|
|
fontAId: null,
|
|
|
|
|
fontBId: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-15 22:25:34 +03:00
|
|
|
* Store for managing font comparison state.
|
2026-03-02 22:18:05 +03:00
|
|
|
*
|
2026-05-24 19:41:40 +03:00
|
|
|
* Uses FontsByIdsStore (TanStack Query) to fetch fonts by ID, replacing
|
2026-04-15 22:25:34 +03:00
|
|
|
* 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.
|
2026-03-02 22:18:05 +03:00
|
|
|
*/
|
|
|
|
|
export class ComparisonStore {
|
2026-04-17 12:14:55 +03:00
|
|
|
/**
|
|
|
|
|
* The primary font model for Side A (left/top)
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
#fontA = $state<UnifiedFont | undefined>();
|
2026-04-17 12:14:55 +03:00
|
|
|
/**
|
|
|
|
|
* The secondary font model for Side B (right/bottom)
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
#fontB = $state<UnifiedFont | undefined>();
|
2026-04-17 12:14:55 +03:00
|
|
|
/**
|
|
|
|
|
* The preview text string displayed in the comparison area
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
#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
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
#fontsReady = $state(false);
|
2026-04-17 12:14:55 +03:00
|
|
|
/**
|
|
|
|
|
* Currently active side (A or B) for single-font adjustments
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
#side = $state<Side>('A');
|
2026-04-17 12:14:55 +03:00
|
|
|
/**
|
|
|
|
|
* Interactive slider position (0-100) used for morphing/layout transitions
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
#sliderPosition = $state(50);
|
2026-04-17 12:14:55 +03:00
|
|
|
/**
|
|
|
|
|
* TanStack Query-backed store for efficient batch font retrieval
|
|
|
|
|
*/
|
2026-05-24 19:41:40 +03:00
|
|
|
#fontsByIdsStore: FontsByIdsStore;
|
2026-03-02 22:18:05 +03:00
|
|
|
|
|
|
|
|
constructor() {
|
2026-04-15 22:25:34 +03:00
|
|
|
// Synchronously seed the batch store with any IDs already in storage
|
|
|
|
|
const { fontAId, fontBId } = storage.value;
|
2026-05-24 19:41:40 +03:00
|
|
|
this.#fontsByIdsStore = new FontsByIdsStore(fontAId && fontBId ? [fontAId, fontBId] : []);
|
2026-03-02 22:18:05 +03:00
|
|
|
|
|
|
|
|
$effect.root(() => {
|
2026-04-15 22:25:34 +03:00
|
|
|
// Effect 1: Sync batch results → fontA / fontB
|
|
|
|
|
$effect(() => {
|
2026-05-24 19:41:40 +03:00
|
|
|
const fonts = this.#fontsByIdsStore.fonts;
|
2026-04-17 13:05:36 +03:00
|
|
|
if (fonts.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-15 22:25:34 +03:00
|
|
|
|
|
|
|
|
const { fontAId: aId, fontBId: bId } = storage.value;
|
|
|
|
|
if (aId) {
|
|
|
|
|
const fa = fonts.find(f => f.id === aId);
|
2026-04-17 13:05:36 +03:00
|
|
|
if (fa) {
|
|
|
|
|
this.#fontA = fa;
|
|
|
|
|
}
|
2026-04-15 22:25:34 +03:00
|
|
|
}
|
|
|
|
|
if (bId) {
|
|
|
|
|
const fb = fonts.find(f => f.id === bId);
|
2026-04-17 13:05:36 +03:00
|
|
|
if (fb) {
|
|
|
|
|
this.#fontB = fb;
|
|
|
|
|
}
|
2026-04-15 22:25:34 +03:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Effect 2: Trigger font loading whenever selection or weight changes
|
2026-04-15 11:35:37 +03:00
|
|
|
$effect(() => {
|
|
|
|
|
const fa = this.#fontA;
|
|
|
|
|
const fb = this.#fontB;
|
2026-04-16 08:44:49 +03:00
|
|
|
const weight = typographySettingsStore.weight;
|
2026-04-15 11:35:37 +03:00
|
|
|
|
2026-04-17 13:05:36 +03:00
|
|
|
if (!fa || !fb) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-15 11:35:37 +03:00
|
|
|
|
|
|
|
|
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) {
|
2026-05-24 20:00:43 +03:00
|
|
|
fontLifecycleManager.touch(configs);
|
2026-04-15 11:35:37 +03:00
|
|
|
this.#checkFontsLoaded();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-15 22:25:34 +03:00
|
|
|
// Effect 3: Set default fonts when storage is empty
|
2026-03-02 22:18:05 +03:00
|
|
|
$effect(() => {
|
2026-04-17 13:05:36 +03:00
|
|
|
if (this.#fontA && this.#fontB) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-02 22:18:05 +03:00
|
|
|
|
2026-05-24 20:00:43 +03:00
|
|
|
const fonts = fontCatalogStore.fonts;
|
2026-03-02 22:18:05 +03:00
|
|
|
if (fonts.length >= 2) {
|
2026-04-15 11:35:37 +03:00
|
|
|
untrack(() => {
|
2026-04-15 22:25:34 +03:00
|
|
|
const id1 = fonts[0].id;
|
|
|
|
|
const id2 = fonts[fonts.length - 1].id;
|
|
|
|
|
storage.value = { fontAId: id1, fontBId: id2 };
|
2026-05-24 19:41:40 +03:00
|
|
|
this.#fontsByIdsStore.setIds([id1, id2]);
|
2026-04-15 11:35:37 +03:00
|
|
|
});
|
2026-03-02 22:18:05 +03:00
|
|
|
}
|
|
|
|
|
});
|
2026-04-16 10:55:41 +03:00
|
|
|
|
|
|
|
|
// Effect 4: Pin fontA/fontB so eviction never removes on-screen fonts
|
|
|
|
|
$effect(() => {
|
|
|
|
|
const fa = this.#fontA;
|
|
|
|
|
const fb = this.#fontB;
|
|
|
|
|
const w = typographySettingsStore.weight;
|
2026-04-17 13:05:36 +03:00
|
|
|
if (fa) {
|
2026-05-24 20:00:43 +03:00
|
|
|
fontLifecycleManager.pin(fa.id, w, fa.features?.isVariable);
|
2026-04-17 13:05:36 +03:00
|
|
|
}
|
|
|
|
|
if (fb) {
|
2026-05-24 20:00:43 +03:00
|
|
|
fontLifecycleManager.pin(fb.id, w, fb.features?.isVariable);
|
2026-04-17 13:05:36 +03:00
|
|
|
}
|
2026-04-16 10:55:41 +03:00
|
|
|
return () => {
|
2026-04-17 13:05:36 +03:00
|
|
|
if (fa) {
|
2026-05-24 20:00:43 +03:00
|
|
|
fontLifecycleManager.unpin(fa.id, w, fa.features?.isVariable);
|
2026-04-17 13:05:36 +03:00
|
|
|
}
|
|
|
|
|
if (fb) {
|
2026-05-24 20:00:43 +03:00
|
|
|
fontLifecycleManager.unpin(fb.id, w, fb.features?.isVariable);
|
2026-04-17 13:05:36 +03:00
|
|
|
}
|
2026-04-16 10:55:41 +03:00
|
|
|
};
|
|
|
|
|
});
|
2026-03-02 22:18:05 +03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 11:35:37 +03:00
|
|
|
/**
|
2026-04-15 22:25:34 +03:00
|
|
|
* Checks if fonts are actually loaded in the browser at current weight.
|
2026-03-02 22:18:05 +03:00
|
|
|
*
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 08:44:49 +03:00
|
|
|
const weight = typographySettingsStore.weight;
|
|
|
|
|
const size = typographySettingsStore.renderedSize;
|
2026-03-02 22:18:05 +03:00
|
|
|
const fontAName = this.#fontA?.name;
|
|
|
|
|
const fontBName = this.#fontB?.name;
|
|
|
|
|
|
2026-04-17 13:05:36 +03:00
|
|
|
if (!fontAName || !fontBName) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-02 22:18:05 +03:00
|
|
|
|
2026-04-20 11:13:54 +03:00
|
|
|
const fontAString = getPretextFontString(weight, size, fontAName);
|
|
|
|
|
const fontBString = getPretextFontString(weight, size, fontBName);
|
2026-03-02 22:18:05 +03:00
|
|
|
|
|
|
|
|
// 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(() => {
|
2026-04-15 22:25:34 +03:00
|
|
|
requestAnimationFrame(resolve);
|
2026-03-02 22:18:05 +03:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
this.#fontsReady = true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn('[ComparisonStore] Font loading failed:', error);
|
2026-05-24 21:13:38 +03:00
|
|
|
setTimeout(() => (this.#fontsReady = true), FONT_READY_FALLBACK_MS);
|
2026-03-02 22:18:05 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-15 22:25:34 +03:00
|
|
|
* Updates persistent storage with the current font selection.
|
2026-03-02 22:18:05 +03:00
|
|
|
*/
|
|
|
|
|
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)
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
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)
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
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)
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
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)
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
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)
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
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)
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
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)
|
|
|
|
|
*/
|
2026-03-02 22:18:05 +03:00
|
|
|
get isLoading() {
|
2026-05-24 19:41:40 +03:00
|
|
|
return this.#fontsByIdsStore.isLoading || !this.#fontsReady;
|
2026-03-02 22:18:05 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-15 22:25:34 +03:00
|
|
|
* Resets all state, clears storage, and disables the batch query.
|
2026-03-02 22:18:05 +03:00
|
|
|
*/
|
|
|
|
|
resetAll() {
|
|
|
|
|
this.#fontA = undefined;
|
|
|
|
|
this.#fontB = undefined;
|
2026-05-24 19:41:40 +03:00
|
|
|
this.#fontsByIdsStore.setIds([]);
|
2026-03-02 22:18:05 +03:00
|
|
|
storage.clear();
|
2026-04-16 08:44:49 +03:00
|
|
|
typographySettingsStore.reset();
|
2026-03-02 22:18:05 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Singleton comparison store instance
|
|
|
|
|
*/
|
|
|
|
|
export const comparisonStore = new ComparisonStore();
|