diff --git a/frontend/src/views/Terminal.vue b/frontend/src/views/Terminal.vue index de88542..b609eb1 100644 --- a/frontend/src/views/Terminal.vue +++ b/frontend/src/views/Terminal.vue @@ -230,6 +230,7 @@ let fitAddon = null let initialScrollTop = 0 let wheelHandler = null let viewportWheelHandler = null +let isComposing = false // 标记 IME 输入法是否正在组合输入中 // 初始化终端 const initTerminal = () => { @@ -283,27 +284,70 @@ const initTerminal = () => { // 聚焦终端 term.focus() - // 隐藏辅助文本输入框的视觉干扰 - const hideHelperTextarea = () => { + // ================================================================ + // 修复中文输入法(IME)渲染到终端顶部的 Bug + // ---------------------------------------------------------------- + // 根本原因:xterm.js 内部 compositionHelper 监听了 helper textarea + // 的 compositionupdate 事件,在 IME 选字过程中会直接向 xterm 的内部 + // buffer 写入候选字并渲染到 canvas 第0行(终端顶部)。 + // + // 解决方案: + // 1. 在「捕获阶段」(useCapture=true)监听 compositionupdate/ + // compositionstart,调用 stopImmediatePropagation() 阻止 xterm + // 内部监听器收到事件 → 候选字不会被写入 canvas。 + // 2. compositionend 时从 e.data 取出最终文字直接发给服务器, + // 并清空 textarea,防止 xterm 的 onData 再次重复发送。 + // ================================================================ + const setupHelperTextarea = () => { const textarea = terminalRef.value?.querySelector('.xterm-helper-textarea') - if (textarea) { - textarea.style.width = '0' - textarea.style.height = '0' + if (textarea && !textarea._imeFixed) { + textarea._imeFixed = true // 防止重复绑定 + + // 保留 textarea 在终端容器内(确保 IME 候选框能在屏幕上弹出) + // 仅隐藏其视觉显示 textarea.style.opacity = '0' + textarea.style.color = 'transparent' textarea.style.position = 'absolute' - textarea.style.left = '-9999px' - textarea.style.zIndex = '-1' + textarea.style.bottom = '4px' + textarea.style.left = '4px' + textarea.style.width = '1px' + textarea.style.height = '1px' + textarea.style.zIndex = '1' + textarea.style.caretColor = 'transparent' + + // 【核心修复】捕获阶段拦截 compositionstart,阻止 xterm 内部处理 + textarea.addEventListener('compositionstart', (e) => { + isComposing = true + e.stopImmediatePropagation() // 阻止 xterm 内部 compositionHelper 收到此事件 + }, true) + + // 【核心修复】捕获阶段拦截 compositionupdate,阻止 xterm 将候选字写入 canvas + textarea.addEventListener('compositionupdate', (e) => { + e.stopImmediatePropagation() // 阻止 xterm 内部 compositionHelper 渲染候选字 + }, true) + + // compositionend:取最终确认文字发给服务器,清空 textarea + textarea.addEventListener('compositionend', (e) => { + e.stopImmediatePropagation() + isComposing = false + const text = e.data + if (text && terminalStore.isConnected) { + terminalStore.sendInput(text) + } + // 清空 textarea 防止 xterm onData 再次重复发送 + Promise.resolve().then(() => { textarea.value = '' }) + }, true) } } - // 初始隐藏 - hideHelperTextarea() + // 初始设置 + setupHelperTextarea() - // 监听DOM变化,确保新创建的辅助输入框也被隐藏 + // 监听DOM变化,确保 helper textarea 创建后及时处理 const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList') { - hideHelperTextarea() + setupHelperTextarea() } }) }) @@ -311,11 +355,14 @@ const initTerminal = () => { observer.observe(terminalRef.value, { childList: true, subtree: true }) // 处理终端输入 - 直接发送到服务器 + // IME 组合期间(isComposing === true)忽略 onData,最终文字由 compositionend 处理 term.onData((data) => { + if (isComposing) { + return // IME 组合进行中,跳过,避免发送候选字 + } if (terminalStore.isConnected) { terminalStore.sendInput(data) } else { - // 如果未连接,显示提示 term.write('\r\n\x1b[31m未连接到服务器,请先连接\x1b[0m\r\n') } }) @@ -743,11 +790,14 @@ onUnmounted(() => {