feat(web): add built-in Nerd Font for terminal icon support (#122)

* feat(web): add built-in Nerd Font for terminal icon support

Co-authored-by: tfq <tfq@gmail.com>
Co-authored-by: weishu <twsxtd@gmail.com>
This commit is contained in:
遗憾组成人生
2026-02-02 11:17:48 +08:00
committed by GitHub
co-authored by tfq weishu
parent 5b27f6b41e
commit 61dd69d5df
2 changed files with 175 additions and 74 deletions
+51 -21
View File
@@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useRef } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { CanvasAddon } from '@xterm/addon-canvas'
import '@xterm/xterm/css/xterm.css'
import { createFontProvider, type ITerminalFontProvider } from '@/lib/terminalFont'
import { ensureBuiltinFontLoaded, getFontProvider } from '@/lib/terminalFont'
function resolveThemeColors(): { background: string; foreground: string; selectionBackground: string } {
const styles = getComputedStyle(document.documentElement)
@@ -22,12 +22,6 @@ export function TerminalView(props: {
const containerRef = useRef<HTMLDivElement | null>(null)
const onMountRef = useRef(props.onMount)
const onResizeRef = useRef(props.onResize)
const [fontProvider, setFontProvider] = useState<ITerminalFontProvider | null>(null)
// Initialize font provider
useEffect(() => {
createFontProvider('default').then(setFontProvider)
}, [])
useEffect(() => {
onMountRef.current = props.onMount
@@ -39,10 +33,11 @@ export function TerminalView(props: {
useEffect(() => {
const container = containerRef.current
if (!container || !fontProvider) {
return
}
if (!container) return
const abortController = new AbortController()
const fontProvider = getFontProvider()
const { background, foreground, selectionBackground } = resolveThemeColors()
const terminal = new Terminal({
cursorBlink: true,
@@ -66,27 +61,62 @@ export function TerminalView(props: {
terminal.loadAddon(canvasAddon)
terminal.open(container)
const resizeTerminal = () => {
const observer = new ResizeObserver(() => {
requestAnimationFrame(() => {
fitAddon.fit()
onResizeRef.current?.(terminal.cols, terminal.rows)
})
})
observer.observe(container)
const refreshFont = (forceRemeasure = false) => {
if (abortController.signal.aborted) return
const nextFamily = fontProvider.getFontFamily()
if (forceRemeasure && terminal.options.fontFamily === nextFamily) {
terminal.options.fontFamily = `${nextFamily}, "__hapi_font_refresh__"`
requestAnimationFrame(() => {
if (abortController.signal.aborted) return
terminal.options.fontFamily = nextFamily
if (terminal.rows > 0) {
terminal.refresh(0, terminal.rows - 1)
}
fitAddon.fit()
onResizeRef.current?.(terminal.cols, terminal.rows)
})
return
}
terminal.options.fontFamily = nextFamily
if (terminal.rows > 0) {
terminal.refresh(0, terminal.rows - 1)
}
fitAddon.fit()
onResizeRef.current?.(terminal.cols, terminal.rows)
}
const observer = new ResizeObserver(() => {
requestAnimationFrame(resizeTerminal)
void ensureBuiltinFontLoaded().then(loaded => {
if (!loaded) return
refreshFont(true)
})
observer.observe(container)
requestAnimationFrame(resizeTerminal)
onMountRef.current?.(terminal)
return () => {
// Cleanup on abort
abortController.signal.addEventListener('abort', () => {
observer.disconnect()
fitAddon.dispose()
webLinksAddon.dispose()
canvasAddon.dispose()
terminal.dispose()
}
}, [fontProvider])
})
requestAnimationFrame(() => {
fitAddon.fit()
onResizeRef.current?.(terminal.cols, terminal.rows)
})
onMountRef.current?.(terminal)
return () => abortController.abort()
}, [])
return (
<div
+124 -53
View File
@@ -2,9 +2,15 @@
* Terminal Font Provider
*
* Provides font configuration for terminal rendering with Nerd Font support.
* Follows SOLID principles for easy extensibility.
* Loads Nerd Font from CDN to ensure icons display correctly on all devices.
*/
const BUILTIN_FONT_NAME = 'MesloLGLDZ Nerd Font Mono'
const CDN_FONT_URLS = [
'https://cdn.jsdmirror.com/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/MesloLGLDZNerdFontMono-Regular.woff2',
'https://cdn.jsdelivr.net/gh/mshaugh/nerdfont-webfonts@v3.3.0/build/fonts/MesloLGLDZNerdFontMono-Regular.woff2'
]
/**
* Terminal font provider interface
*/
@@ -13,69 +19,134 @@ export interface ITerminalFontProvider {
* Get CSS fontFamily string for terminal
*/
getFontFamily(): string
/**
* Optional async initialization (for future smart detection)
*/
initialize?(): Promise<void>
}
/**
* Default font provider with hardcoded Nerd Font fallback list
*
* Includes common Nerd Fonts and system fallbacks.
* Zero runtime overhead - uses browser's native font fallback mechanism.
* Common local Nerd Fonts (prioritized by popularity)
* These are checked first, so users with local fonts get better rendering
*/
export class DefaultFontProvider implements ITerminalFontProvider {
private static readonly NERD_FONTS = [
// Common Nerd Fonts (prioritized by popularity)
'JetBrainsMono Nerd Font',
'JetBrainsMonoNerdFont',
'FiraCode Nerd Font',
'FiraCodeNerdFont',
'Hack Nerd Font',
'HackNerdFont',
'MapleMono NF',
'Maple Mono NF',
'Iosevka Nerd Font',
'IosevkaNerdFont',
'CaskaydiaCove Nerd Font',
'MesloLGS Nerd Font',
'SourceCodePro Nerd Font',
'UbuntuMono Nerd Font'
]
const LOCAL_NERD_FONTS = [
'JetBrainsMono Nerd Font',
'JetBrainsMonoNerdFont',
'FiraCode Nerd Font',
'FiraCodeNerdFont',
'Hack Nerd Font',
'HackNerdFont',
'MapleMono NF',
'Maple Mono NF',
'Iosevka Nerd Font',
'IosevkaNerdFont',
'CaskaydiaCove Nerd Font',
'MesloLGS Nerd Font',
'SourceCodePro Nerd Font',
'UbuntuMono Nerd Font'
]
private static readonly SYSTEM_FALLBACKS = [
'ui-monospace',
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'"Liberation Mono"',
'"Courier New"',
'monospace'
]
/**
* Generic CSS font families must be unquoted; quoted names are specific font families
*/
const GENERIC_FAMILIES = ['ui-monospace', 'monospace']
const SYSTEM_FALLBACKS = [
'"SFMono-Regular"',
'"Menlo"',
'"Monaco"',
'"Consolas"',
'"Liberation Mono"',
'"Courier New"'
]
/**
* Load Nerd Font from CDN with fallback
*/
async function loadBuiltinFont(): Promise<void> {
let lastError: Error | null = null
for (const url of CDN_FONT_URLS) {
try {
const font = new FontFace(
BUILTIN_FONT_NAME,
`url(${url}) format("woff2")`,
{ style: 'normal', weight: '400', display: 'swap' }
)
await font.load()
document.fonts.add(font)
return
} catch (err) {
lastError = err as Error
console.warn(`[TerminalFont] Failed to load from ${url}, trying next...`)
}
}
throw lastError ?? new Error('All CDN URLs failed')
}
/**
* Font provider implementation
*/
class FontProvider implements ITerminalFontProvider {
private fontFamily: string
constructor(fontFamily: string) {
this.fontFamily = fontFamily
}
getFontFamily(): string {
return [
...DefaultFontProvider.NERD_FONTS.map(f => `"${f}"`),
...DefaultFontProvider.SYSTEM_FALLBACKS
].join(', ')
return this.fontFamily
}
}
const LOCAL_FONT_FAMILY = LOCAL_NERD_FONTS.map(f => `"${f}"`).join(', ')
const FONT_FAMILY_PARTS = [LOCAL_FONT_FAMILY, `"${BUILTIN_FONT_NAME}"`, ...SYSTEM_FALLBACKS, ...GENERIC_FAMILIES]
const FONT_FAMILY = FONT_FAMILY_PARTS.join(', ')
const fontProvider = new FontProvider(FONT_FAMILY)
let fontLoadPromise: Promise<boolean> | null = null
function isFontAvailable(fontName: string): boolean {
if (typeof document === 'undefined') return false
// Use canvas width comparison for reliable font detection
// document.fonts.check() is unreliable on some mobile browsers
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return false
const testString = 'mmmmmmmmmmlli'
ctx.font = '72px "__nonexistent_font_test__", monospace'
const baseWidth = ctx.measureText(testString).width
ctx.font = `72px "${fontName}", monospace`
const testWidth = ctx.measureText(testString).width
return testWidth !== baseWidth
}
function hasLocalNerdFont(): boolean {
return [BUILTIN_FONT_NAME, ...LOCAL_NERD_FONTS].some(isFontAvailable)
}
/**
* Factory function to create font provider
*
* @param mode - Provider mode (currently only 'default', extensible for future)
* @returns Font provider instance
* 获取字体 Provider(懒加载,只加载一次)
*/
export async function createFontProvider(
mode: 'default' = 'default'
): Promise<ITerminalFontProvider> {
switch (mode) {
case 'default':
default:
return new DefaultFontProvider()
export function getFontProvider(): ITerminalFontProvider {
return fontProvider
}
export function ensureBuiltinFontLoaded(): Promise<boolean> {
if (!fontLoadPromise) {
if (hasLocalNerdFont()) {
console.log('[TerminalFont] Local Nerd Font detected; skip CDN load')
fontLoadPromise = Promise.resolve(false)
} else {
fontLoadPromise = loadBuiltinFont()
.then(() => {
console.log('[TerminalFont] CDN font loaded')
return true
})
.catch(err => {
console.error('[TerminalFont] Failed to load CDN font:', err)
return false
})
}
}
return fontLoadPromise
}