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 <textarea>, so the desktop key-forwarding handlers now skip TEXTAREA targets as well, which stops an empty-box Backspace from being forwarded twice (once by imeBackspace, once by the document handler). Co-Authored-By: deepseek-v4-pro
This commit is contained in:
1 parent
0c04f7516b
commit
5cea1167be
1 file changed
+242
-8
+242
-8
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<title>__BRAND_NAME__</title>
|
||||
<!-- PWA / iOS Standalone App Support -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
@@ -1078,6 +1078,56 @@
|
||||
}
|
||||
.input-shortcuts .shortcut-btn:hover { background: rgba(128,128,128,0.5); }
|
||||
.input-shortcuts .shortcut-btn:active { transform: scale(0.95); background: rgba(128,128,128,0.6); }
|
||||
.input-shortcuts .shortcut-btn.active {
|
||||
background: rgba(52,199,89,0.85);
|
||||
border-color: rgba(52,199,89,0.9);
|
||||
}
|
||||
/* "I" 手机输入法组合框(微信聊天风格):固定在屏幕底部,键盘弹出时随视觉视口上浮 */
|
||||
.ime-bar {
|
||||
display: none;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(24,24,28,0.97);
|
||||
border-top: 1px solid rgba(128,128,128,0.4);
|
||||
flex-shrink: 0;
|
||||
z-index: 200;
|
||||
}
|
||||
.ime-bar.visible { display: flex; }
|
||||
.ime-bar textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
max-height: 80px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(128,128,128,0.5);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
font-family: inherit;
|
||||
}
|
||||
.ime-bar .ime-send,
|
||||
.ime-bar .ime-collapse {
|
||||
flex: none;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ime-bar .ime-send { background: rgba(52,199,89,0.9); }
|
||||
.ime-bar .ime-collapse { background: rgba(128,128,128,0.5); width: 40px; padding: 0; }
|
||||
|
||||
/* Android virtual navigation bar - overlays bottom of canvas when controlling Android */
|
||||
.android-navbar {
|
||||
@@ -1264,11 +1314,11 @@
|
||||
<button class="shortcut-btn" data-key="50" tabindex="-1">2</button>
|
||||
<button class="shortcut-btn" data-key="51" tabindex="-1">3</button>
|
||||
<button class="shortcut-btn" data-key="52" tabindex="-1">4</button>
|
||||
<button class="shortcut-btn" data-key="53" tabindex="-1">5</button>
|
||||
<button class="shortcut-btn" data-key="190" tabindex="-1">.</button>
|
||||
<button class="shortcut-btn" data-key="188" tabindex="-1">,</button>
|
||||
<button class="shortcut-btn" data-key="191" data-shift="1" tabindex="-1">?</button>
|
||||
<button class="shortcut-btn" data-key="32" data-ctrl="1" tabindex="-1" title="Ctrl+Space">中</button>
|
||||
<button class="shortcut-btn" id="btn-ime" tabindex="-1" title="Phone IME input">I</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="touch-indicator" id="touch-indicator"></div>
|
||||
@@ -1283,6 +1333,11 @@
|
||||
</div>
|
||||
<div class="zoom-indicator" id="zoom-indicator">100%</div>
|
||||
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
|
||||
<div class="ime-bar" id="ime-bar">
|
||||
<button type="button" class="ime-collapse" id="ime-collapse" title="Collapse" tabindex="-1" onmousedown="event.preventDefault()">▼</button>
|
||||
<textarea id="ime-input" rows="1" enterkeyhint="send" autocomplete="off" autocorrect="off" autocapitalize="off" placeholder="Type to send"></textarea>
|
||||
<button type="button" class="ime-send" id="ime-send" tabindex="-1" onmousedown="event.preventDefault()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Page (xterm.js) -->
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user