Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added public/fonts/FiraCodeNerdFont-Bold.ttf
Binary file not shown.
Binary file added public/fonts/FiraCodeNerdFont-Regular.ttf
Binary file not shown.
2 changes: 2 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useMenuUpdaterListener } from './hooks/use-menu-updater-listener'
import { useUpdateCheck } from './hooks/use-updater'
import { useUpdateToast } from './components/UpdateAvailableToast'
import { useVisibilityState } from './hooks/use-visibility-state'
import { useCustomFontsLoader } from './hooks/use-custom-fonts'

// Hook to prevent Alt key from showing the default browser menu bar
function usePreventAltMenu(): void {
Expand Down Expand Up @@ -65,6 +66,7 @@ function AppEffects(): null {
useExitCode()
useContextBarSettings()
useAppSettingsLoader()
useCustomFontsLoader()
useKeyboardShortcutsLoader()
useProjectsLoader()
useProjectsAutoSave()
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/TauriApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useMenuUpdaterListener } from './hooks/use-menu-updater-listener'
import { useUpdateCheck } from './hooks/use-updater'
import { useUpdateToast } from './components/UpdateAvailableToast'
import { useVisibilityState } from './hooks/use-visibility-state'
import { useCustomFontsLoader } from './hooks/use-custom-fonts'

const queryClient = new QueryClient()

Expand All @@ -39,6 +40,7 @@ function AppEffects(): null {
useExitCode()
useContextBarSettings()
useAppSettingsLoader()
useCustomFontsLoader()
useKeyboardShortcutsLoader()
useProjectsLoader()
useProjectsAutoSave()
Expand Down
118 changes: 118 additions & 0 deletions src/renderer/hooks/use-custom-fonts.ts
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]
)
Comment on lines +37 to +95
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Stale closure issue with fonts reference.

The fonts array is captured in the useCallback closure, but it can become stale between renders. If a user adds fonts quickly in succession, the callback may operate on an outdated fonts array, 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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]
)
export function useAddCustomFont(): (file: File) => Promise<{ success: true; font: CustomFont } | { success: false; error: string }> {
const addFont = useCustomFontStore((state) => state.addFont)
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.' }
}
// 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 = useCustomFontStore.getState().fonts
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]
)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/hooks/use-custom-fonts.ts` around lines 37 - 95, The callback
returned by useAddCustomFont captures the stale fonts variable; instead, inside
the async callback read the live state from the store (e.g., const currentFonts
= useCustomFontStore.getState().fonts) and replace all uses of fonts with
currentFonts (checks for length >= MAX_CUSTOM_FONTS, duplicate fontFamily, and
composing allFonts for persistence). Keep addFont from useCustomFontStore for
updating, and update the useCallback dependency array to omit fonts (e.g.,
[addFont]) so the closure no longer relies on a stale array; ensure
persistenceApi.writeDebounced(CUSTOM_FONTS_KEY, allFonts) uses the computed
allFonts based on currentFonts.

}

/**
* 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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same stale closure issue in useRemoveCustomFont.

The fonts reference captured in the closure can become stale. Use useCustomFontStore.getState().fonts within the callback for consistency.

🐛 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
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/hooks/use-custom-fonts.ts` around lines 101 - 117, The callback
returned by useRemoveCustomFont captures the stale fonts variable; update the
callback to read the latest fonts from useCustomFontStore.getState().fonts
inside the async function (instead of the captured fonts) so unregisterFont and
persistenceApi.writeDebounced use the current list, and adjust the useCallback
dependency array to only include removeFont; reference functions/values:
useRemoveCustomFont, useCustomFontStore.getState().fonts, removeFont,
unregisterFont, persistenceApi.writeDebounced, CUSTOM_FONTS_KEY, and
useCallback.

}
17 changes: 17 additions & 0 deletions src/renderer/index.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');

/* Bundled FiraCode Nerd Font */
@font-face {
font-family: 'FiraCode Nerd Font';
src: url('/fonts/FiraCodeNerdFont-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}

@font-face {
font-family: 'FiraCode Nerd Font';
src: url('/fonts/FiraCodeNerdFont-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}

@tailwind base;
@tailwind components;
@tailwind utilities;
Expand Down
108 changes: 108 additions & 0 deletions src/renderer/lib/font-loader.ts
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
}
Loading
Loading