remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git
index.html (26K)
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
6 <meta name="apple-mobile-web-app-capable" content="yes">
7 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
8 <title>cce remote</title>
9 <style>
10 * { margin: 0; padding: 0; box-sizing: border-box; -webkit-user-select: none; user-select: none; }
11 html, body { height: 100%; overflow: hidden; background: #14151c; color: #ccccd4;
12 font-family: ui-monospace, Menlo, monospace; }
13 body { display: flex; flex-direction: column;
14 padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
15 #bar { display: flex; gap: 6px; padding: 8px; flex-wrap: wrap; }
16 #bar button { flex: 1 1 auto; min-width: 44px; padding: 10px 6px; font-size: 13px;
17 font-family: inherit; color: #ccccd4; background: #23252f;
18 border: 1px solid #2e3140; border-radius: 8px; }
19 #bar button.on { background: #25626c; border-color: #337fc0; }
20 #pad { flex: 1; margin: 0 8px; background: #191b23; border: 1px solid #2e3140;
21 border-radius: 12px; touch-action: none; position: relative;
22 /* clip the zoomed/panned view (its wrapper is what scales) to the
23 frame — the clip must be on this NON-transformed ancestor */
24 overflow: hidden; }
25 #pad .hint { position: absolute; inset: 0; display: flex; align-items: center;
26 justify-content: center; color: #4a4d5c; font-size: 12px; text-align: center;
27 pointer-events: none; }
28 #clicks { display: flex; gap: 8px; padding: 8px; }
29 #clicks button { flex: 1; padding: 16px; font-size: 13px; font-family: inherit;
30 color: #ccccd4; background: #23252f; border: 1px solid #2e3140;
31 border-radius: 10px; }
32 #clicks button:active, #bar button:active { background: #2c5f69; }
33 #kb { position: absolute; opacity: 0.02; width: 1px; height: 1px; left: -10px; top: -10px; }
34 /* Zoom/pan transform lives on the WRAPPER: getBoundingClientRect then
35 reflects it, so the cursor ring stays correctly placed while zoomed.
36 (Historically this also dodged an iOS bug where a transformed
37 multipart-MJPEG <img> stopped repainting; the live view is a canvas
38 now, but the structure stays.) */
39 #screenwrap { display: none; position: absolute; inset: 0; z-index: 2;
40 transform-origin: 0 0; border-radius: 12px; overflow: hidden; }
41 #pad.view #screenwrap { display: block; }
42 #screen { display: block; width: 100%; height: 100%;
43 object-fit: contain; background: #0c0d12; }
44 #vcursor { display: none; position: absolute; width: 14px; height: 14px; z-index: 3;
45 pointer-events: none; border: 2px solid #7dffff; border-radius: 50%;
46 box-shadow: 0 0 4px #000; margin: -7px 0 0 -7px; }
47 #pad.view #vcursor.on { display: block; }
48 #pad.view #screen { display: block; }
49 #pad.view .hint { display: none; }
50 #switcher, #menu { display: none; position: absolute; inset: 8px; overflow-y: auto;
51 background: rgba(20,21,28,0.96); border: 1px solid #2e3140;
52 border-radius: 10px; z-index: 5; -webkit-overflow-scrolling: touch; }
53 #switcher.show, #menu.show { display: block; }
54 #switcher .win, #menu .win { padding: 14px 14px; border-bottom: 1px solid #23252f;
55 display: flex; gap: 10px; align-items: baseline; }
56 #switcher .win .t, #menu .win .t { flex: 1; overflow: hidden; text-overflow: ellipsis;
57 white-space: nowrap; font-size: 14px; }
58 #switcher .win .a { color: #6a6d7c; font-size: 11px; }
59 #switcher .win.focused { background: #1d3a44; }
60 #switcher .win.focused .t { color: #7dffff; }
61 #switcher .empty { padding: 20px; color: #4a4d5c; text-align: center; font-size: 12px; }
62 #preview { display: none; margin: 0 8px 6px; padding: 8px 10px; min-height: 36px;
63 background: #191b23; border: 1px solid #2e3140; border-radius: 8px;
64 font-size: 15px; color: #cfe8e8; white-space: pre-wrap; word-break: break-all; }
65 #preview.show { display: block; }
66 #preview.show::after { content: "▍"; color: #7dffff; animation: blink 1s steps(1) infinite; }
67 @keyframes blink { 50% { opacity: 0; } }
68 #status { position: fixed; top: 4px; right: 10px; font-size: 10px; color: #4a4d5c; }
69 </style>
70 </head>
71 <body>
72 <div id="status">·</div>
73 <div id="bar">
74 <button data-k="1">esc</button>
75 <button data-k="15">tab</button>
76 <button data-mod="29">ctrl</button>
77 <button data-mod="56">alt</button>
78 <button data-mod="125">sup</button>
79 <button data-k="105">←</button>
80 <button data-k="103">↑</button>
81 <button data-k="108">↓</button>
82 <button data-k="106">→</button>
83 <button id="wbtn">☰</button>
84 <button id="mbtn">⋯</button>
85 <button id="kbtn">⌨</button>
86 </div>
87 <div id="preview"></div>
88 <div id="pad"><div class="hint">drag = move · tap = click · 2-finger drag = scroll<br>2-finger tap = right click · hold = drag</div>
89 <div id="screenwrap"><canvas id="screen" width="0" height="0"></canvas></div>
90 <div id="vcursor"></div>
91 <div id="switcher"></div>
92 <div id="menu"></div>
93 </div>
94 <div id="clicks">
95 <button id="lclick">left</button>
96 <button id="rclick">right</button>
97 </div>
98 <textarea id="kb" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false"></textarea>
99 <script>
100 "use strict";
101 // ── WebSocket + pairing ──────────────────────────────────────────────
102 // First frame must be `auth <pin>` (the PIN cce-remote prints at startup).
103 // Remembered in localStorage after the first successful pairing.
104 let ws = null, authed = false, pageVer = null;
105 const status = document.getElementById("status");
106 function askPin(msg) {
107 const pin = window.prompt(msg || "cce-remote pairing PIN (shown in the server terminal):");
108 if (pin) localStorage.setItem("cce-remote-pin", pin.trim());
109 return pin ? pin.trim() : null;
110 }
111 function connect() {
112 authed = false;
113 ws = new WebSocket("ws://" + location.host + "/ws");
114 ws.onopen = () => {
115 const pin = localStorage.getItem("cce-remote-pin") || askPin();
116 if (pin) ws.send("auth " + pin); else ws.close();
117 };
118 ws.onmessage = e => {
119 if (typeof e.data === "string" && e.data.startsWith("windows ")) { renderSwitcher(e.data.slice(8)); }
120 else if (typeof e.data === "string" && e.data.startsWith("ploc ")) { updateCursorMarker(e.data.slice(5)); }
121 else if (typeof e.data === "string" && e.data.startsWith("ver ")) {
122 // The server names the page version it serves. First connect pins it;
123 // a DIFFERENT version on a later reconnect means the service restarted
124 // onto new code — reload to pick it up. (The input WS retries every 1s,
125 // so this fires moments after a restart, automatically.)
126 const v = e.data.slice(4);
127 if (pageVer === null) pageVer = v;
128 else if (pageVer !== v) location.reload();
129 }
130 else if (e.data === "auth ok") { authed = true; status.textContent = "●"; status.style.color = "#5fbf6f"; }
131 else if (e.data === "auth fail") {
132 localStorage.removeItem("cce-remote-pin");
133 askPin("Wrong PIN — try again:");
134 // server closed on failure; onclose reconnects with the new PIN
135 }
136 };
137 ws.onclose = () => { authed = false; status.textContent = "○"; status.style.color = "#bf5f5f"; setTimeout(connect, 1000); };
138 }
139 connect();
140 function send(s) { if (authed && ws && ws.readyState === 1) ws.send(s); }
141
142 // ── Trackpad ─────────────────────────────────────────────────────────
143 const pad = document.getElementById("pad");
144 // forward decls used by the pad handlers; the panels are wired further down.
145 // "sw" now means any overlay panel (window switcher or the ⋯ menu).
146 function swContains(el) {
147 return document.getElementById("switcher").contains(el)
148 || document.getElementById("menu").contains(el);
149 }
150 function swHide() {
151 document.getElementById("switcher").classList.remove("show");
152 document.getElementById("menu").classList.remove("show");
153 }
154 // SCROLL is finger-px → wl axis units (~surface px): 0.8 ≈ natural 1:1 feel.
155 const ACCEL = 1.6, SCROLL = 0.8;
156 // Touch state: one finger moves, two fingers scroll; short still touches
157 // click; a long-press starts a held drag (release on lift).
158 let touches = new Map(), moved = 0, startT = 0, maxFingers = 0, startX = 0, startY = 0;
159 let dragging = false, dragTimer = null, pendDx = 0, pendDy = 0, pendSy = 0, flushT = null;
160
161 // ── Window view mode: live view of the focused window. Input is identical
162 // to the trackpad — the picture is feedback, not a touchscreen.
163 // Frames arrive as JPEG binaries on a dedicated /wstream WebSocket, drawn to
164 // the canvas; winRect, which places the cursor ring, is kept fresh by polling
165 // `wl` over the input WS (the stream follows focus server-side).
166 //
167 // Delivery is ACK-CLOCKED: after rendering a frame we send "n", and only then
168 // does the server send its NEWEST frame. At most one frame is ever in flight,
169 // so a slow link costs frame rate, never accumulating lag — the old MJPEG
170 // path queued frames in the TCP buffer and grew seconds behind. The ack also
171 // times the whole pipeline for the server's resolution/quality adaptation,
172 // which is why it is sent after drawImage, not on receipt.
173 let viewMode = false, winRect = null, rectTimer = null, cursorTimer = null;
174 const screenEl = document.getElementById("screen");
175 const screenCtx = screenEl.getContext("2d");
176 let sws = null, lastFrameAt = 0, watchdog = null;
177 function streamStart() {
178 if (sws) { const old = sws; sws = null; try { old.close(); } catch (_) {} }
179 const pin = localStorage.getItem("cce-remote-pin") || "";
180 const s = new WebSocket("ws://" + location.host + "/wstream");
181 s.binaryType = "arraybuffer";
182 sws = s;
183 lastFrameAt = Date.now();
184 let paired = false;
185 s.onopen = () => s.send("auth " + pin);
186 s.onmessage = async e => {
187 if (typeof e.data === "string") {
188 if (e.data === "auth ok") { paired = true; s.send("n"); }
189 return;
190 }
191 lastFrameAt = Date.now();
192 try {
193 const bmp = await createImageBitmap(new Blob([e.data], { type: "image/jpeg" }));
194 if (screenEl.width !== bmp.width || screenEl.height !== bmp.height) {
195 screenEl.width = bmp.width;
196 screenEl.height = bmp.height;
197 }
198 screenCtx.drawImage(bmp, 0, 0);
199 bmp.close();
200 } catch (_) {}
201 if (sws === s && s.readyState === 1) s.send("n");
202 };
203 // Reconnect only if this connection ever paired: a stale PIN would
204 // otherwise retry-loop failures into the server's rate limiter and lock
205 // the phone's address out of the INPUT socket too.
206 s.onclose = () => { if (viewMode && sws === s && paired) setTimeout(streamStart, 600); };
207 }
208 function streamStop() {
209 if (sws) { const old = sws; sws = null; try { old.close(); } catch (_) {} }
210 screenEl.width = 0;
211 screenEl.height = 0;
212 }
213 function updateRect(wins) {
214 const f = wins.find(w => w.focused && w.mode !== "Status");
215 if (f) winRect = { id: f.id, x: f.x, y: f.y, w: f.w, h: f.h };
216 }
217 // View zoom/pan (two-finger pinch/drag in view mode): a pure client-side
218 // transform of the streamed image. Uniform scale+translate means
219 // getBoundingClientRect reflects it, so placeMarker stays correct zoomed.
220 let vZoom = 1, vPanX = 0, vPanY = 0, pinch = null;
221 const screenWrap = document.getElementById("screenwrap");
222 function applyViewTransform() {
223 screenWrap.style.transform = "translate(" + vPanX + "px," + vPanY + "px) scale(" + vZoom + ")";
224 }
225 function resetViewTransform() { vZoom = 1; vPanX = 0; vPanY = 0; pinch = null; applyViewTransform(); }
226 function clampPan() {
227 const r = pad.getBoundingClientRect();
228 vPanX = Math.min(Math.max(vPanX, r.width - r.width * vZoom), 0);
229 vPanY = Math.min(Math.max(vPanY, r.height - r.height * vZoom), 0);
230 if (vZoom <= 1.001) { vZoom = 1; vPanX = 0; vPanY = 0; }
231 }
232
233 // The compositor's frames carry no cursor (they're surface textures), so
234 // view mode draws its own marker: poll pointer-location over the WS and map
235 // that layout position through the contain-fit letterbox onto the canvas.
236 const vcursor = document.getElementById("vcursor");
237 let ptr = null; // last known pointer position in layout px (from ploc + prediction)
238 function placeMarker(lx, ly) {
239 if (!viewMode || !winRect || !screenEl.width) { vcursor.classList.remove("on"); return; }
240 const u = (lx - winRect.x) / winRect.w, v = (ly - winRect.y) / winRect.h;
241 if (!(u >= 0 && u <= 1 && v >= 0 && v <= 1)) { vcursor.classList.remove("on"); return; }
242 const box = screenEl.getBoundingClientRect();
243 const scale = Math.min(box.width / screenEl.width, box.height / screenEl.height);
244 const dw = screenEl.width * scale, dh = screenEl.height * scale;
245 const padBox = pad.getBoundingClientRect();
246 vcursor.style.left = (box.left - padBox.left + (box.width - dw) / 2 + u * dw) + "px";
247 vcursor.style.top = (box.top - padBox.top + (box.height - dh) / 2 + v * dh) + "px";
248 vcursor.classList.add("on");
249 }
250 // Server truth (poll): reconcile the predicted position.
251 function updateCursorMarker(coords) {
252 const [lx, ly] = coords.split(" ").map(Number);
253 ptr = { x: lx, y: ly };
254 placeMarker(lx, ly);
255 }
256 // Client prediction: move the marker with the finger immediately, so pointer
257 // motion feels instant even though the window frame is damage-gated and won't
258 // repaint just because the cursor moved. Reconciled by the next ploc poll.
259 function predictMarker(dLx, dLy) {
260 if (!ptr || !winRect) return;
261 ptr.x = Math.min(Math.max(ptr.x + dLx, winRect.x), winRect.x + winRect.w);
262 ptr.y = Math.min(Math.max(ptr.y + dLy, winRect.y), winRect.y + winRect.h);
263 placeMarker(ptr.x, ptr.y);
264 }
265
266 function flush() {
267 flushT = null;
268 if (pendDx || pendDy) { send("m " + (pendDx * ACCEL).toFixed(1) + " " + (pendDy * ACCEL).toFixed(1)); pendDx = pendDy = 0; }
269 if (pendSy) { send("s " + pendSy.toFixed(3) + " 0"); pendSy = 0; }
270 }
271 function queueFlush() { if (!flushT) flushT = setTimeout(flush, 12); }
272
273 pad.addEventListener("touchstart", e => {
274 // Touches on the window-switcher panel are its own (row taps, list
275 // scrolling) — never trackpad input. Any other pad touch dismisses it.
276 if (swContains(e.target)) return;
277 swHide();
278 e.preventDefault();
279 for (const t of e.changedTouches) touches.set(t.identifier, { x: t.clientX, y: t.clientY });
280 maxFingers = Math.max(maxFingers, touches.size);
281 if (touches.size === 1) {
282 moved = 0; startT = Date.now(); pinch = null;
283 const t0 = e.changedTouches[0];
284 startX = t0.clientX; startY = t0.clientY;
285 dragTimer = setTimeout(() => {
286 if (moved >= 8) return;
287 dragging = true; send("b left down"); navigator.vibrate && navigator.vibrate(15);
288 }, 350);
289 } else { clearTimeout(dragTimer); }
290 }, { passive: false });
291
292 pad.addEventListener("touchmove", e => {
293 if (swContains(e.target)) return;
294 e.preventDefault();
295 if (touches.size === 1) {
296 const t = e.changedTouches[0], p = touches.get(t.identifier);
297 if (!p) return;
298 // one-finger drag = relative pointer motion, in BOTH modes
299 const rdx = t.clientX - p.x, rdy = t.clientY - p.y;
300 pendDx += rdx; pendDy += rdy; queueFlush();
301 // view mode: predict the cursor marker instantly (same accel as the send)
302 if (viewMode) predictMarker(rdx * ACCEL, rdy * ACCEL);
303 moved += Math.abs(rdx) + Math.abs(rdy);
304 touches.set(t.identifier, { x: t.clientX, y: t.clientY });
305 } else if (touches.size === 2) {
306 let dy = 0, n = 0;
307 for (const t of e.changedTouches) {
308 const p = touches.get(t.identifier);
309 if (p) { dy += t.clientY - p.y; n++; touches.set(t.identifier, { x: t.clientX, y: t.clientY }); }
310 }
311 if (n) moved += Math.abs(dy);
312 if (viewMode) {
313 // two fingers in view mode: pinch-zoom + pan of the view
314 const pts = [...touches.values()];
315 if (pts.length === 2) {
316 const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
317 const cx = (pts[0].x + pts[1].x) / 2, cy = (pts[0].y + pts[1].y) / 2;
318 if (pinch && pinch.dist > 0) {
319 const nz = Math.min(8, Math.max(1, vZoom * (dist / pinch.dist)));
320 const applied = nz / vZoom;
321 const r = pad.getBoundingClientRect();
322 const px = cx - r.left, py = cy - r.top;
323 // zoom about the pinch centroid, then follow its drag
324 vPanX = px - (px - vPanX) * applied + (cx - pinch.cx);
325 vPanY = py - (py - vPanY) * applied + (cy - pinch.cy);
326 vZoom = nz;
327 clampPan();
328 applyViewTransform();
329 }
330 pinch = { dist, cx, cy };
331 }
332 } else if (n) {
333 // trackpad mode: two-finger drag scrolls (natural direction)
334 pendSy += (-dy / n) * SCROLL;
335 queueFlush();
336 }
337 }
338 }, { passive: false });
339
340 pad.addEventListener("touchend", e => {
341 if (swContains(e.target)) return;
342 e.preventDefault();
343 for (const t of e.changedTouches) touches.delete(t.identifier);
344 if (touches.size < 2) pinch = null;
345 if (touches.size > 0) return;
346 clearTimeout(dragTimer);
347 flush();
348 if (dragging) { send("b left up"); dragging = false; }
349 else if (moved < 10 && Date.now() - startT < 300) {
350 // Identical in both modes: a tap clicks where the cursor IS — it never
351 // warps the pointer to the tapped spot. View mode is the trackpad with a
352 // picture behind it, not a touchscreen.
353 send(maxFingers >= 2 ? "b right click" : "b left click");
354 }
355 maxFingers = 0;
356 }, { passive: false });
357 pad.addEventListener("touchcancel", () => { touches.clear(); clearTimeout(dragTimer); if (dragging) { send("b left up"); dragging = false; } maxFingers = 0; });
358
359 document.getElementById("lclick").addEventListener("click", () => send("b left click"));
360 document.getElementById("rclick").addEventListener("click", () => send("b right click"));
361
362 // ── View mode + the ⋯ menu ───────────────────────────────────────────
363 const mbtn = document.getElementById("mbtn");
364 function setViewMode(on) {
365 if (viewMode === on) return;
366 viewMode = on;
367 mbtn.classList.toggle("on", viewMode);
368 pad.classList.toggle("view", viewMode);
369 resetViewTransform();
370 if (viewMode) {
371 streamStart();
372 send("wl"); // prime winRect
373 rectTimer = setInterval(() => send("wl"), 2000);
374 cursorTimer = setInterval(() => send("pl"), 120);
375 // Reconnect if no frame arrives for a while (the frame sources force
376 // keepalive frames every ≤20s, so ~30s of silence means the connection
377 // is dead, not idle).
378 watchdog = setInterval(() => {
379 if (viewMode && Date.now() - lastFrameAt > 30000) streamStart();
380 }, 5000);
381 } else {
382 clearInterval(rectTimer);
383 clearInterval(cursorTimer);
384 clearInterval(watchdog);
385 vcursor.classList.remove("on");
386 streamStop();
387 }
388 }
389 const menuEl = document.getElementById("menu");
390 function menuRow(label, danger, fn) {
391 const row = document.createElement("div");
392 row.className = "win";
393 const t = document.createElement("span");
394 t.className = "t";
395 t.textContent = label;
396 if (danger) t.style.color = "#e08a8a";
397 row.appendChild(t);
398 row.addEventListener("click", () => { swHide(); fn(); });
399 menuEl.appendChild(row);
400 }
401 mbtn.addEventListener("click", () => {
402 if (menuEl.classList.contains("show")) { swHide(); return; }
403 swHide();
404 menuEl.innerHTML = "";
405 menuRow(viewMode ? "Switch to trackpad" : "Show focused window (live view)", false,
406 () => setViewMode(!viewMode));
407 menuRow("Reload page", false, () => location.reload());
408 menuRow("Restart compositor", true, () => {
409 if (window.confirm("Restart the compositor? Every app in the session will close, then the session relaunches.")) {
410 send("cmd restart-compositor");
411 }
412 });
413 menuEl.classList.add("show");
414 });
415
416 // ── Window switcher ──────────────────────────────────────────────────
417 const switcher = document.getElementById("switcher");
418 let wantSwitcher = false;
419 document.getElementById("wbtn").addEventListener("click", () => {
420 if (switcher.classList.contains("show")) { switcher.classList.remove("show"); return; }
421 wantSwitcher = true;
422 send("wl"); // reply renders + shows the panel
423 });
424 function renderSwitcher(json) {
425 let wins = [];
426 try { wins = JSON.parse(json); } catch (_) {}
427 updateRect(wins); // every wl reply refreshes the tap-mapping rect
428 if (!wantSwitcher) return;
429 wantSwitcher = false;
430 // real app windows only: no status-bar modules, no child surfaces
431 wins = wins.filter(w => w.mode !== "Status" && !w.has_parent && !w.minimized);
432 switcher.innerHTML = "";
433 if (!wins.length) {
434 switcher.innerHTML = '<div class="empty">no windows</div>';
435 }
436 for (const w of wins) {
437 const row = document.createElement("div");
438 row.className = "win" + (w.focused ? " focused" : "");
439 const t = document.createElement("span"); t.className = "t"; t.textContent = w.title || w.app_id;
440 const a = document.createElement("span"); a.className = "a"; a.textContent = w.app_id;
441 row.append(t, a);
442 row.addEventListener("click", () => {
443 send("wf " + w.id);
444 swHide();
445 });
446 switcher.appendChild(row);
447 }
448 switcher.classList.add("show");
449 }
450
451 // ── Bar keys + sticky modifiers ──────────────────────────────────────
452 for (const b of document.querySelectorAll("#bar button[data-k]"))
453 b.addEventListener("click", () => send("k " + b.dataset.k));
454 for (const b of document.querySelectorAll("#bar button[data-mod]"))
455 b.addEventListener("click", () => {
456 const on = b.classList.toggle("on");
457 send((on ? "kd " : "ku ") + b.dataset.mod);
458 });
459
460 // ── Keyboard: iOS software keyboard via a hidden textarea ────────────
461 // beforeinput is the reliable path on iOS (keydown often reports 229).
462 const kb = document.getElementById("kb");
463 document.getElementById("kbtn").addEventListener("click", () => {
464 if (document.activeElement === kb) { kb.blur(); } else { kb.focus(); }
465 });
466 // iOS never fires deleteContentBackward on an EMPTY field — keep sentinel
467 // padding in the textarea (cursor at the end) so backspace always has
468 // something to "delete". beforeinput preventDefault keeps it untouched;
469 // the re-arm below repairs any drift (autocorrect ghosts etc.).
470 const SENTINEL = "········";
471 function armKb() { kb.value = SENTINEL; kb.setSelectionRange(SENTINEL.length, SENTINEL.length); }
472 // Local echo of what's been typed — cleared on Enter or keyboard close.
473 // (The target app shows the real text; this is feedback for eyes-on-phone.)
474 const preview = document.getElementById("preview");
475 let typed = "";
476 // The iOS keyboard overlays the page without any CSS-visible layout change;
477 // visualViewport is the only honest signal (its height shrinks by the
478 // keyboard, and offsetTop tracks iOS sliding a standalone page). While the
479 // keyboard is up, fit the WHOLE layout into the visible strip above it: the
480 // flex column reflows, so the trackpad / live view shrinks but stays fully
481 // visible — its bottom edge, and the click buttons, ride the keyboard top.
482 // The 0.85 guard keeps ordinary browser-chrome changes from counting as a
483 // keyboard. Tap mapping and the cursor marker need no adjustment: both read
484 // getBoundingClientRect, which reflects the reflow.
485 const vv = window.visualViewport;
486 function fitViewport() {
487 const short = vv && vv.height < window.innerHeight * 0.85;
488 document.body.style.height = short ? vv.height + "px" : "";
489 // The keyboard covers the home-indicator area, so the safe-area bottom
490 // padding (~34px in standalone) would hover the click buttons that far
491 // above the keyboard. It exists to dodge the indicator; the keyboard is
492 // dodging it for us.
493 document.body.style.paddingBottom = short ? "0px" : "";
494 document.body.style.transform = short && vv.offsetTop > 0
495 ? "translateY(" + vv.offsetTop + "px)"
496 : "";
497 }
498 if (vv) {
499 vv.addEventListener("resize", fitViewport);
500 vv.addEventListener("scroll", fitViewport);
501 }
502 function updPreview() {
503 preview.textContent = typed;
504 preview.classList.toggle("show", document.activeElement === kb);
505 }
506 kb.addEventListener("focus", () => { armKb(); updPreview(); });
507 kb.addEventListener("blur", () => { typed = ""; updPreview(); });
508 // US-layout char → [evdev keycode, needs shift]
509 const KEYS = {};
510 "abcdefghijklmnopqrstuvwxyz".split("").forEach((c, i) => {
511 KEYS[c] = [[30,48,46,32,18,33,34,35,23,36,37,38,50,49,24,25,16,19,31,20,22,47,17,45,21,44][i], false];
512 KEYS[c.toUpperCase()] = [KEYS[c][0], true];
513 });
514 "1234567890".split("").forEach((c, i) => { KEYS[c] = [2 + i, false]; });
515 Object.assign(KEYS, {
516 " ": [57,false], "-": [12,false], "=": [13,false], "[": [26,false], "]": [27,false],
517 "\\": [43,false], ";": [39,false], "'": [40,false], "`": [41,false], ",": [51,false],
518 ".": [52,false], "/": [53,false],
519 "!": [2,true], "@": [3,true], "#": [4,true], "$": [5,true], "%": [6,true], "^": [7,true],
520 "&": [8,true], "*": [9,true], "(": [10,true], ")": [11,true], "_": [12,true], "+": [13,true],
521 "{": [26,true], "}": [27,true], "|": [43,true], ":": [39,true], "\"": [40,true],
522 "~": [41,true], "<": [51,true], ">": [52,true], "?": [53,true],
523 });
524 function typeChar(ch) {
525 const k = KEYS[ch];
526 if (!k) return;
527 if (k[1]) { send("kd 42"); send("k " + k[0]); send("ku 42"); }
528 else { send("k " + k[0]); }
529 typed += ch;
530 }
531 kb.addEventListener("beforeinput", e => {
532 e.preventDefault();
533 switch (e.inputType) {
534 case "insertText":
535 case "insertReplacementText":
536 for (const ch of (e.data || "")) typeChar(ch);
537 break;
538 case "insertLineBreak": send("k 28"); typed = ""; break;
539 case "deleteContentBackward": send("k 14"); typed = typed.slice(0, -1); break;
540 case "deleteWordBackward": {
541 // one backspace per removed char, so desktop and preview stay in sync
542 const rest = typed.replace(/\S+\s*$/, "");
543 const n = Math.max(1, typed.length - rest.length);
544 for (let i = 0; i < n; i++) send("k 14");
545 typed = rest;
546 break;
547 }
548 case "deleteContentForward": send("k 111"); break;
549 }
550 updPreview();
551 });
552 // repair sentinel drift while the keyboard is up
553 setInterval(() => { if (document.activeElement === kb && kb.value !== SENTINEL) armKb(); }, 1000);
554 </script>
555 </body>
556 </html>