From 011ec3d509b0933c2e0962f278e20e9032814055 Mon Sep 17 00:00:00 2001
From: yuanyuanxiang <962914132@qq.com>
Date: Wed, 22 Apr 2026 00:05:38 +0200
Subject: [PATCH] Improve keyboard input user-experience and support Shift
---
server/2015Remote/WebPage.h | 420 +++++++++++++++++++++++++++++++++---
1 file changed, 395 insertions(+), 25 deletions(-)
diff --git a/server/2015Remote/WebPage.h b/server/2015Remote/WebPage.h
index c9d7bc2..28f91a5 100644
--- a/server/2015Remote/WebPage.h
+++ b/server/2015Remote/WebPage.h
@@ -449,6 +449,15 @@ inline std::string GetWebPageHTML() {
height: 100dvh !important;
max-height: none !important;
}
+ /* Portrait fullscreen: align canvas to top */
+ @media (orientation: portrait) {
+ #screen-page:fullscreen .canvas-container,
+ #screen-page:-webkit-full-screen .canvas-container,
+ #screen-page.pseudo-fullscreen .canvas-container {
+ align-items: flex-start !important;
+ padding-top: 56px !important;
+ }
+ }
#screen-page.pseudo-fullscreen #screen-canvas {
max-width: 100vw !important; max-height: 100vh !important;
max-height: 100dvh !important;
@@ -554,6 +563,65 @@ inline std::string GetWebPageHTML() {
transform: translate(-50%, -50%);
z-index: 999;
}
+ /* Utility class for hiding elements */
+ .ui-hidden { display: none !important; }
+ /* Quick control buttons - always visible on touch devices */
+ .quick-controls {
+ position: absolute;
+ display: none;
+ gap: 8px;
+ z-index: 100;
+ padding: 8px;
+ }
+ .quick-controls.visible { display: flex; }
+ .quick-controls .qc-btn {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ border: none;
+ background: rgba(0,0,0,0.6);
+ color: #fff;
+ font-size: 20px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.15s;
+ }
+ .quick-controls .qc-btn:hover { background: rgba(0,0,0,0.8); }
+ .quick-controls .qc-btn:active { transform: scale(0.9); }
+ .quick-controls .qc-btn.active { background: rgba(52,199,89,0.9); }
+ .quick-controls .qc-btn:disabled { opacity: 0.4; }
+ /* Portrait: horizontal at top center */
+ @media (orientation: portrait) {
+ .quick-controls {
+ top: 4px;
+ left: 50%;
+ transform: translateX(-50%);
+ flex-direction: row;
+ padding: 4px;
+ gap: 6px;
+ }
+ .quick-controls .qc-btn {
+ width: 40px;
+ height: 40px;
+ font-size: 18px;
+ }
+ /* Portrait: align canvas to top, leave space for controls */
+ .canvas-container {
+ align-items: flex-start;
+ padding-top: 56px;
+ }
+ }
+ /* Landscape: vertical at right center */
+ @media (orientation: landscape) {
+ .quick-controls {
+ right: 8px;
+ top: 8px;
+ transform: none;
+ flex-direction: column;
+ }
+ }
.cursor-overlay {
position: fixed;
width: 24px;
@@ -567,6 +635,37 @@ inline std::string GetWebPageHTML() {
transform-origin: 0 0;
}
.cursor-overlay.active { display: block; }
+ /* Input shortcut bar - below canvas, portrait mode only */
+ .input-shortcuts {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ display: none;
+ justify-content: center;
+ gap: 4px;
+ padding: 6px 4px;
+ z-index: 101;
+ }
+ .input-shortcuts.visible { display: flex; }
+ .input-shortcuts .shortcut-btn {
+ min-width: 36px;
+ height: 36px;
+ padding: 0 10px;
+ border-radius: 6px;
+ border: 1px solid rgba(128,128,128,0.5);
+ background: rgba(128,128,128,0.4);
+ color: #fff;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.1s;
+ text-shadow: 0 0 2px #000, 0 0 4px #000, 1px 1px 1px #000;
+ }
+ .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); }
/* Mobile responsive */
@media (max-width: 768px) {
.page { padding: 10px; }
@@ -655,7 +754,26 @@ inline std::string GetWebPageHTML() {
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -907,6 +1025,9 @@ inline std::string GetWebPageHTML() {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, width, height);
+ // Update input shortcuts position after canvas resize
+ requestAnimationFrame(updateInputShortcutsPosition);
+
// Set up vertical flip transform once (BMP is bottom-up)
ctx.setTransform(1, 0, 0, -1, 0, height);
if (decoder) { try { decoder.close(); } catch(e) {} }
@@ -1198,16 +1319,17 @@ inline std::string GetWebPageHTML() {
function toggleFullscreen() {
const el = document.getElementById('screen-page');
- const isFullscreen = document.fullscreenElement || document.webkitFullscreenElement;
+ const isFs = document.fullscreenElement || document.webkitFullscreenElement;
const isPseudo = el.classList.contains('pseudo-fullscreen');
// Try native fullscreen first
if (!isPseudo && (el.requestFullscreen || el.webkitRequestFullscreen)) {
- if (!isFullscreen) {
+ if (!isFs) {
(el.requestFullscreen || el.webkitRequestFullscreen).call(el).catch(() => {
// Native fullscreen failed (iOS), use pseudo-fullscreen
el.classList.add('pseudo-fullscreen');
window.scrollTo(0, 1);
+ updateUIForOrientation();
});
} else {
if (document.exitFullscreen) document.exitFullscreen();
@@ -1217,6 +1339,7 @@ inline std::string GetWebPageHTML() {
// Toggle pseudo-fullscreen (iOS fallback)
el.classList.toggle('pseudo-fullscreen');
if (el.classList.contains('pseudo-fullscreen')) window.scrollTo(0, 1);
+ updateUIForOrientation();
}
}
)HTML";
@@ -1235,6 +1358,7 @@ inline std::string GetWebPageHTML() {
const now = Date.now();
if (now - lastTapTime < 300 && e.changedTouches.length === 1) {
el.classList.remove('pseudo-fullscreen');
+ updateUIForOrientation();
e.preventDefault();
}
lastTapTime = now;
@@ -1254,14 +1378,25 @@ inline std::string GetWebPageHTML() {
let toolbarVisible = false;
let toolbarHideTimer = null;
+ function isFullscreen() {
+ return !!(document.fullscreenElement || document.webkitFullscreenElement ||
+ document.getElementById('screen-page')?.classList.contains('pseudo-fullscreen'));
+ }
+
function toggleFloatingToolbar() {
const toolbar = document.getElementById('floating-toolbar');
toolbarVisible = !toolbarVisible;
toolbar.classList.toggle('visible', toolbarVisible);
- // Auto-hide after 4 seconds
- if (toolbarHideTimer) clearTimeout(toolbarHideTimer);
- if (toolbarVisible) {
+ // Clear any existing timer
+ if (toolbarHideTimer) {
+ clearTimeout(toolbarHideTimer);
+ toolbarHideTimer = null;
+ }
+
+ // Only auto-hide when NOT in fullscreen (desktop behavior)
+ // In fullscreen (touch device), user must click again to close
+ if (toolbarVisible && !isFullscreen()) {
toolbarHideTimer = setTimeout(() => {
toolbarVisible = false;
toolbar.classList.remove('visible');
@@ -1269,6 +1404,88 @@ inline std::string GetWebPageHTML() {
}
}
+ function isLandscape() {
+ return window.innerWidth > window.innerHeight;
+ }
+
+ // Cached DOM elements (initialized in window.onload)
+ let uiElements = null;
+
+ function initUIElements() {
+ uiElements = {
+ quickControls: document.getElementById('quick-controls'),
+ toolbarToggle: document.getElementById('toolbar-toggle'),
+ floatingToolbar: document.getElementById('floating-toolbar'),
+ btnRdpResetBar: document.getElementById('btn-rdp-reset-bar'),
+ btnMouseBar: document.getElementById('btn-mouse-bar'),
+ btnKeyboardBar: document.getElementById('btn-keyboard-bar'),
+ inputShortcuts: document.getElementById('input-shortcuts')
+ };
+ }
+
+ function updateUIForOrientation() {
+ if (!isTouchDevice || !uiElements) return;
+
+ // Clear any pending toolbar hide timer when orientation/fullscreen changes
+ if (toolbarHideTimer) {
+ clearTimeout(toolbarHideTimer);
+ toolbarHideTimer = null;
+ }
+
+ const { quickControls, toolbarToggle, floatingToolbar,
+ btnRdpResetBar, btnMouseBar, btnKeyboardBar, inputShortcuts } = uiElements;
+
+ if (isLandscape()) {
+ // Landscape mode
+ quickControls.classList.remove('visible');
+ inputShortcuts.classList.remove('visible');
+
+ if (isFullscreen()) {
+ // Landscape fullscreen: show three-dot menu
+ toolbarToggle.classList.remove('ui-hidden');
+ btnRdpResetBar.classList.add('ui-hidden');
+ btnMouseBar.classList.add('ui-hidden');
+ btnKeyboardBar.classList.add('ui-hidden');
+ } else {
+ // Landscape non-fullscreen: show top toolbar buttons
+ toolbarToggle.classList.add('ui-hidden');
+ floatingToolbar.classList.remove('visible');
+ toolbarVisible = false;
+ btnRdpResetBar.classList.remove('ui-hidden');
+ btnMouseBar.classList.remove('ui-hidden');
+ btnKeyboardBar.classList.remove('ui-hidden');
+ }
+ } else {
+ // Portrait mode: show quick controls
+ quickControls.classList.add('visible');
+ // Input shortcuts only visible when keyboard is open
+ const keyboardOpen = document.activeElement === mobileKeyboard;
+ inputShortcuts.classList.toggle('visible', keyboardOpen);
+ toolbarToggle.classList.add('ui-hidden');
+ floatingToolbar.classList.remove('visible');
+ toolbarVisible = false;
+ btnRdpResetBar.classList.add('ui-hidden');
+ btnMouseBar.classList.add('ui-hidden');
+ btnKeyboardBar.classList.add('ui-hidden');
+ }
+ // Position input shortcuts below canvas
+ updateInputShortcutsPosition();
+ }
+
+ // Position input shortcuts bar below the canvas
+ function updateInputShortcutsPosition() {
+ if (!isTouchDevice || !uiElements || !uiElements.inputShortcuts) return;
+ if (isLandscape()) return; // Only for portrait mode
+
+ const canvas = document.getElementById('screen-canvas');
+ const shortcuts = uiElements.inputShortcuts;
+ if (!canvas || canvas.offsetHeight === 0) return;
+
+ // Position shortcuts 8px below canvas
+ const canvasBottom = canvas.offsetTop + canvas.offsetHeight;
+ shortcuts.style.top = (canvasBottom + 8) + 'px';
+ }
+
function sendRdpReset() {
if (ws && ws.readyState === WebSocket.OPEN && token) {
ws.send(JSON.stringify({ cmd: 'rdp_reset', token }));
@@ -1290,14 +1507,20 @@ inline std::string GetWebPageHTML() {
controlEnabled = !controlEnabled;
// Update floating toolbar buttons
const btnMouse = document.getElementById('btn-mouse');
- const btnKeyboard = document.getElementById('btn-keyboard');
btnMouse.classList.toggle('active', controlEnabled);
- btnKeyboard.disabled = !controlEnabled;
// Update top toolbar buttons (sync state)
const btnMouseBar = document.getElementById('btn-mouse-bar');
- const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
btnMouseBar.classList.toggle('active', controlEnabled);
- btnKeyboardBar.disabled = !controlEnabled;
+ // Update quick controls buttons
+ const qcMouse = document.getElementById('qc-mouse');
+ if (qcMouse) qcMouse.classList.toggle('active', controlEnabled);
+ // Desktop only: keyboard requires mouse control enabled
+ if (!isTouchDevice) {
+ const btnKeyboard = document.getElementById('btn-keyboard');
+ const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
+ if (btnKeyboard) btnKeyboard.disabled = !controlEnabled;
+ if (btnKeyboardBar) btnKeyboardBar.disabled = !controlEnabled;
+ }
// Cursor handling
const canvas = document.getElementById('screen-canvas');
const cursorOverlay = document.getElementById('cursor-overlay');
@@ -1314,15 +1537,14 @@ inline std::string GetWebPageHTML() {
const canvas = document.getElementById('screen-canvas');
const cursorOverlay = document.getElementById('cursor-overlay');
- // Get canvas base rect (without transform, use container)
+ // Get canvas base position (without transform, use container + offset)
+ // offsetLeft/offsetTop are layout positions, unaffected by CSS transform
const container = document.querySelector('.canvas-container');
const containerRect = container.getBoundingClientRect();
-
- // Calculate canvas position within container (centered)
const canvasDisplayWidth = canvas.offsetWidth;
const canvasDisplayHeight = canvas.offsetHeight;
- const canvasLeft = containerRect.left + (containerRect.width - canvasDisplayWidth) / 2;
- const canvasTop = containerRect.top + (containerRect.height - canvasDisplayHeight) / 2;
+ const canvasLeft = containerRect.left + canvas.offsetLeft;
+ const canvasTop = containerRect.top + canvas.offsetTop;
// Convert canvas coords to position on unzoomed canvas
// Map [0, width-1] to [0, 1] for proper edge-to-edge display
@@ -1354,13 +1576,13 @@ inline std::string GetWebPageHTML() {
// Used to recalculate cursor position after zoom/pan
function screenToCanvas(screenX, screenY) {
const canvas = document.getElementById('screen-canvas');
+ // Get canvas base position (without transform, use container + offset)
const container = document.querySelector('.canvas-container');
const containerRect = container.getBoundingClientRect();
-
const canvasDisplayWidth = canvas.offsetWidth;
const canvasDisplayHeight = canvas.offsetHeight;
- const canvasLeft = containerRect.left + (containerRect.width - canvasDisplayWidth) / 2;
- const canvasTop = containerRect.top + (containerRect.height - canvasDisplayHeight) / 2;
+ const canvasLeft = containerRect.left + canvas.offsetLeft;
+ const canvasTop = containerRect.top + canvas.offsetTop;
// Reverse the transform chain
const scaledX = screenX - canvasLeft;
@@ -1937,10 +2159,49 @@ inline std::string GetWebPageHTML() {
touchState.touchCount = e.touches.length;
}, { passive: false });
- function toggleKeyboard() {
- mobileKeyboard.focus();
+ let lastKeyboardBtnTouch = 0; // Timestamp of last keyboard button touch
+ let wasKeyboardFocused = false; // Capture focus state at touchstart (before blur)
+
+ function updateKeyboardButtons(active) {
+ // Update all keyboard buttons across different toolbars
+ const qcKeyboard = document.getElementById('qc-keyboard');
+ const btnKeyboard = document.getElementById('btn-keyboard');
+ const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
+ if (qcKeyboard) qcKeyboard.classList.toggle('active', active);
+ if (btnKeyboard) btnKeyboard.classList.toggle('active', active);
+ if (btnKeyboardBar) btnKeyboardBar.classList.toggle('active', active);
+ // Show/hide input shortcuts based on keyboard state (portrait only)
+ const shortcuts = document.getElementById('input-shortcuts');
+ if (shortcuts && !isLandscape()) {
+ shortcuts.classList.toggle('visible', active);
+ if (active) updateInputShortcutsPosition();
+ }
}
+ function toggleKeyboard() {
+ // Use focus state captured at touchstart (more reliable than button's active class)
+ const isOpen = wasKeyboardFocused;
+ wasKeyboardFocused = false; // Reset
+
+ if (isOpen) {
+ mobileKeyboard.blur();
+ updateKeyboardButtons(false);
+ } else {
+ mobileKeyboard.focus();
+ updateKeyboardButtons(true);
+ }
+ }
+
+ mobileKeyboard.addEventListener('focus', function() {
+ updateKeyboardButtons(true);
+ });
+
+ mobileKeyboard.addEventListener('blur', function() {
+ // Skip if keyboard button was touched within last 300ms
+ if (Date.now() - lastKeyboardBtnTouch < 300) return;
+ updateKeyboardButtons(false);
+ });
+
function sendRightClick() {
// Use cursor position (canvas coordinates), not touch position (screen coordinates)
const x = Math.round(cursorState.x);
@@ -2029,9 +2290,32 @@ inline std::string GetWebPageHTML() {
mobileKeyboard.addEventListener('input', function(e) {
const char = e.data;
if (char) {
- const keyCode = char.toUpperCase().charCodeAt(0);
+ // Check if character needs Shift key
+ const isUpperCase = char >= 'A' && char <= 'Z';
+ const shiftSymbols = '~!@#$%^&*()_+{}|:"<>?';
+ const needsShift = isUpperCase || shiftSymbols.includes(char);
+
+ // Map symbols to their base keys
+ const symbolMap = {
+ '~': 192, '!': 49, '@': 50, '#': 51, '$': 52, '%': 53,
+ '^': 54, '&': 55, '*': 56, '(': 57, ')': 48, '_': 189,
+ '+': 187, '{': 219, '}': 221, '|': 220, ':': 186,
+ '"': 222, '<': 188, '>': 190, '?': 191
+ };
+
+ let keyCode;
+ if (symbolMap[char]) {
+ keyCode = symbolMap[char];
+ } else {
+ keyCode = char.toUpperCase().charCodeAt(0);
+ }
+
+ // Send Shift down if needed
+ if (needsShift) sendKey(16, true); // VK_SHIFT = 16
sendKey(keyCode, true);
sendKey(keyCode, false);
+ // Send Shift up if needed
+ if (needsShift) sendKey(16, false);
}
mobileKeyboard.value = '';
});
@@ -2052,16 +2336,24 @@ inline std::string GetWebPageHTML() {
function disconnect() {
// Reset control mode
controlEnabled = false;
+ // Reset keyboard state (blur event will update button state)
+ mobileKeyboard.blur();
// Reset floating toolbar buttons
const btnMouse = document.getElementById('btn-mouse');
- const btnKeyboard = document.getElementById('btn-keyboard');
if (btnMouse) btnMouse.classList.remove('active');
- if (btnKeyboard) btnKeyboard.disabled = true;
// Reset top toolbar buttons
const btnMouseBar = document.getElementById('btn-mouse-bar');
- const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
if (btnMouseBar) btnMouseBar.classList.remove('active');
- if (btnKeyboardBar) btnKeyboardBar.disabled = true;
+ // Desktop only: disable keyboard button
+ if (!isTouchDevice) {
+ const btnKeyboard = document.getElementById('btn-keyboard');
+ const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
+ if (btnKeyboard) btnKeyboard.disabled = true;
+ if (btnKeyboardBar) btnKeyboardBar.disabled = true;
+ }
+ // Reset quick control buttons
+ const qcMouse = document.getElementById('qc-mouse');
+ if (qcMouse) qcMouse.classList.remove('active');
document.getElementById('screen-canvas').style.cursor = 'default';
document.getElementById('cursor-overlay').classList.remove('active');
@@ -2121,11 +2413,89 @@ inline std::string GetWebPageHTML() {
});
window.onload = function() {
+ // Initialize cached DOM elements
+ initUIElements();
+
const compat = checkWebCodecs();
if (!compat.supported) {
document.body.insertAdjacentHTML('afterbegin',
'
Warning: Your browser may not support H264 decoding.
');
}
+ // On touch devices: setup UI based on orientation and fullscreen state
+ if (isTouchDevice) {
+ // Initial UI setup
+ updateUIForOrientation();
+
+ // Touch devices: keyboard is independent from mouse, enable keyboard buttons
+ const btnKeyboard = document.getElementById('btn-keyboard');
+ const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
+ if (btnKeyboard) btnKeyboard.disabled = false;
+ if (btnKeyboardBar) btnKeyboardBar.disabled = false;
+
+ // Listen for orientation changes (with debounce)
+ let resizeTimer = null;
+ window.addEventListener('resize', function() {
+ if (resizeTimer) clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(updateUIForOrientation, 100);
+ });
+
+ // Listen for fullscreen changes
+ document.addEventListener('fullscreenchange', updateUIForOrientation);
+ document.addEventListener('webkitfullscreenchange', updateUIForOrientation);
+
+ // Input shortcut buttons event handlers
+ // Shortcuts only visible when keyboard is open, so no extra check needed
+ function sendShortcutKey(keyCode, isDown) {
+ if (ws && ws.readyState === WebSocket.OPEN && token) {
+ ws.send(JSON.stringify({ cmd: 'key', token, keyCode, down: isDown, alt: false }));
+ }
+ }
+ const shortcutBtns = document.querySelectorAll('.shortcut-btn');
+ shortcutBtns.forEach(function(btn) {
+ btn.addEventListener('touchstart', function(e) {
+ e.preventDefault();
+ const keyCode = parseInt(btn.dataset.key);
+ const needShift = btn.dataset.shift === '1';
+ if (needShift) sendShortcutKey(16, true); // Shift down
+ sendShortcutKey(keyCode, true);
+ sendShortcutKey(keyCode, false);
+ if (needShift) sendShortcutKey(16, false); // Shift up
+ });
+ btn.addEventListener('click', function(e) {
+ e.preventDefault();
+ // Only handle click for non-touch (mouse)
+ if (!('ontouchstart' in window)) {
+ const keyCode = parseInt(btn.dataset.key);
+ const needShift = btn.dataset.shift === '1';
+ if (needShift) sendShortcutKey(16, true);
+ sendShortcutKey(keyCode, true);
+ sendShortcutKey(keyCode, false);
+ if (needShift) sendShortcutKey(16, false);
+ }
+ });
+ });
+ } else {
+ // Desktop: hide keyboard buttons (physical keyboard available)
+ const btnKeyboard = document.getElementById('btn-keyboard');
+ const btnKeyboardBar = document.getElementById('btn-keyboard-bar');
+ if (btnKeyboard) btnKeyboard.classList.add('ui-hidden');
+ if (btnKeyboardBar) btnKeyboardBar.classList.add('ui-hidden');
+ }
+ // Keyboard buttons: capture focus state and prevent blur from updating button state
+ function bindKeyboardBtnEvents(btn) {
+ if (!btn) return;
+ btn.addEventListener('touchstart', function() {
+ lastKeyboardBtnTouch = Date.now();
+ wasKeyboardFocused = (document.activeElement === mobileKeyboard);
+ }, { passive: true });
+ btn.addEventListener('mousedown', function() {
+ lastKeyboardBtnTouch = Date.now();
+ wasKeyboardFocused = (document.activeElement === mobileKeyboard);
+ });
+ }
+ bindKeyboardBtnEvents(document.getElementById('qc-keyboard'));
+ bindKeyboardBtnEvents(document.getElementById('btn-keyboard'));
+ bindKeyboardBtnEvents(document.getElementById('btn-keyboard-bar'));
// Restore token from sessionStorage
token = sessionStorage.getItem('token');
connectWebSocket();