Files
hapi/web/src/components/Terminal/TerminalView.tsx
T
a13b54ccc7 feat(web): improve mobile terminal input (#1407)
* feat(web): improve mobile terminal input

Co-Authored-By: Codex <noreply@anthropic.com>

* fix(web): preserve terminal IME and key activation

---------

Co-authored-by: Codex <noreply@anthropic.com>
2026-08-08 13:46:19 +08:00

144 lines
4.9 KiB
TypeScript

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 { ensureBuiltinFontLoaded, getFontProvider } from '@/lib/terminalFont'
import { getInitialTerminalFontSize } from '@/hooks/useTerminalFontSize'
function resolveThemeColors(): { background: string; foreground: string; selectionBackground: string } {
const styles = getComputedStyle(document.documentElement)
const background = styles.getPropertyValue('--app-bg').trim() || '#000000'
const foreground = styles.getPropertyValue('--app-fg').trim() || '#ffffff'
const selectionBackground = styles.getPropertyValue('--app-subtle-bg').trim() || 'rgba(255, 255, 255, 0.2)'
return { background, foreground, selectionBackground }
}
export function TerminalView(props: {
onMount?: (terminal: Terminal) => void
onResize?: (cols: number, rows: number) => void
className?: string
disableStdin?: boolean
}) {
const containerRef = useRef<HTMLDivElement | null>(null)
const terminalRef = useRef<Terminal | null>(null)
const onMountRef = useRef(props.onMount)
const onResizeRef = useRef(props.onResize)
useEffect(() => {
onMountRef.current = props.onMount
}, [props.onMount])
useEffect(() => {
onResizeRef.current = props.onResize
}, [props.onResize])
useEffect(() => {
const terminal = terminalRef.current
if (!terminal) return
terminal.options.disableStdin = props.disableStdin ?? false
if (props.disableStdin) {
terminal.blur()
}
}, [props.disableStdin])
useEffect(() => {
const container = containerRef.current
if (!container) return
const abortController = new AbortController()
const fontProvider = getFontProvider()
const fontSize = getInitialTerminalFontSize()
const { background, foreground, selectionBackground } = resolveThemeColors()
const terminal = new Terminal({
cursorBlink: true,
disableStdin: props.disableStdin ?? false,
fontFamily: fontProvider.getFontFamily(),
fontSize,
theme: {
background,
foreground,
cursor: foreground,
selectionBackground
},
convertEol: true,
customGlyphs: true
})
const fitAddon = new FitAddon()
const webLinksAddon = new WebLinksAddon()
const canvasAddon = new CanvasAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(webLinksAddon)
terminal.loadAddon(canvasAddon)
terminal.open(container)
terminalRef.current = terminal
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)
}
void ensureBuiltinFontLoaded().then(loaded => {
if (!loaded) return
refreshFont(true)
})
// Cleanup on abort
abortController.signal.addEventListener('abort', () => {
observer.disconnect()
fitAddon.dispose()
webLinksAddon.dispose()
canvasAddon.dispose()
terminal.dispose()
terminalRef.current = null
})
requestAnimationFrame(() => {
fitAddon.fit()
onResizeRef.current?.(terminal.cols, terminal.rows)
})
onMountRef.current?.(terminal)
return () => abortController.abort()
}, [])
return (
<div
ref={containerRef}
className={`h-full w-full ${props.className ?? ''}`}
/>
)
}