diff --git a/web/src/components/Terminal/TerminalView.tsx b/web/src/components/Terminal/TerminalView.tsx index dd795f87..cf17111f 100644 --- a/web/src/components/Terminal/TerminalView.tsx +++ b/web/src/components/Terminal/TerminalView.tsx @@ -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(null) const onMountRef = useRef(props.onMount) const onResizeRef = useRef(props.onResize) - const [fontProvider, setFontProvider] = useState(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 (
} /** - * 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 { + 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 | 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 { - switch (mode) { - case 'default': - default: - return new DefaultFontProvider() +export function getFontProvider(): ITerminalFontProvider { + return fontProvider +} + +export function ensureBuiltinFontLoaded(): Promise { + 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 }