lazyBoy/apps/web/vnc.html

514 lines
24 KiB
HTML
Raw Permalink Normal View History

2026-09-03 12:46:14 +00:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
2026-09-06 14:09:17 +00:00
<meta name="viewport" content="width=device-width, initial-scale=1" />
2026-09-03 12:46:14 +00:00
<title>LazyBoy desktop</title>
<style>
html, body, #screen {
margin: 0;
height: 100%;
width: 100%;
background: #0f172a;
overflow: hidden;
}
2026-09-07 09:05:14 +00:00
#status { flex:none; padding:6px 10px; color:#f8fafc; font:12px system-ui; }
#status[hidden] { display:none; }
2026-09-06 14:09:17 +00:00
body { display:flex; flex-direction:column; }
#screen { flex:1; min-height:0; height:auto; }
2026-09-03 12:46:14 +00:00
#screen canvas { cursor: default; }
2026-09-07 09:05:14 +00:00
#trackpad { height:clamp(64px,18vh,150px); margin-top:6px; border:1px solid #64748b;
border-radius:10px; background:#111b2e; touch-action:none; user-select:none;
display:grid; place-items:center; color:#94a3b8; }
#trackpad[hidden] { display:none; }
#mobile-controls .shortcuts { margin-top:5px; }
#mobile-controls .shortcuts[hidden] { display:none; }
@media(orientation:landscape) {
body.touch-ui {
display:grid;
grid-template:"status status" auto "screen controls" 1fr / minmax(0,1fr) 210px;
}
body.touch-ui #status { grid-area:status; }
body.touch-ui #screen { grid-area:screen; width:auto; height:auto; min-width:0; }
body.touch-ui #mobile-controls { grid-area:controls; width:auto; box-sizing:border-box; overflow:auto; }
body.touch-ui #mobile-controls .buttons { flex-wrap:wrap; }
body.touch-ui #trackpad { height:80px; }
}
2026-09-06 14:09:17 +00:00
#mobile-controls { display:none; flex:none; padding:6px 8px max(6px,env(safe-area-inset-bottom));
background:#172033; color:#f8fafc; font:12px system-ui; border-top:1px solid #334155; }
#mobile-controls .buttons { display:flex; gap:5px; overflow-x:auto; }
#mobile-controls button { flex:1 0 auto; min-height:44px; padding:8px 10px;
border:1px solid #64748b; border-radius:8px; background:#25334a; color:inherit; font:inherit; touch-action:manipulation; }
#mobile-controls button[aria-pressed="true"] { background:#155e75; border-color:#67e8f9; }
#mobile-controls button:disabled { opacity:.4; }
#pointer-help { margin:5px 0 0; line-height:1.4; }
#mobile-cursor { display:none; position:fixed; width:18px; height:18px; border:2px solid white;
border-radius:50%; box-shadow:0 0 0 1px #000,0 1px 5px #000; transform:translate(-50%,-50%);
pointer-events:none; z-index:5; }
#mobile-cursor::after { content:""; position:absolute; width:4px; height:4px; background:#fff;
border-radius:50%; left:7px; top:7px; }
@media(any-pointer:coarse) { #mobile-controls { display:block; } }
2026-09-07 09:05:14 +00:00
body.touch-ui #mobile-controls { display:block; }
2026-09-06 14:09:17 +00:00
/* Keep a real editable element on screen for iOS/Android keyboards. */
#mobile-keyboard { position:fixed; bottom:0; left:0; width:1px; height:1px;
padding:0; border:0; opacity:.01; font-size:16px; pointer-events:none; }
2026-09-03 12:46:14 +00:00
</style>
<script type="module">
import RFB from "./core/rfb.js";
function query(name) {
const match = `${document.location.href}${window.location.hash}`.match(
new RegExp(`[?&#]${name}=([^&#]*)`)
);
return match ? decodeURIComponent(match[1]) : null;
}
function flag(name, fallback = false) {
const raw = query(name);
if (raw == null) return fallback;
const value = String(raw).toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
const statusEl = document.getElementById("status");
const setStatus = (text) => {
statusEl.textContent = text;
2026-09-07 09:05:14 +00:00
statusEl.hidden = !text;
2026-09-03 12:46:14 +00:00
};
const prefix = window.location.pathname.replace(/[^/]+$/, "");
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const path = (query("path") || "websockify").replace(/^\//, "");
const url = path.includes("/")
? `${protocol}://${window.location.host}/${path}`
: `${protocol}://${window.location.host}${prefix}${path}`;
let rfb = null;
let reconnectTimer = null;
2026-09-08 01:14:53 +00:00
// A desktop that has never answered on this page is still coming up, and
// x11vnc opens its port a heartbeat after the last attempt failed. Holding
// every retry at the calm cadence leaves up to half a second of veil on top
// of a desktop that is already there, so the first window of attempts comes
// back eager. Once a session has been seen the calm cadence returns: a
// genuinely dropped connection should not be hammered.
const CALM_RETRY_MS = 1500;
const EAGER_RETRY_MS = 350;
const EAGER_ATTEMPTS = 40;
let everConnected = false;
let eagerAttempts = 0;
const retryDelay = () => (!everConnected && eagerAttempts < EAGER_ATTEMPTS)
? (eagerAttempts += 1, EAGER_RETRY_MS)
: CALM_RETRY_MS;
2026-09-03 23:43:37 +00:00
const parentOrigin = window.location.origin;
2026-09-06 14:09:17 +00:00
const keyboard = document.getElementById("mobile-keyboard");
const sentinel = "\u200b";
let composing = false;
let touchStart = null;
const controls = document.getElementById("mobile-controls");
const cursor = document.getElementById("mobile-cursor");
const modeButton = document.getElementById("pointer-mode");
const dragButton = document.getElementById("pointer-drag");
const pointerHelp = document.getElementById("pointer-help");
2026-09-07 09:05:14 +00:00
const touchUi = window.matchMedia?.("(any-pointer:coarse)");
document.body?.classList?.toggle("touch-ui", Boolean(touchUi?.matches));
touchUi?.addEventListener?.("change", event => document.body?.classList?.toggle("touch-ui", event.matches));
let trackpad = Boolean(touchUi?.matches);
const pad = document.getElementById("trackpad");
const shortcuts = document.getElementById("keyboard-shortcuts");
let modifier = null;
2026-09-06 14:09:17 +00:00
let dragging = false;
let gesture = null;
let pointer = { x: .5, y: .5 };
function pointerGeometry() {
const canvas = document.querySelector("#screen canvas");
const rect = canvas?.getBoundingClientRect();
return rect && rect.width > 0 && rect.height > 0 ? rect : null;
}
function paintPointer() {
const rect = pointerGeometry();
if (!cursor?.style) return;
cursor.style.display = trackpad && rect && rfb && !rfb.viewOnly ? "block" : "none";
if (rect) {
cursor.style.left = `${rect.left + pointer.x * rect.width}px`;
cursor.style.top = `${rect.top + pointer.y * rect.height}px`;
}
}
// Pointer adapter for the pinned noVNC 1.7.0. These methods take scaled
// canvas coordinates and preserve noVNC's throttling/protocol handling.
function sendPointer(mask = null) {
const rect = pointerGeometry();
if (!rect || !rfb || rfb.viewOnly) return;
const x = Math.min(rect.width - 1, Math.max(0, pointer.x * rect.width));
const y = Math.min(rect.height - 1, Math.max(0, pointer.y * rect.height));
if (mask === null) rfb._handleMouseMove(x, y);
else rfb._handleMouseButton(x, y, mask);
paintPointer();
}
function releaseDrag() {
if (dragging) sendPointer(0);
dragging = false;
dragButton?.setAttribute?.("aria-pressed", "false");
}
function pointerClick(mask) {
releaseDrag();
sendPointer(mask);
sendPointer(0);
}
function scrollPointer(deltaX, deltaY) {
const rect = pointerGeometry();
if (!rect || !rfb || rfb.viewOnly) return;
rfb._handleWheel({ clientX:rect.left + pointer.x * rect.width,
clientY:rect.top + pointer.y * rect.height, deltaX, deltaY, deltaMode:0,
buttons:0, preventDefault(){}, stopPropagation(){} });
}
function updatePointerControls() {
2026-09-07 09:05:14 +00:00
if (pad) pad.hidden = !trackpad;
2026-09-06 14:09:17 +00:00
modeButton?.setAttribute?.("aria-pressed", String(trackpad));
if (modeButton) modeButton.textContent = trackpad ? "觸控板" : "直接點選";
if (pointerHelp) pointerHelp.textContent = trackpad
? "滑動移游標・輕點左鍵・雙指捲動/輕點右鍵・拖曳按完再按一次放開"
2026-09-07 09:05:14 +00:00
: "點畫面定位・按「鍵盤」輸入・精準操作可切換觸控板";
2026-09-06 14:09:17 +00:00
for (const button of controls?.querySelectorAll?.("button[data-action]") || []) {
button.disabled = !rfb || rfb.viewOnly;
}
paintPointer();
}
function showKeyboard() {
if (!rfb || rfb.viewOnly) return;
rfb.focusOnClick = false;
2026-09-07 09:05:14 +00:00
if (shortcuts) shortcuts.hidden = false;
2026-09-06 14:09:17 +00:00
keyboard.focus({ preventScroll: true });
keyboard.setSelectionRange(keyboard.value.length, keyboard.value.length);
}
window.addEventListener("click", event => {
const button = event.target.closest?.("#mobile-controls button");
if (!button) return;
const action = button.dataset.action;
if (action === "mode") {
releaseDrag(); gesture = null; touchStart = null;
trackpad = !trackpad; keyboard.blur(); updatePointerControls(); return;
}
if (!rfb || rfb.viewOnly) return;
if (action === "keyboard") { releaseDrag(); showKeyboard(); return; }
2026-09-07 09:05:14 +00:00
if (action === "hide-keyboard") { keyboard.blur(); if (shortcuts) shortcuts.hidden = true; return; }
if (action === "modifier") {
modifier = modifier === button.dataset.key ? null : button.dataset.key;
for (const item of controls.querySelectorAll('[data-action="modifier"]')) item.setAttribute("aria-pressed", String(item.dataset.key === modifier));
return;
}
if (action === "key") { mobileKey(button.dataset.key); return; }
2026-09-06 14:09:17 +00:00
if (action === "left") pointerClick(1);
if (action === "right") pointerClick(4);
if (action === "up") { releaseDrag(); scrollPointer(0, -100); }
if (action === "down") { releaseDrag(); scrollPointer(0, 100); }
if (action === "drag") {
if (!trackpad) { trackpad = true; keyboard.blur(); }
dragging = !dragging; sendPointer(dragging ? 1 : 0);
dragButton.setAttribute("aria-pressed", String(dragging));
updatePointerControls();
}
});
function stopTouch(event) { event.preventDefault(); event.stopImmediatePropagation(); }
function touchCenter(touches) {
const points = Array.from(touches);
return { x:points.reduce((sum,p)=>sum+p.clientX,0)/points.length,
y:points.reduce((sum,p)=>sum+p.clientY,0)/points.length };
}
function startPointerTouch(event) {
2026-09-07 09:05:14 +00:00
if (!trackpad || !event.target.closest?.("#trackpad")) return false;
2026-09-06 14:09:17 +00:00
stopTouch(event);
if (!rfb || rfb.viewOnly) {
window.parent.postMessage({ type:"lazyboy-request-control" }, parentOrigin);
return true;
}
const center = touchCenter(event.touches);
if (event.touches.length > 1) releaseDrag();
gesture = { last:center, start:center, fingers:event.touches.length,
moved:false, time:Date.now() };
return true;
}
function movePointerTouch(event) {
if (!trackpad || !gesture) return false;
stopTouch(event);
if (!rfb || rfb.viewOnly) { gesture = null; return true; }
const center = touchCenter(event.touches);
if (event.touches.length !== gesture.fingers) {
gesture.last = center; gesture.moved = true; return true;
}
const dx = center.x - gesture.last.x, dy = center.y - gesture.last.y;
gesture.last = center;
if (Math.hypot(center.x - gesture.start.x, center.y - gesture.start.y) > 5) gesture.moved = true;
if (gesture.fingers === 2) { scrollPointer(-dx, -dy); return true; }
const rect = pointerGeometry();
if (rect && gesture.fingers === 1 && gesture.moved) {
pointer.x = Math.max(0, Math.min(1, pointer.x + dx / rect.width));
pointer.y = Math.max(0, Math.min(1, pointer.y + dy / rect.height));
sendPointer();
}
return true;
}
function endPointerTouch(event) {
if (!trackpad || !gesture) return false;
stopTouch(event);
if (event.touches.length) return true;
if (!gesture.moved && Date.now() - gesture.time < 450 && !dragging) {
if (gesture.fingers === 1) pointerClick(1);
if (gesture.fingers === 2) pointerClick(4);
}
gesture = null;
return true;
}
window.addEventListener("blur", () => { gesture = null; releaseDrag(); });
window.addEventListener("pagehide", () => { gesture = null; releaseDrag(); });
function resetKeyboard() {
keyboard.value = sentinel;
composing = false;
}
function mobileKey(key) {
if (!rfb || rfb.viewOnly) return;
2026-09-07 09:05:14 +00:00
window.parent.postMessage({ type: "lazyboy-mobile-key", key: modifier ? `${modifier}+${key}` : key }, parentOrigin);
modifier = null;
for (const item of controls?.querySelectorAll?.('[data-action="modifier"]') || []) item.setAttribute("aria-pressed", "false");
2026-09-06 14:09:17 +00:00
}
function commitKeyboard() {
if (composing) return;
const value = keyboard.value;
if (!rfb || rfb.viewOnly) { resetKeyboard(); return; }
if (!value) mobileKey("BackSpace");
else {
const text = value.startsWith(sentinel) ? value.slice(1) : value;
if (text) pasteIntoDesktop(text);
}
resetKeyboard();
}
resetKeyboard();
window.addEventListener("compositionstart", event => {
if (event.target === keyboard) composing = true;
});
window.addEventListener("compositionend", event => {
if (event.target !== keyboard) return;
composing = false;
commitKeyboard();
});
window.addEventListener("input", event => {
if (event.target === keyboard && !event.isComposing) commitKeyboard();
});
window.addEventListener("beforeinput", event => {
if (event.target !== keyboard || composing || event.isComposing) return;
if (event.inputType === "insertLineBreak" || event.inputType === "insertParagraph") {
event.preventDefault(); mobileKey("Return"); resetKeyboard();
}
});
// Focus synchronously in the user's tap, before iOS loses activation.
// VNC pixels cannot reveal whether the remote target is a text field.
2026-09-07 09:05:14 +00:00
function ignoreScreenTouch(event) {
if (!trackpad || !event.target.closest?.("#screen")) return false;
event.preventDefault();
event.stopImmediatePropagation();
touchStart = null;
return true;
}
2026-09-06 14:09:17 +00:00
window.addEventListener("touchstart", event => {
if (event.target === keyboard) return;
if (startPointerTouch(event)) { touchStart = null; return; }
2026-09-07 09:05:14 +00:00
if (ignoreScreenTouch(event)) return;
2026-09-06 14:09:17 +00:00
const point = event.touches[0];
const rect = pointerGeometry();
if (point && rect && event.target.closest?.("#screen")) {
pointer.x = Math.max(0, Math.min(1, (point.clientX - rect.left) / rect.width));
pointer.y = Math.max(0, Math.min(1, (point.clientY - rect.top) / rect.height));
}
touchStart = event.touches.length === 1 && event.target.closest?.("#screen")
? { x: point.clientX, y: point.clientY } : null;
if (rfb) rfb.focusOnClick = false;
}, { passive: false, capture: true });
window.addEventListener("touchmove", event => {
if (movePointerTouch(event)) return;
2026-09-07 09:05:14 +00:00
if (ignoreScreenTouch(event)) return;
2026-09-06 14:09:17 +00:00
const point = event.touches[0];
if (touchStart && (!point || event.touches.length !== 1 ||
Math.hypot(point.clientX - touchStart.x, point.clientY - touchStart.y) > 10)) touchStart = null;
}, { passive: false, capture: true });
window.addEventListener("touchcancel", () => { touchStart = null; gesture = null; releaseDrag(); }, true);
window.addEventListener("touchend", event => {
if (endPointerTouch(event)) return;
2026-09-07 09:05:14 +00:00
if (ignoreScreenTouch(event)) return;
2026-09-06 14:09:17 +00:00
const tapped = touchStart && event.touches.length === 0;
touchStart = null;
if (!tapped || !rfb || rfb.viewOnly) return;
2026-09-07 09:05:14 +00:00
// Direct taps position the pointer; the keyboard opens only on request.
2026-09-06 14:09:17 +00:00
}, { passive: false, capture: true });
2026-09-03 12:46:14 +00:00
2026-09-03 16:16:34 +00:00
function pasteIntoDesktop(text) {
if (!rfb || rfb.viewOnly || !text) return;
window.parent.postMessage({type:"lazyboy-paste-text", text}, parentOrigin);
2026-09-03 16:16:34 +00:00
}
2026-09-03 12:46:14 +00:00
function pinTaskbar() {
// noVNC centers the scaled canvas (margin:auto), leaving a dead
// strip under the panel. Stick the canvas to the bottom.
const inner = document.querySelector("#screen > div");
const canvas = document.querySelector("#screen canvas");
if (inner) {
inner.style.alignItems = "flex-end";
inner.style.justifyContent = "center";
}
if (canvas) {
canvas.style.margin = "0 auto";
}
}
2026-09-08 01:14:53 +00:00
// The host flips this gate whenever control changes hands, including
// while the frame is still dialling or between retries. Keep the last
// thing it asked for and hand it to every new RFB, so a handoff never
// depends on which side of a reconnect the message happened to land.
let wantedViewOnly = null;
function applyViewOnly(value) {
2026-09-08 01:14:53 +00:00
wantedViewOnly = Boolean(value);
if (!rfb) return;
2026-09-08 01:14:53 +00:00
if (wantedViewOnly) { releaseDrag(); gesture = null; modifier = null; if (shortcuts) shortcuts.hidden = true; }
rfb.viewOnly = wantedViewOnly;
2026-09-06 14:09:17 +00:00
updatePointerControls();
if (rfb.viewOnly) { keyboard.blur(); resetKeyboard(); }
2026-09-08 01:14:53 +00:00
// Taking over has to be usable, not just painted: focus so the very
// first keystroke after the veil lands on the desktop.
else { try { rfb.focus(); } catch (_) {} }
}
2026-09-03 12:46:14 +00:00
function connect() {
setStatus("Connecting to desktop…");
window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
2026-09-03 12:46:14 +00:00
rfb = new RFB(document.getElementById("screen"), url);
2026-09-08 09:14:15 +00:00
rfb.viewOnly = wantedViewOnly ?? flag("view_only", true);
2026-09-03 12:46:14 +00:00
rfb.scaleViewport = true;
2026-09-07 09:05:14 +00:00
rfb.qualityLevel = 6;
rfb.compressionLevel = 2;
2026-09-03 12:46:14 +00:00
rfb.clipViewport = false;
rfb.background = "#0f172a";
pinTaskbar();
2026-09-06 14:09:17 +00:00
updatePointerControls();
2026-09-03 12:46:14 +00:00
rfb.addEventListener("connect", () => {
2026-09-08 01:14:53 +00:00
everConnected = true;
eagerAttempts = 0;
2026-09-07 09:05:14 +00:00
setStatus("");
2026-09-03 12:46:14 +00:00
pinTaskbar();
2026-09-06 14:09:17 +00:00
updatePointerControls();
2026-09-03 12:46:14 +00:00
try { rfb.focus(); } catch (_) {}
2026-09-08 01:14:53 +00:00
if (wantedViewOnly !== null) applyViewOnly(wantedViewOnly);
window.parent.postMessage({ type: "lazyboy-desktop-ready" }, parentOrigin);
2026-09-03 12:46:14 +00:00
});
rfb.addEventListener("disconnect", (event) => {
2026-09-06 14:09:17 +00:00
releaseDrag(); gesture = null;
2026-09-08 09:14:15 +00:00
// Disable the disconnected instance without overwriting host intent.
rfb.viewOnly = true;
modifier = null;
if (shortcuts) shortcuts.hidden = true;
updatePointerControls();
2026-09-06 14:09:17 +00:00
keyboard.blur(); resetKeyboard();
window.parent.postMessage({ type: "lazyboy-desktop-lost" }, parentOrigin);
2026-09-03 12:46:14 +00:00
const clean = event && event.detail && event.detail.clean;
setStatus(clean ? "Disconnected — retrying" : "Desktop connection lost — retrying");
if (reconnectTimer) clearTimeout(reconnectTimer);
2026-09-08 01:14:53 +00:00
reconnectTimer = setTimeout(connect, retryDelay());
2026-09-03 12:46:14 +00:00
});
2026-09-03 16:16:34 +00:00
rfb.addEventListener("clipboard", (event) => {
const text = event && event.detail ? event.detail.text : "";
if (typeof text === "string") {
2026-09-03 23:43:37 +00:00
window.parent.postMessage({ type: "lazyboy-desktop-clipboard", text }, parentOrigin);
2026-09-03 16:16:34 +00:00
}
});
2026-09-03 12:46:14 +00:00
}
// Capture before noVNC forwards the shortcut; otherwise stale remote
// clipboard contents can be pasted once before the local text arrives.
window.addEventListener("keydown", async (event) => {
2026-09-06 14:09:17 +00:00
if (event.target === keyboard) {
if (event.isComposing || composing || event.keyCode === 229) return;
const keys = { Enter: "Return", Backspace: "BackSpace", Tab: "Tab", Escape: "Escape",
ArrowLeft: "Left", ArrowRight: "Right", ArrowUp: "Up", ArrowDown: "Down" };
if (keys[event.key]) {
event.preventDefault(); event.stopImmediatePropagation();
mobileKey(keys[event.key]); resetKeyboard();
}
return;
}
if (rfb && !rfb.viewOnly && event.metaKey && event.code === "KeyC") {
event.preventDefault(); event.stopImmediatePropagation();
window.parent.postMessage({type:"lazyboy-copy-request"},parentOrigin); return;
}
if (!rfb || rfb.viewOnly || !(event.ctrlKey || event.metaKey) || event.altKey || event.code !== "KeyV") return;
event.preventDefault();
event.stopImmediatePropagation();
const target = rfb;
try {
const text = await navigator.clipboard.readText();
if (rfb === target && !target.viewOnly) pasteIntoDesktop(text);
} catch (_) {
window.parent.postMessage({ type: "lazyboy-paste-request" }, parentOrigin);
}
}, true);
2026-09-03 12:46:14 +00:00
connect();
2026-09-06 14:09:17 +00:00
window.addEventListener("resize", () => { pinTaskbar(); paintPointer(); });
window.addEventListener("pointerdown", (event) => {
if (event.target === keyboard || event.target.closest?.("#mobile-controls")) return;
try { if (rfb && event.pointerType !== "touch") { rfb.focusOnClick = true; rfb.focus(); } } catch (_) {}
2026-09-03 12:46:14 +00:00
if (rfb && rfb.viewOnly) {
2026-09-03 23:43:37 +00:00
window.parent.postMessage({ type: "lazyboy-request-control" }, parentOrigin);
2026-09-03 12:46:14 +00:00
}
});
window.addEventListener("paste", (event) => {
if (!rfb || rfb.viewOnly) return;
const text = event.clipboardData ? event.clipboardData.getData("text") : "";
if (!text) return;
event.preventDefault();
2026-09-03 16:16:34 +00:00
pasteIntoDesktop(text);
2026-09-06 14:09:17 +00:00
if (event.target === keyboard) resetKeyboard();
2026-09-03 16:16:34 +00:00
});
window.addEventListener("message", (event) => {
if (event.origin !== parentOrigin || event.source !== window.parent) return;
if (!event.data) return;
if (event.data.type === "lazyboy-view-only") {
applyViewOnly(event.data.viewOnly);
return;
}
if (event.data.type !== "lazyboy-host-clipboard") return;
2026-09-03 16:16:34 +00:00
pasteIntoDesktop(String(event.data.text || ""));
2026-09-03 12:46:14 +00:00
});
</script>
</head>
<body>
<div id="status" hidden>Loading desktop…</div>
2026-09-03 12:46:14 +00:00
<div id="screen"></div>
2026-09-06 14:09:17 +00:00
<div id="mobile-cursor" aria-hidden="true"></div>
<nav id="mobile-controls" aria-label="遠端滑鼠">
<div class="buttons">
<button id="pointer-mode" data-action="mode" aria-pressed="false" title="切換直接點選與觸控板">直接點選</button>
<button data-action="left">左鍵</button>
<button data-action="right">右鍵</button>
<button id="pointer-drag" data-action="drag" aria-pressed="false">拖曳</button>
<button data-action="up" aria-label="向上捲動"></button>
<button data-action="down" aria-label="向下捲動"></button>
<button data-action="keyboard">鍵盤</button>
</div>
2026-09-07 09:05:14 +00:00
<div class="buttons shortcuts" id="keyboard-shortcuts" hidden>
<button data-action="modifier" data-key="ctrl" aria-pressed="false">Ctrl</button>
<button data-action="modifier" data-key="alt" aria-pressed="false">Alt</button>
<button data-action="key" data-key="a">A</button><button data-action="key" data-key="c">C</button>
<button data-action="key" data-key="v">V</button><button data-action="key" data-key="z">Z</button>
<button data-action="key" data-key="Tab">Tab</button><button data-action="key" data-key="Escape">Esc</button>
<button data-action="key" data-key="Left"></button><button data-action="key" data-key="Right"></button>
<button data-action="key" data-key="Up"></button><button data-action="key" data-key="Down"></button>
<button data-action="key" data-key="Return">Enter</button><button data-action="key" data-key="BackSpace"></button>
<button data-action="hide-keyboard">收起鍵盤</button>
</div>
<div id="trackpad" role="group" aria-label="獨立觸控板" hidden>在這裡滑動控制滑鼠</div>
2026-09-06 14:09:17 +00:00
<p id="pointer-help"></p>
</nav>
<textarea id="mobile-keyboard" aria-label="Remote desktop keyboard" tabindex="-1"
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
2026-09-03 12:46:14 +00:00
</body>
</html>