mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 08:00:32 +02:00
Merge main into devices: switching headset stops the keyboard agent and retargets the Mac view
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
08fbe730c6
74 files changed
+9786
-167
No files matched your search
+353
-3
@@ -139,6 +139,7 @@
|
||||
.found { margin-top: 10px; }
|
||||
|
||||
/* ---- drop anywhere ---- */
|
||||
#media select { min-width: 0; max-width: 100%; flex: 1; }
|
||||
.dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none;
|
||||
background: rgba(14,20,27,.82); backdrop-filter: blur(3px); }
|
||||
.dropzone > div { padding: 42px 60px; border: 2px dashed var(--blue); border-radius: 8px; text-align: center;
|
||||
@@ -317,9 +318,9 @@
|
||||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||||
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||
.rep-item .s { white-space: normal; }
|
||||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg { width: min(640px, calc(100vw - 40px)); }
|
||||
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
|
||||
@@ -328,7 +329,10 @@
|
||||
#bugForm .popt { margin: 14px 0 0; }
|
||||
#bugForm .sentlog { max-height: 200px; }
|
||||
#bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; }
|
||||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
.about-text { max-height: 55vh; overflow: auto; }
|
||||
.about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); }
|
||||
.about-text summary { cursor: pointer; margin: 8px 0; }
|
||||
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||||
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
@@ -400,6 +404,19 @@
|
||||
border-radius: 3px; height: 32px; padding: 0 8px; font: inherit; font-size: 13px; }
|
||||
/* Touch screens can't hover: keep the library's name and Play button showing. */
|
||||
@media (hover: none) { .capsule .over { opacity: 1; } .capsule:hover { transform: none; } }
|
||||
/* ---- keyboard and trackpad ---- */
|
||||
.pad-area { position: relative; height: 190px; margin: 12px 0; border-radius: 4px; background: rgba(0,0,0,.28);
|
||||
border: 1px dashed rgba(255,255,255,.14); display: grid; place-items: center; cursor: pointer;
|
||||
touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; outline: none; }
|
||||
.pad-area:focus-visible, .pad-area.captured { border: 1px solid var(--blue); box-shadow: 0 0 0 1px var(--blue) inset; }
|
||||
.pad-area.off { cursor: default; opacity: .55; }
|
||||
.pad-hint { max-width: 420px; padding: 0 16px; text-align: center; color: var(--muted); font-size: 13px; pointer-events: none; }
|
||||
.pad-keys { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.pad-keys input { flex: 1 1 220px; min-width: 0; height: 32px; padding: 0 10px; border-radius: 3px; border: 1px solid transparent;
|
||||
background: rgba(0,0,0,.28); color: var(--text); font: inherit; }
|
||||
.pad-keys input:focus { outline: none; border-color: var(--blue); }
|
||||
#pad .hint { margin-top: 10px; }
|
||||
|
||||
/* ---- phones, upright or on their side: tabs move to a bottom bar, as in iOS apps. Every edge keeps clear of
|
||||
the safe area (notch or Dynamic Island, rounded corners, home indicator); env() is zero on desktops. ---- */
|
||||
@media (max-width: 640px), (max-height: 500px) and (hover: none) {
|
||||
@@ -436,6 +453,10 @@
|
||||
button.small { height: 32px; }
|
||||
.actions button { height: 46px; }
|
||||
.cat-tools select { max-width: none; flex: 1 1 100%; }
|
||||
.pad-area { height: 240px; }
|
||||
.pad-keys .seg { flex: 1 1 100%; display: grid; grid-template-columns: repeat(4, 1fr); }
|
||||
.pad-keys .pad-clicks { grid-template-columns: repeat(2, 1fr); }
|
||||
.pad-keys .seg button { padding: 0 4px; justify-content: center; text-align: center; }
|
||||
/* The on-screen keyboard covers the bottom of the screen; the tab bar would ride on top of it. */
|
||||
body.typing nav { display: none; }
|
||||
}
|
||||
@@ -571,6 +592,37 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel" id="pad">
|
||||
<div class="shelf-head"><h2>Keyboard and trackpad</h2><span class="sub" id="padState"></span><span class="spacer"></span>
|
||||
<button class="small" id="padOn">Turn on</button>
|
||||
</div>
|
||||
<div class="pad-area" id="padArea" tabindex="0" role="application" aria-label="Trackpad for the Frame">
|
||||
<div class="pad-hint" id="padHint"></div>
|
||||
</div>
|
||||
<div class="pad-keys" aria-label="Keys and clicks">
|
||||
<input id="padType" type="text" placeholder="Type here to type on the Frame" autocomplete="off" autocapitalize="off"
|
||||
autocorrect="off" spellcheck="false" enterkeyhint="send">
|
||||
<span class="seg pad-clicks">
|
||||
<button data-pad-click="singleclick" title="Left click">Click</button>
|
||||
<button data-pad-click="rightclick" title="Right click">Right-click</button>
|
||||
</span>
|
||||
<span class="seg pad-special">
|
||||
<button data-pad-key="14" title="Escape">Esc</button>
|
||||
<button data-pad-key="2" title="Tab">Tab</button>
|
||||
<button data-pad-key="4" title="Left arrow" aria-label="Left arrow">←</button>
|
||||
<button data-pad-key="5" title="Up arrow" aria-label="Up arrow">↑</button>
|
||||
<button data-pad-key="7" title="Down arrow" aria-label="Down arrow">↓</button>
|
||||
<button data-pad-key="6" title="Right arrow" aria-label="Right arrow">→</button>
|
||||
<button data-pad-key="1" title="Backspace" aria-label="Backspace">⌫</button>
|
||||
<button data-pad-key="12" title="Enter">Enter</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hint">Types and points in apps on the Frame, such as Chromium or the desktop's Linux apps; Steam's own VR menus
|
||||
don't take it. It works through KDE Connect, which comes with Frame Control: the first time, it copies Valve's build
|
||||
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
|
||||
else to install. <a href="#" data-about>Licences</a></div>
|
||||
</section>
|
||||
|
||||
<section id="shots">
|
||||
<div class="shelf-head"><h2>Screenshots</h2><span class="count" id="shotCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="shotsRefresh">Refresh</button>
|
||||
@@ -718,6 +770,31 @@
|
||||
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="media">
|
||||
<div class="shelf-head"><h2>Media in the headset</h2><span class="count">Preview</span></div>
|
||||
<p class="sub">Play your movies and stereo photos with Frame Control's own player.</p>
|
||||
<div class="row"><button class="small" id="mediaChoose">Send media…</button>
|
||||
<button class="small" id="mediaRefresh">Refresh</button></div>
|
||||
<input type="file" id="mediaInput" accept=".mp4,.mkv,.mov,.m4v,.webm,.png,.jpg,.jpeg,.splat" hidden>
|
||||
<div class="row" style="margin-top:12px"><label for="mediaFile">On the Frame</label>
|
||||
<select id="mediaFile"><option value="">Refresh to load your media</option></select></div>
|
||||
<div class="row" style="margin-top:8px"><label for="mediaLayout">Layout</label>
|
||||
<select id="mediaLayout">
|
||||
<option value="auto">Detect from name or metadata</option><option value="mono">2D</option>
|
||||
<option value="sbs">Half SBS · left / right</option><option value="ou">Half OU · top / bottom</option>
|
||||
<option value="full-sbs">Full SBS · left / right</option><option value="full-ou">Full OU · top / bottom</option>
|
||||
</select></div>
|
||||
<div class="row" style="margin-top:8px"><label><input type="checkbox" id="mediaTheatre" checked>
|
||||
Theatre · bigger screen, dark surround</label></div>
|
||||
<div class="row" style="margin-top:12px"><button class="action small" id="mediaPlay">Play</button>
|
||||
<button class="small" id="mediaStop">Stop</button></div>
|
||||
<p class="sub" id="mediaState" role="status" aria-live="polite">Choose a file to begin.</p>
|
||||
<div class="hint">H.264/H.265 video; SBS/OU PNG or JPEG photos. Screens follow your head.
|
||||
Small .splat files show a stationary stereo preview (up to 20,000 Gaussians).
|
||||
Native HEIC/MPO photos, large splat scenes, seeking and subtitles aren't supported yet.
|
||||
Stop works from here; playback also ends after four hours.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="apps">
|
||||
<div class="shelf-head"><h2>Linux apps</h2><span class="count">Flatpak</span></div>
|
||||
<div class="list" id="flatpaks"><div class="sub">Loading…</div></div>
|
||||
@@ -743,6 +820,7 @@
|
||||
<div class="hint desk-only">Sleep, Restart and Shut down open a terminal window for the Developer Mode password.</div>
|
||||
<div class="hint mobile-only">Sleep, Restart and Shut down ask for the Developer Mode password. SSH, SFTP, Steam Link and remote desktop open in the app that handles them.</div>
|
||||
<div class="row mobile-only" style="margin-top:14px"><button class="small" id="changeHeadset">Change headset…</button></div>
|
||||
<div class="row" style="margin-top:14px"><button class="small" data-about>About and licences</button></div>
|
||||
<div class="links desk-only">
|
||||
<div><a href="https://store.steampowered.com/remoteplay" target="_blank">Steam Link</a>: Valve's remote view of the headset</div>
|
||||
<div><a href="https://streamframe.app/" target="_blank">Stream Frame</a>: third-party recorder (macOS 14+)</div>
|
||||
@@ -831,6 +909,14 @@
|
||||
<div class="actions"><button id="apkAltClose">Close</button></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="aboutDlg" aria-labelledby="aboutTitle">
|
||||
<h2 id="aboutTitle">About and licences</h2>
|
||||
<p>Frame Control is MIT-licensed. It ships other people's software, each under its own licence: the notices and
|
||||
licence texts follow, with where to get the source.</p>
|
||||
<div id="aboutText" class="about-text">Loading…</div>
|
||||
<div class="actions"><button id="aboutClose">Close</button></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="repDlg" aria-labelledby="repTitle">
|
||||
<form method="dialog" id="repForm">
|
||||
<h2 id="repTitle">Report an APK</h2>
|
||||
@@ -1681,6 +1767,42 @@ function upload(file, mode, dev = window.connDevice) {
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
async function refreshMedia(selectId) {
|
||||
const data = await api("/api/media", { action: "list" });
|
||||
const selected = selectId || $("mediaFile").value;
|
||||
$("mediaFile").replaceChildren();
|
||||
if (!data.files.length) $("mediaFile").add(new Option("Send media to begin", ""));
|
||||
for (const file of data.files) $("mediaFile").add(new Option(file.name, file.id));
|
||||
if (data.files.some(f => f.id === selected)) $("mediaFile").value = selected;
|
||||
const p = data.player;
|
||||
$("mediaState").textContent = p.error || `${p.state}${p.file ? ": " + p.file : ""}`;
|
||||
return data;
|
||||
}
|
||||
$("mediaChoose").onclick = () => $("mediaInput").click();
|
||||
$("mediaInput").onchange = () => act("Send media", async () => {
|
||||
const file = $("mediaInput").files[0];
|
||||
if (!file) return;
|
||||
const result = await upload(file, "media");
|
||||
await refreshMedia(result.id);
|
||||
$("mediaInput").value = "";
|
||||
return result;
|
||||
}, $("mediaChoose"));
|
||||
$("mediaRefresh").onclick = () => act("Refresh media", () => refreshMedia(), $("mediaRefresh"));
|
||||
$("mediaPlay").onclick = () => act("Play media", async () => {
|
||||
const id = $("mediaFile").value;
|
||||
if (!id) throw new Error("Send or select a media file first");
|
||||
const result = await api("/api/media", { action: "play", id,
|
||||
layout: $("mediaLayout").value, theatre: $("mediaTheatre").checked });
|
||||
$("mediaState").textContent = "Starting…";
|
||||
setTimeout(() => refreshMedia().catch(e => { $("mediaState").textContent = e.message; }), 1500);
|
||||
return result;
|
||||
}, $("mediaPlay"));
|
||||
$("mediaStop").onclick = () => act("Stop media", async () => {
|
||||
const result = await api("/api/media", { action: "stop" });
|
||||
await refreshMedia();
|
||||
return result;
|
||||
}, $("mediaStop"));
|
||||
|
||||
let apkLookup = 0;
|
||||
async function checkApkAlternatives(apk, dev = window.connDevice) {
|
||||
const lookup = ++apkLookup;
|
||||
@@ -1726,6 +1848,21 @@ function showApkAlternatives(reason, result, dev = window.connDevice) {
|
||||
}
|
||||
$("apkAltClose").onclick = () => $("apkAltDlg").close();
|
||||
|
||||
// ---- about and licences ----
|
||||
document.addEventListener("click", async e => {
|
||||
if (!e.target.closest("[data-about]")) return;
|
||||
e.preventDefault();
|
||||
$("aboutDlg").showModal();
|
||||
try {
|
||||
const { notices } = await api("/api/licenses");
|
||||
$("aboutText").innerHTML = notices.map((n, i) =>
|
||||
`<details${i ? "" : " open"}><summary>${esc(n.title)}</summary><pre>${esc(n.text)}</pre></details>`).join("");
|
||||
} catch (err) {
|
||||
$("aboutText").textContent = `Couldn't load the licences: ${err.message}. They're also in THIRD_PARTY_NOTICES.md and frame/kdeconnect in Frame Control's source.`;
|
||||
}
|
||||
});
|
||||
$("aboutClose").onclick = () => $("aboutDlg").close();
|
||||
|
||||
const TITLE_EXT = /\.(zip|exe)$/i;
|
||||
async function sendFiles(files, dirs = new Set()) {
|
||||
const dev = window.connDevice; // the whole batch is for the headset it was dropped on
|
||||
@@ -2532,6 +2669,219 @@ window.addEventListener("hashchange", showPage);
|
||||
const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=checkbox], [type=radio], [type=file], [type=button])');
|
||||
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
|
||||
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
|
||||
|
||||
// ---- keyboard and trackpad: events go to KDE Connect on the Frame (see ui/frame_input_agent.py) ----
|
||||
const pad = { state: "off", queue: [], sending: false, poll: null, captured: false };
|
||||
const PAD_STATES = {
|
||||
off: "", starting: "Connecting…", installing: "Setting up KDE Connect on the Frame (first time only)…",
|
||||
pairing: "Pairing with KDE Connect…", ready: "On",
|
||||
};
|
||||
// KDE Connect's specialKey numbers (plugins/mousepad in its source).
|
||||
const PAD_SPECIAL = { Backspace: 1, Tab: 2, ArrowLeft: 4, ArrowUp: 5, ArrowRight: 6, ArrowDown: 7, PageUp: 8,
|
||||
PageDown: 9, Home: 10, End: 11, Enter: 12, Delete: 13, Escape: 14,
|
||||
...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`F${i + 1}`, 21 + i])) };
|
||||
const IS_MAC = /^Mac/.test(navigator.platform);
|
||||
const TOUCH = matchMedia("(hover: none)").matches;
|
||||
|
||||
function padShow(status) {
|
||||
pad.state = status.state || "off";
|
||||
const ready = pad.state === "ready";
|
||||
const busy = !ready && pad.state !== "off" && pad.state !== "error";
|
||||
$("padState").textContent = pad.state === "error" ? "" : PAD_STATES[pad.state] || "";
|
||||
$("padOn").hidden = ready || busy;
|
||||
$("padOn").textContent = pad.state === "error" ? "Try again" : "Turn on";
|
||||
$("padArea").classList.toggle("off", !ready);
|
||||
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach KDE Connect on the Frame."
|
||||
: !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.")
|
||||
: TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click"
|
||||
: pad.captured ? "Your mouse and keyboard now control the Frame. Press Esc to stop."
|
||||
: "Click here to use your mouse and keyboard on the Frame (Esc to stop), or drag like a trackpad.";
|
||||
clearTimeout(pad.poll);
|
||||
if (ready && pad.queue.length) padFlush();
|
||||
if (busy) pad.poll = setTimeout(async () => padShow(await api("/api/input").catch(e => ({ state: "error", message: e.message }))), 1000);
|
||||
}
|
||||
async function padStart() {
|
||||
localStorage.padUsed = "1"; // from now on it turns on with the page
|
||||
padShow({ state: "starting" });
|
||||
padShow(await api("/api/input?start=1").catch(e => ({ state: "error", message: e.message })));
|
||||
}
|
||||
$("padOn").onclick = padStart;
|
||||
|
||||
// Queue events and send them one request at a time, merging pointer moves made meanwhile.
|
||||
const isMove = e => Object.keys(e).every(k => k === "dx" || k === "dy");
|
||||
function padSend(event) {
|
||||
// While it reconnects, keys and clicks wait in the queue; pointer moves would be stale.
|
||||
// A button release is always kept, so a drag never leaves the button held on the Frame.
|
||||
const room = pad.queue.length < 200 || event.singlerelease;
|
||||
const reconnecting = ["starting", "pairing", "installing"].includes(pad.state) && room;
|
||||
// Even after an error: a button pressed on the Frame must be let go once it's back.
|
||||
const release = event.singlerelease && pad.state !== "off";
|
||||
if (pad.state !== "ready" && !(reconnecting && !isMove(event)) && !release) return;
|
||||
const last = pad.queue[pad.queue.length - 1];
|
||||
if (last && isMove(last) && isMove(event)) { last.dx = (last.dx || 0) + (event.dx || 0); last.dy = (last.dy || 0) + (event.dy || 0); }
|
||||
else pad.queue.push(event);
|
||||
padFlush();
|
||||
}
|
||||
// Events that didn't reach the Frame (the link had dropped, and is reconnecting): keep
|
||||
// the keys and clicks for when it's back, but not pointer moves, which would be stale.
|
||||
// Button releases are kept whatever the queue's length.
|
||||
function padKeep(events) {
|
||||
pad.queue = [...events, ...pad.queue].filter(e => !isMove(e)).filter((e, i) => i < 200 || e.singlerelease);
|
||||
}
|
||||
async function padFlush() {
|
||||
if (pad.sending || !pad.queue.length || pad.state !== "ready") return;
|
||||
pad.sending = true;
|
||||
const events = pad.queue.splice(0, 200);
|
||||
try {
|
||||
const status = await api("/api/input", { events });
|
||||
if (!status.sent) padKeep(events);
|
||||
if (status.state !== "ready") padShow(status);
|
||||
} catch (e) {
|
||||
padKeep(events);
|
||||
padShow({ state: "error", message: e.message });
|
||||
} finally {
|
||||
pad.sending = false;
|
||||
if (pad.queue.length) padFlush();
|
||||
}
|
||||
}
|
||||
function padKey(e) {
|
||||
const mods = { ...(e.ctrlKey || (IS_MAC && e.metaKey) ? { ctrl: true } : {}), ...(e.altKey ? { alt: true } : {}),
|
||||
...(!IS_MAC && e.metaKey ? { super: true } : {}) };
|
||||
if (PAD_SPECIAL[e.key]) return { specialKey: PAD_SPECIAL[e.key], ...mods, ...(e.shiftKey ? { shift: true } : {}) };
|
||||
if (e.key.length === 1) return { key: e.key, ...mods }; // already shifted, so no shift flag
|
||||
return null;
|
||||
}
|
||||
|
||||
// Touch: one finger moves, a quick tap clicks; two fingers scroll, or right-click when tapped.
|
||||
const PAD_SPEED = 1.6, PAD_SCROLL_STEP = 18;
|
||||
const touches = new Map();
|
||||
let gesture = null;
|
||||
$("padArea").addEventListener("pointerdown", e => {
|
||||
if (pad.state === "off" || pad.state === "error") { padStart(); return; }
|
||||
if (pad.state !== "ready") return;
|
||||
if (e.pointerType === "mouse") {
|
||||
if (pad.captured) return; // mousedown and mouseup below send the clicks
|
||||
// Lock the pointer to the pad and pass the whole mouse and keyboard through; where
|
||||
// that isn't allowed, the mouse drags like a finger on a trackpad instead.
|
||||
if (e.button === 0 && !pad.noLock && $("padArea").requestPointerLock) { $("padArea").requestPointerLock(); return; }
|
||||
// Only the left button drags and taps like a finger; the others are clicks of their own.
|
||||
if (e.button === 1 || e.button === 2) { padSend(e.button === 2 ? { rightclick: true } : { middleclick: true }); return; }
|
||||
if (e.button !== 0) return;
|
||||
}
|
||||
try { $("padArea").setPointerCapture(e.pointerId); } catch {} // keeps the drag if the finger leaves the pad
|
||||
touches.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (touches.size === 1) gesture = { start: performance.now(), moved: 0, fingers: 1, scroll: 0 };
|
||||
else if (gesture) gesture.fingers = Math.max(gesture.fingers, touches.size);
|
||||
e.preventDefault();
|
||||
});
|
||||
$("padArea").addEventListener("pointermove", e => {
|
||||
if (pad.captured) { padSend({ dx: e.movementX, dy: e.movementY }); return; }
|
||||
const t = touches.get(e.pointerId);
|
||||
if (!t || !gesture) return;
|
||||
const dx = e.clientX - t.x, dy = e.clientY - t.y;
|
||||
t.x = e.clientX; t.y = e.clientY;
|
||||
gesture.moved += Math.abs(dx) + Math.abs(dy);
|
||||
if (touches.size >= 2) {
|
||||
// Scroll with the lead finger only, a notch per step, like a laptop trackpad (content follows the fingers).
|
||||
if (e.pointerId !== touches.keys().next().value) return;
|
||||
gesture.scroll += dy;
|
||||
while (Math.abs(gesture.scroll) >= PAD_SCROLL_STEP) {
|
||||
const up = gesture.scroll > 0;
|
||||
padSend({ scroll: true, dy: up ? 1 : -1 });
|
||||
gesture.scroll -= up ? PAD_SCROLL_STEP : -PAD_SCROLL_STEP;
|
||||
}
|
||||
} else if (gesture.fingers === 1) {
|
||||
padSend({ dx: dx * PAD_SPEED, dy: dy * PAD_SPEED });
|
||||
}
|
||||
});
|
||||
function padLift(e) {
|
||||
if (!touches.delete(e.pointerId) || touches.size || !gesture) return;
|
||||
const quick = performance.now() - gesture.start < 250 && gesture.moved < 10;
|
||||
if (quick) padSend(gesture.fingers >= 2 ? { rightclick: true } : { singleclick: true });
|
||||
gesture = null;
|
||||
}
|
||||
$("padArea").addEventListener("pointerup", padLift);
|
||||
$("padArea").addEventListener("pointercancel", e => { touches.delete(e.pointerId); if (!touches.size) gesture = null; });
|
||||
|
||||
// A computer's mouse and keyboard: captured while the pointer is locked to the pad.
|
||||
document.addEventListener("pointerlockerror", () => { pad.noLock = true; });
|
||||
document.addEventListener("pointerlockchange", () => {
|
||||
pad.captured = document.pointerLockElement === $("padArea");
|
||||
window.frameApp?.captureKeys?.(pad.captured); // the desktop app's menu shortcuts go to the Frame too
|
||||
// Esc or switching windows mid-drag: let go of the button on the Frame as well.
|
||||
if (!pad.captured && pad.held) { padSend({ singlerelease: true }); pad.held = false; }
|
||||
$("padArea").classList.toggle("captured", pad.captured);
|
||||
padShow({ state: pad.state });
|
||||
});
|
||||
$("padArea").addEventListener("mousedown", e => {
|
||||
if (!pad.captured) return;
|
||||
if (e.button === 0) { padSend({ singlehold: true }); pad.held = true; }
|
||||
else if (e.button === 1) padSend({ middleclick: true });
|
||||
else if (e.button === 2) padSend({ rightclick: true });
|
||||
});
|
||||
$("padArea").addEventListener("mouseup", e => { if (pad.held && e.button === 0) { padSend({ singlerelease: true }); pad.held = false; } });
|
||||
$("padArea").addEventListener("contextmenu", e => e.preventDefault());
|
||||
$("padArea").addEventListener("wheel", e => {
|
||||
if (pad.state !== "ready") return;
|
||||
e.preventDefault();
|
||||
if (e.deltaY) padSend({ scroll: true, dy: e.deltaY > 0 ? -1 : 1 });
|
||||
}, { passive: false });
|
||||
// Capture phase, so the page's own shortcuts (tabs 1-4, R, zoom) don't also fire.
|
||||
window.addEventListener("keydown", e => {
|
||||
if (!pad.captured) return;
|
||||
e.preventDefault(); e.stopImmediatePropagation();
|
||||
const event = padKey(e);
|
||||
if (event) padSend(event);
|
||||
}, true);
|
||||
|
||||
// The text field: each keystroke goes to the Frame. While focused it holds one space,
|
||||
// so Backspace always has something to delete; empty otherwise, to show its prompt.
|
||||
const padType = $("padType");
|
||||
padType.addEventListener("beforeinput", e => {
|
||||
if (e.inputType === "insertCompositionText") return; // IME composing; sent on compositionend
|
||||
e.preventDefault();
|
||||
if (pad.state === "off" || pad.state === "error") padStart(); // what's typed meanwhile waits in the queue
|
||||
const text = e.data ?? e.dataTransfer?.getData("text/plain") ?? "";
|
||||
if (/^insert(Text|ReplacementText|FromPaste|FromDrop)$/.test(e.inputType) && text) padText(text);
|
||||
else if (e.inputType === "deleteContentBackward") padSend({ specialKey: 1 });
|
||||
else if (e.inputType === "deleteWordBackward") padSend({ specialKey: 1, ctrl: true }); // Ctrl+Backspace: a word, on Linux
|
||||
else if (e.inputType === "deleteContentForward") padSend({ specialKey: 13 });
|
||||
else if (e.inputType === "deleteWordForward") padSend({ specialKey: 13, ctrl: true });
|
||||
else if (e.inputType === "insertLineBreak" || e.inputType === "insertParagraph") padSend({ specialKey: 12 });
|
||||
});
|
||||
padType.addEventListener("compositionend", e => { if (e.data) padText(e.data); padType.value = " "; padCaret(); });
|
||||
// Text in pieces the server takes (500 characters), with Enter for each line break.
|
||||
function padText(text) {
|
||||
text.replace(/\r\n?/g, "\n").split("\n").forEach((line, i) => {
|
||||
if (i) padSend({ specialKey: 12 });
|
||||
const chars = Array.from(line); // whole characters, so an emoji isn't cut in two
|
||||
for (let at = 0; at < chars.length; at += 500) padSend({ key: chars.slice(at, at + 500).join("") });
|
||||
});
|
||||
}
|
||||
padType.addEventListener("keydown", e => {
|
||||
const special = PAD_SPECIAL[e.key];
|
||||
// Text and deletions arrive as beforeinput; this takes the keys that don't edit the field.
|
||||
if (!special || e.key === "Backspace" || e.key === "Delete" || e.isComposing) return;
|
||||
e.preventDefault();
|
||||
padSend(padKey(e));
|
||||
});
|
||||
// The caret stays after the space, so Backspace always has something to delete.
|
||||
const padCaret = () => requestAnimationFrame(() => padType.setSelectionRange(1, 1));
|
||||
padType.addEventListener("focus", () => { padType.value = " "; padCaret(); if (pad.state === "off") padStart(); });
|
||||
padType.addEventListener("blur", () => { padType.value = ""; });
|
||||
padType.addEventListener("input", e => {
|
||||
if (e.isComposing) return; // an input method is still composing; compositionend sends it
|
||||
if (padType.value !== " ") padType.value = " ";
|
||||
padCaret();
|
||||
});
|
||||
padType.addEventListener("click", padCaret);
|
||||
document.querySelector("#pad .pad-keys").addEventListener("click", e => {
|
||||
const b = e.target.closest("button");
|
||||
if (!b) return;
|
||||
if (b.dataset.padClick) padSend({ [b.dataset.padClick]: true });
|
||||
else if (b.dataset.padKey) padSend({ specialKey: +b.dataset.padKey });
|
||||
});
|
||||
api("/api/input").then(s => s.state === "off" && localStorage.padUsed ? padStart() : padShow(s), () => padShow({ state: "off" }));
|
||||
showPage();
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;
|
||||
|
||||
Reference in new issue
Block a user