Files
Netcatty/application/state/uiFontStore.ts
T
bincxz 7e3e4ce3b8 Ensures UI font family applies after loading
Introduces a `useUIFontsLoaded` hook to track the loading status of UI fonts.

Updates the `useLayoutEffect` responsible for applying the UI font family to re-run once fonts are loaded. This ensures that local fonts, whose family properties might only be correctly resolved after loading, are applied accurately to the UI.

Also updates local Claude settings to allow GitHub CLI commands for development convenience.
2026-01-21 13:55:30 +08:00

208 lines
5.5 KiB
TypeScript

import { useSyncExternalStore } from 'react';
import { UI_FONTS, withUiCjkFallback, type UIFont } from '../../infrastructure/config/uiFonts';
/**
* UI Font Store - singleton pattern using useSyncExternalStore
* Fetches system fonts and combines with bundled fonts
*/
type Listener = () => void;
interface UIFontStoreState {
availableFonts: UIFont[];
isLoading: boolean;
isLoaded: boolean;
error: string | null;
}
/**
* Type definition for Local Font Access API
*/
interface LocalFontData {
family: string;
}
class UIFontStore {
private state: UIFontStoreState = {
availableFonts: UI_FONTS,
isLoading: false,
isLoaded: false,
error: null,
};
private listeners = new Set<Listener>();
getAvailableFonts = (): UIFont[] => this.state.availableFonts;
getIsLoading = (): boolean => this.state.isLoading;
getIsLoaded = (): boolean => this.state.isLoaded;
private notify = () => {
Promise.resolve().then(() => {
this.listeners.forEach(listener => listener());
});
};
private setState = (partial: Partial<UIFontStoreState>) => {
this.state = { ...this.state, ...partial };
this.notify();
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
initialize = async (): Promise<void> => {
if (this.state.isLoaded || this.state.isLoading) {
return;
}
this.setState({ isLoading: true, error: null });
try {
const localFonts = await this.getLocalFonts();
// Use a Map to deduplicate by normalized font name
const fontMap = new Map<string, UIFont>();
// Add bundled fonts first (they have priority)
UI_FONTS.forEach(font => fontMap.set(font.id, font));
// Add local fonts with a distinct ID namespace
localFonts.forEach(font => {
const localId = `local-${font.id}`;
// Skip if a bundled font with similar name exists
if (!fontMap.has(font.id)) {
fontMap.set(localId, { ...font, id: localId });
}
});
this.setState({
availableFonts: Array.from(fontMap.values()),
isLoading: false,
isLoaded: true,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load local fonts';
console.warn('Failed to fetch local UI fonts, using defaults:', error);
this.setState({
availableFonts: UI_FONTS,
isLoading: false,
isLoaded: true,
error: errorMessage,
});
}
};
private async getLocalFonts(): Promise<UIFont[]> {
if (typeof window === 'undefined' || !('queryLocalFonts' in window)) {
return [];
}
try {
const queryLocalFonts = (window as unknown as { queryLocalFonts: () => Promise<LocalFontData[]> }).queryLocalFonts;
const fonts = await queryLocalFonts();
// Deduplicate by family name
const uniqueFamilies = new Set<string>();
const dedupedFonts = fonts.filter(f => {
if (uniqueFamilies.has(f.family)) return false;
uniqueFamilies.add(f.family);
return true;
});
// Map to UIFont structure
return dedupedFonts.map(f => ({
id: f.family.toLowerCase().replace(/\s+/g, '-'),
name: f.family,
family: withUiCjkFallback(`"${f.family}", system-ui`),
}));
} catch (error) {
console.warn('Failed to query local fonts:', error);
return [];
}
}
getFontById = (fontId: string): UIFont => {
const fonts = this.state.availableFonts;
const found = fonts.find(f => f.id === fontId);
if (found) return found;
// For local fonts that haven't been loaded yet, construct a fallback
// This handles the case when main window receives a local font ID before fonts are loaded
if (fontId.startsWith('local-')) {
const fontName = fontId
.replace(/^local-/, '')
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return {
id: fontId,
name: fontName,
family: withUiCjkFallback(`"${fontName}", system-ui`),
};
}
return fonts[0] || UI_FONTS[0];
};
}
// Singleton instance
export const uiFontStore = new UIFontStore();
/**
* Get available UI fonts - triggers initialization on first use
*/
export const useAvailableUIFonts = (): UIFont[] => {
if (!uiFontStore.getIsLoaded() && !uiFontStore.getIsLoading()) {
uiFontStore.initialize();
}
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getAvailableFonts
);
};
/**
* Get UI font loading state
*/
export const useUIFontsLoading = (): boolean => {
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getIsLoading
);
};
/**
* Get UI font loaded state
*/
export const useUIFontsLoaded = (): boolean => {
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getIsLoaded
);
};
/**
* Get UI font by ID with fallback
*/
export const useUIFontById = (fontId: string): UIFont => {
const fonts = useAvailableUIFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || UI_FONTS[0];
};
/**
* Check if a font ID is valid
*/
export const isValidUiFontId = (fontId: string): boolean => {
// Local fonts are always considered valid (they start with 'local-')
if (fontId.startsWith('local-')) return true;
return uiFontStore.getAvailableFonts().some(f => f.id === fontId);
};
/**
* Initialize UI fonts eagerly
*/
export const initializeUIFonts = (): void => {
uiFontStore.initialize();
};