288 lines
8.2 KiB
TypeScript
288 lines
8.2 KiB
TypeScript
|
|
/**
|
||
|
|
* 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 UnifiedFont,
|
||
|
|
fetchFontsByIds,
|
||
|
|
unifiedFontStore,
|
||
|
|
} from '$entities/Font';
|
||
|
|
import {
|
||
|
|
DEFAULT_TYPOGRAPHY_CONTROLS_DATA,
|
||
|
|
createTypographyControlManager,
|
||
|
|
} from '$features/SetupFont';
|
||
|
|
import { createPersistentStore } from '$shared/lib';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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<ComparisonState>('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<UnifiedFont | undefined>();
|
||
|
|
/** Font for side B */
|
||
|
|
#fontB = $state<UnifiedFont | undefined>();
|
||
|
|
/** 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<Side>('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 set defaults if we aren't restoring and have no selection
|
||
|
|
$effect.root(() => {
|
||
|
|
$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) {
|
||
|
|
this.#checkFontsLoaded();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if fonts are available to set as defaults
|
||
|
|
const fonts = unifiedFontStore.fonts;
|
||
|
|
if (fonts.length >= 2) {
|
||
|
|
// Only set if we really have nothing (fallback)
|
||
|
|
if (!this.#fontA) this.#fontA = fonts[0];
|
||
|
|
if (!this.#fontB) this.#fontB = fonts[fonts.length - 1];
|
||
|
|
|
||
|
|
// Sync defaults to storage so they persist if the user leaves
|
||
|
|
this.updateStorage();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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();
|