-
Notifications
You must be signed in to change notification settings - Fork 8
feat: add FiraCode Nerd Font and custom font (TTF) support #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import { useEffect, useCallback } from 'react' | ||
| import { useCustomFontStore } from '@/stores/custom-font-store' | ||
| import { persistenceApi } from '@/lib/api' | ||
| import { registerFont, unregisterFont, readFontFile, extractFontName } from '@/lib/font-loader' | ||
| import type { CustomFont } from '@/types/settings' | ||
| import { CUSTOM_FONTS_KEY, MAX_CUSTOM_FONTS, MAX_FONT_FILE_SIZE } from '@/types/settings' | ||
|
|
||
| /** | ||
| * Load and register all custom fonts from persistence on app startup | ||
| */ | ||
| export function useCustomFontsLoader(): void { | ||
| const setFonts = useCustomFontStore((state) => state.setFonts) | ||
|
|
||
| useEffect(() => { | ||
| async function load(): Promise<void> { | ||
| const result = await persistenceApi.read<CustomFont[]>(CUSTOM_FONTS_KEY) | ||
| if (result.success && result.data) { | ||
| const fonts = result.data | ||
| setFonts(fonts) | ||
|
|
||
| // Register all fonts with the browser | ||
| for (const font of fonts) { | ||
| await registerFont(font.fontFamily, font.data) | ||
| } | ||
| } else { | ||
| setFonts([]) | ||
| } | ||
| } | ||
| load() | ||
| }, [setFonts]) | ||
| } | ||
|
|
||
| /** | ||
| * Add a custom font from a file input | ||
| * Returns the added font on success, or an error message on failure | ||
| */ | ||
| export function useAddCustomFont(): (file: File) => Promise<{ success: true; font: CustomFont } | { success: false; error: string }> { | ||
| const addFont = useCustomFontStore((state) => state.addFont) | ||
| const fonts = useCustomFontStore((state) => state.fonts) | ||
|
|
||
| return useCallback( | ||
| async (file: File) => { | ||
| // Validate file type | ||
| if (!file.name.toLowerCase().endsWith('.ttf')) { | ||
| return { success: false, error: 'Only TTF font files are supported.' } | ||
| } | ||
|
|
||
| // Validate file size | ||
| if (file.size > MAX_FONT_FILE_SIZE) { | ||
| const sizeMB = (MAX_FONT_FILE_SIZE / 1024 / 1024).toFixed(0) | ||
| return { success: false, error: `Font file is too large. Maximum size is ${sizeMB}MB.` } | ||
| } | ||
|
|
||
| // Check limit | ||
| if (fonts.length >= MAX_CUSTOM_FONTS) { | ||
| return { success: false, error: `Maximum of ${MAX_CUSTOM_FONTS} custom fonts reached. Remove one first.` } | ||
| } | ||
|
|
||
| try { | ||
| // Read file as base64 | ||
| const base64Data = await readFontFile(file) | ||
| const name = extractFontName(file.name) | ||
| const fontFamily = `Custom-${name.replace(/\s+/g, '-')}` | ||
|
|
||
| // Check for duplicate name | ||
| if (fonts.some((f) => f.fontFamily === fontFamily)) { | ||
| return { success: false, error: `A custom font with the name "${name}" already exists.` } | ||
| } | ||
|
|
||
| // Register with browser | ||
| const registered = await registerFont(fontFamily, base64Data) | ||
| if (!registered) { | ||
| return { success: false, error: 'Failed to load font. The file may be corrupted.' } | ||
| } | ||
|
|
||
| const customFont: CustomFont = { | ||
| id: crypto.randomUUID(), | ||
| name, | ||
| fontFamily, | ||
| data: base64Data, | ||
| addedAt: Date.now() | ||
| } | ||
|
|
||
| // Update store and persist | ||
| addFont(customFont) | ||
| const allFonts = [...fonts, customFont] | ||
| await persistenceApi.writeDebounced(CUSTOM_FONTS_KEY, allFonts) | ||
|
|
||
| return { success: true, font: customFont } | ||
| } catch (err) { | ||
| return { success: false, error: `Failed to add font: ${String(err)}` } | ||
| } | ||
| }, | ||
| [addFont, fonts] | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Remove a custom font by ID | ||
| */ | ||
| export function useRemoveCustomFont(): (id: string) => Promise<void> { | ||
| const removeFont = useCustomFontStore((state) => state.removeFont) | ||
| const fonts = useCustomFontStore((state) => state.fonts) | ||
|
|
||
| return useCallback( | ||
| async (id: string) => { | ||
| const font = fonts.find((f) => f.id === id) | ||
| if (font) { | ||
| unregisterFont(font.fontFamily) | ||
| } | ||
|
|
||
| removeFont(id) | ||
| const remaining = fonts.filter((f) => f.id !== id) | ||
| await persistenceApi.writeDebounced(CUSTOM_FONTS_KEY, remaining) | ||
| }, | ||
| [removeFont, fonts] | ||
| ) | ||
|
Comment on lines
+101
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same stale closure issue in The 🐛 Proposed fix export function useRemoveCustomFont(): (id: string) => Promise<void> {
const removeFont = useCustomFontStore((state) => state.removeFont)
- const fonts = useCustomFontStore((state) => state.fonts)
return useCallback(
async (id: string) => {
+ const fonts = useCustomFontStore.getState().fonts
const font = fonts.find((f) => f.id === id)
if (font) {
unregisterFont(font.fontFamily)
}
removeFont(id)
- const remaining = fonts.filter((f) => f.id !== id)
+ const remaining = useCustomFontStore.getState().fonts
await persistenceApi.writeDebounced(CUSTOM_FONTS_KEY, remaining)
},
- [removeFont, fonts]
+ [removeFont]
)
}🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| /** | ||
| * Font Loader Utility | ||
| * | ||
| * Handles dynamic registration and unregistration of custom fonts | ||
| * using the CSS FontFace API. Works in both web and Tauri contexts. | ||
| */ | ||
|
|
||
| const registeredFonts = new Map<string, FontFace>() | ||
|
|
||
| /** | ||
| * Register a custom font from base64-encoded TTF data | ||
| */ | ||
| export async function registerFont(fontFamily: string, base64Data: string): Promise<boolean> { | ||
| try { | ||
| // Unregister existing font with same name first | ||
| if (registeredFonts.has(fontFamily)) { | ||
| unregisterFont(fontFamily) | ||
| } | ||
|
|
||
| const binaryData = base64ToArrayBuffer(base64Data) | ||
| const fontFace = new FontFace(fontFamily, binaryData, { | ||
| style: 'normal', | ||
| weight: '400', | ||
| display: 'swap' | ||
| }) | ||
|
|
||
| await fontFace.load() | ||
| document.fonts.add(fontFace) | ||
| registeredFonts.set(fontFamily, fontFace) | ||
|
|
||
| return true | ||
| } catch (err) { | ||
| console.error(`Failed to register font "${fontFamily}":`, err) | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Unregister a previously registered custom font | ||
| */ | ||
| export function unregisterFont(fontFamily: string): boolean { | ||
| const fontFace = registeredFonts.get(fontFamily) | ||
| if (fontFace) { | ||
| document.fonts.delete(fontFace) | ||
| registeredFonts.delete(fontFamily) | ||
| return true | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| /** | ||
| * Check if a custom font is currently registered | ||
| */ | ||
| export function isFontRegistered(fontFamily: string): boolean { | ||
| return registeredFonts.has(fontFamily) | ||
| } | ||
|
|
||
| /** | ||
| * Get all registered custom font family names | ||
| */ | ||
| export function getRegisteredFonts(): string[] { | ||
| return Array.from(registeredFonts.keys()) | ||
| } | ||
|
|
||
| /** | ||
| * Read a TTF file and return base64-encoded data | ||
| */ | ||
| export function readFontFile(file: File): Promise<string> { | ||
| return new Promise((resolve, reject) => { | ||
| const reader = new FileReader() | ||
| reader.onload = () => { | ||
| const result = reader.result as string | ||
| // Strip the data URL prefix to get pure base64 | ||
| const base64 = result.split(',')[1] | ||
| if (!base64) { | ||
| reject(new Error('Failed to read font file as base64')) | ||
| return | ||
| } | ||
| resolve(base64) | ||
| } | ||
| reader.onerror = () => reject(new Error('Failed to read font file')) | ||
| reader.readAsDataURL(file) | ||
| }) | ||
| } | ||
|
|
||
| /** | ||
| * Extract a clean font name from a filename | ||
| * e.g. "MyFont-Regular.ttf" → "MyFont" | ||
| */ | ||
| export function extractFontName(filename: string): string { | ||
| return filename | ||
| .replace(/\.ttf$/i, '') | ||
| .replace(/-(Regular|Bold|Italic|Light|Medium|SemiBold|ExtraBold|Thin|Black)$/i, '') | ||
| .replace(/([a-z])([A-Z])/g, '$1 $2') // CamelCase → spaces | ||
| .trim() | ||
| } | ||
|
|
||
| /** | ||
| * Convert base64 string to ArrayBuffer | ||
| */ | ||
| function base64ToArrayBuffer(base64: string): ArrayBuffer { | ||
| const binaryString = atob(base64) | ||
| const bytes = new Uint8Array(binaryString.length) | ||
| for (let i = 0; i < binaryString.length; i++) { | ||
| bytes[i] = binaryString.charCodeAt(i) | ||
| } | ||
| return bytes.buffer | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stale closure issue with
fontsreference.The
fontsarray is captured in theuseCallbackclosure, but it can become stale between renders. If a user adds fonts quickly in succession, the callback may operate on an outdatedfontsarray, potentially allowing duplicate fonts or exceeding the limit.Consider reading the current state directly from the store within the callback.
🐛 Proposed fix
export function useAddCustomFont(): (file: File) => Promise<{ success: true; font: CustomFont } | { success: false; error: string }> { const addFont = useCustomFontStore((state) => state.addFont) - const fonts = useCustomFontStore((state) => state.fonts) return useCallback( async (file: File) => { + // Read current fonts from store to avoid stale closure + const fonts = useCustomFontStore.getState().fonts + // Validate file type if (!file.name.toLowerCase().endsWith('.ttf')) { return { success: false, error: 'Only TTF font files are supported.' } } // ... rest of validation using fresh fonts ... // Update store and persist addFont(customFont) - const allFonts = [...fonts, customFont] + const allFonts = useCustomFontStore.getState().fonts await persistenceApi.writeDebounced(CUSTOM_FONTS_KEY, allFonts) return { success: true, font: customFont } }, - [addFont, fonts] + [addFont] ) }📝 Committable suggestion
🤖 Prompt for AI Agents