From 5cea1167bea24f8b01f33f752b0fb4584108eac3 Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Sat, 17 Oct 2026 22:06:46 +0200 Subject: [PATCH] Feature: Web remote desktop phone IME input box ("I") Add an "I" button to the mobile shortcut bar that opens a chat-style input box pinned above the virtual keyboard. Text is delivered to the remote through the existing clipboard bridge (clipboard_set + synthetic Ctrl+V); an empty box turns Enter/Backspace into remote Enter/Backspace so the phone can submit and edit text in the focused window. The box soft-wraps and auto-grows up to four lines. The box is a + + @@ -1366,6 +1421,7 @@ let ws = null, token = null, decoder = null, devices = [], currentDevice = null; let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null; let skipNextCtrlVKeyup = false; // 剪贴板桥:抑制 pasteToRemote 合成的 Ctrl+V 物理 keyup + let imeMode = false; // "I" 手机输入法组合框是否展开(隐藏时不丢内容) // FPS 计数:原始风格——decoder.onOutput 里 ++,每经过 1 秒采样一次。 // 简单直接,与本次会话改动前一致。 // 网络流量统计:handleBinaryFrame 累加,每 1 秒钟 renderStats 读出 @@ -3228,7 +3284,7 @@ document.getElementById('screen-page').classList.remove('android-portrait'); quickControls.classList.add('visible'); // Input shortcuts only visible when keyboard is open - const keyboardOpen = document.activeElement === mobileKeyboard; + const keyboardOpen = document.activeElement === mobileKeyboard || imeMode; inputShortcuts.classList.toggle('visible', keyboardOpen); toolbarToggle.classList.add('ui-hidden'); floatingToolbar.classList.remove('visible'); @@ -3250,9 +3306,9 @@ const shortcuts = uiElements.inputShortcuts; if (!canvas || canvas.offsetHeight === 0) return; - // Position shortcuts 8px below canvas + // Position shortcuts 2px below canvas const canvasBottom = canvas.offsetTop + canvas.offsetHeight; - shortcuts.style.top = (canvasBottom + 8) + 'px'; + shortcuts.style.top = (canvasBottom + 2) + 'px'; } function sendRdpReset() { @@ -3743,6 +3799,119 @@ }, 250); } + // 直接注入单个按键到远程(不经过 sendKey 的 controlEnabled 门槛)。 + // "I" 组合框和 1/2/3…/中 同属输入辅助栏,不应要求先开鼠标控制。 + function sendKeyRaw(keyCode, isDown) { + if (ws && ws.readyState === WebSocket.OPEN && token) { + ws.send(JSON.stringify({ cmd: 'key', token, keyCode, down: isDown, alt: false })); + } + } + + // "I" 手机输入法组合框:一次性把文本经剪贴板桥发到远程光标处。 + // 与 pasteToRemote 同款:先 clipboard_set(服务端转 GBK),再合成完整 Ctrl+V。 + function sendTextToRemote(text) { + if (!text) return; + if (ws && ws.readyState === WebSocket.OPEN && token) { + ws.send(JSON.stringify({ cmd: 'clipboard_set', token, text })); + } + sendKeyRaw(17, true); // Ctrl down + sendKeyRaw(86, true); // V down + sendKeyRaw(86, false); // V up + sendKeyRaw(17, false); // Ctrl up + } + + // 空输入时向远程发送 Backspace(删除远程窗口内容) + function imeBackspace() { + sendKeyRaw(8, true); // VK_BACK + sendKeyRaw(8, false); + } + + // 输入框随内容自动长高:文字换行时像微信一样扩展为多行(超过 max-height 后滚动) + function autoGrowImeInput() { + const el = document.getElementById('ime-input'); + if (!el) return; + el.style.height = 'auto'; + // box-sizing:border-box 下 scrollHeight 不含上下 border(各 1px),补回 2px 避免末行出现滚动条 + el.style.height = (el.scrollHeight + 2) + 'px'; + } + + function toggleIme() { + imeMode = !imeMode; + const imeBar = document.getElementById('ime-bar'); + const imeInput = document.getElementById('ime-input'); + const btnIme = document.getElementById('btn-ime'); + const page = document.getElementById('screen-page'); + if (imeMode) { + if (btnIme) btnIme.classList.add('active'); + if (imeBar) { imeBar.classList.add('visible'); adjustImeBar(); } + if (imeInput) { + imeInput.focus(); + adjustImeBar(); + startImeWatcher(); + // iOS 上软键盘弹出后 visualViewport 常要一次滚动才归位,强制滚回顶部触发重排 + setTimeout(function() { window.scrollTo(0, 0); adjustImeBar(); }, 60); + } + } else { + stopImeWatcher(); + if (btnIme) btnIme.classList.remove('active'); + if (imeBar) imeBar.classList.remove('visible'); + if (imeInput) imeInput.blur(); + if (page) page.style.paddingBottom = ''; + updateKeyboardButtons(false); // 收起键盘,便于移动远程光标 + } + updateInputShortcutsPosition(); + } + + function imeSend() { + const input = document.getElementById('ime-input'); + if (!input) return; + const text = input.value; + if (text) { + sendTextToRemote(text); + input.value = ''; // 清空输入框,保持键盘弹出 + autoGrowImeInput(); // 复位为单行高度 + input.focus(); + } else { + // 空输入框:向远程发送回车(提交远程已编辑好的消息) + sendKeyRaw(13, true); // VK_RETURN + sendKeyRaw(13, false); + } + } + + // 键盘弹出时给 screen-page 加 padding-bottom = 键盘高度,把底部输入条顶到键盘上方。 + // 与 adjustTermViewport 同款做法:screen-page 是 flex 列,ime-bar 是最后一个 flex 子项。 + function adjustImeBar() { + if (!imeMode) return; + const page = document.getElementById('screen-page'); + if (!page) return; + let inset = 0; + if (window.visualViewport) { + inset = Math.max(0, Math.round( + window.innerHeight - window.visualViewport.height - (window.visualViewport.offsetTop || 0) + )); + } + const next = inset + 'px'; + if (page.style.paddingBottom !== next) { + page.style.paddingBottom = next; + updateInputShortcutsPosition(); + } + } + + // 键盘弹出是异步动画,iOS 上 visualViewport 甚至要一次滚动才更新,用 rAF 循环 + // 在 imeMode 期间持续修正,避免"必须手动滚动网页才归位"的问题。 + let imeRafId = null; + function imeWatcher() { + if (!imeMode) { imeRafId = null; return; } + adjustImeBar(); + imeRafId = requestAnimationFrame(imeWatcher); + } + function startImeWatcher() { + if (imeRafId == null) imeRafId = requestAnimationFrame(imeWatcher); + } + function stopImeWatcher() { + if (imeRafId != null) { cancelAnimationFrame(imeRafId); imeRafId = null; } + } + function showTouchIndicator(x, y) { touchIndicator.style.left = x + 'px'; touchIndicator.style.top = y + 'px'; @@ -4131,10 +4300,12 @@ function toggleKeyboard() { // Use focus state captured at touchstart (more reliable than button's active class) - const isOpen = wasKeyboardFocused; + // 若 "I" 输入框开着,键盘按钮此时也视为"开启"——取消时一并收起输入框、清掉 "I" 状态。 + const isOpen = wasKeyboardFocused || imeMode; wasKeyboardFocused = false; // Reset if (isOpen) { + if (imeMode) toggleIme(); // 关闭 "I" 输入框(内部会 updateKeyboardButtons(false)) mobileKeyboard.blur(); updateKeyboardButtons(false); } else { @@ -4148,6 +4319,8 @@ }); mobileKeyboard.addEventListener('blur', function() { + // "I" 组合框聚焦会让 mobile-keyboard 失焦,此时保持快捷键栏可见 + if (imeMode) return; // Skip if keyboard button was touched within last 300ms if (Date.now() - lastKeyboardBtnTouch < 300) return; updateKeyboardButtons(false); @@ -4222,7 +4395,7 @@ // Desktop keyboard handling document.addEventListener('keydown', function(e) { if (!document.getElementById('screen-page').classList.contains('active')) return; - if (e.target.tagName === 'INPUT') return; + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; // F11 belongs to the local fullscreen toggle UNLESS we're actively // controlling the remote — see the F11 handler above. Skip here // to let that one handle it. @@ -4251,7 +4424,7 @@ document.addEventListener('keyup', function(e) { if (!document.getElementById('screen-page').classList.contains('active')) return; - if (e.target.tagName === 'INPUT') return; + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key === 'F11' && !controlEnabled) return; // 由 pasteToRemote 合成的完整 Ctrl+V 按下:物理 keyup 不再转发,避免 down/up 乱序 if (e.keyCode === 86 && skipNextCtrlVKeyup) { @@ -4320,6 +4493,15 @@ controlEnabled = false; // Reset keyboard state (blur event will update button state) mobileKeyboard.blur(); + // Reset "I" 手机输入法组合框 + imeMode = false; + stopImeWatcher(); + const imeBar = document.getElementById('ime-bar'); + if (imeBar) imeBar.classList.remove('visible'); + const imePage = document.getElementById('screen-page'); + if (imePage) imePage.style.paddingBottom = ''; + const btnIme = document.getElementById('btn-ime'); + if (btnIme) btnIme.classList.remove('active'); // Reset floating toolbar buttons const btnMouse = document.getElementById('btn-mouse'); if (btnMouse) btnMouse.classList.remove('active'); @@ -4502,6 +4684,8 @@ } const shortcutBtns = document.querySelectorAll('.shortcut-btn'); shortcutBtns.forEach(function(btn) { + // "I" 按钮不是按键注入,交由 toggleIme 处理 + if (btn.id === 'btn-ime') return; btn.addEventListener('touchstart', function(e) { e.preventDefault(); const keyCode = parseInt(btn.dataset.key); @@ -4530,6 +4714,56 @@ } }); }); + + // "I" 手机输入法组合框:触摸记时、开关、回车/按钮发送、键盘上浮 + const btnIme = document.getElementById('btn-ime'); + if (btnIme) { + btnIme.addEventListener('touchstart', function() { + lastKeyboardBtnTouch = Date.now(); + }, { passive: true }); + btnIme.addEventListener('mousedown', function() { + lastKeyboardBtnTouch = Date.now(); + }); + btnIme.addEventListener('click', function(e) { + e.preventDefault(); + toggleIme(); + }); + } + const imeInput = document.getElementById('ime-input'); + if (imeInput) { + imeInput.addEventListener('keydown', function(e) { + if (e.isComposing) return; // 输入法确认候选时一律不转发 + if (e.key === 'Enter') { + e.preventDefault(); + imeSend(); + } else if (e.key === 'Backspace' && !imeInput.value) { + // 空输入框:删除键改为向远程发送 Backspace + e.preventDefault(); + imeBackspace(); + } + }); + imeInput.addEventListener('input', autoGrowImeInput); + } + const imeSendBtn = document.getElementById('ime-send'); + if (imeSendBtn) { + imeSendBtn.addEventListener('click', function(e) { + e.preventDefault(); + imeSend(); + }); + } + const imeCollapse = document.getElementById('ime-collapse'); + if (imeCollapse) { + imeCollapse.addEventListener('click', function(e) { + e.preventDefault(); + toggleIme(); + }); + } + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', adjustImeBar); + window.visualViewport.addEventListener('scroll', adjustImeBar); + } + // iOS / 部分浏览器键盘弹出时触发的是 window resize,作为上浮兜底 + window.addEventListener('resize', adjustImeBar); } else { // Desktop: hide keyboard buttons (physical keyboard available) const btnKeyboard = document.getElementById('btn-keyboard');