mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user