22360d46e5
Two conflicts, both expected.
**`invalidate_relabelled` (improvise-hkt6, WU-K).** Both sides kept: the
signature and the item-tuple comparison are this branch's — `old` is an
`AxisFrame` now, and a group header appearing or collapsing must not blank
cells it did not rename — and the totals blanking and its whole argument are
main's. `grid_updates` still diffs the totals against the *framed* mirror,
which is what makes a renamed column whose total did not change send one;
`a_relabelled_column_drops_its_total`,
`a_relabelled_row_leaves_the_totals_alone` and
`a_renamed_column_whose_total_did_not_change_still_gets_its_total_back` all
pass against the new frame.
**`lib.rs`'s composition test.** Main's deletion taken: 18189c1 moved
`client_showing`, `published_basis` and
`a_client_composes_a_write_from_the_id_it_was_given` into `socket.rs` when
it consolidated `set_cell_at` and `typed`.
Main's new `GridUpdate::RowLabels`/`ColLabels` call sites are converted, and
five doc comments naming the retired spellings with them — a fact stated in
several places is a grep, not an edit.
1327 passing, 0 failing. clippy/fmt clean, wasm gate clean, both bundles
build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
520 lines
23 KiB
JavaScript
520 lines
23 KiB
JavaScript
// The browser half of improvise-cr3, kept as thin as it can be made, now
|
|
// talking to either of two peers (improvise-djm).
|
|
//
|
|
// The boundary is JSON strings in and out, so this file holds no protocol
|
|
// knowledge at all: it never inspects a Command, never builds a CellId, never
|
|
// decides whether a cell is editable or whether a typed string is a number.
|
|
// Five calls are the whole harness —
|
|
//
|
|
// client.set_viewport(rows, cols) -> the handshake frame, and every resize;
|
|
// also records the window so keys resolve here
|
|
// client.on_frame(json) -> fold one frame, in arrival order
|
|
// client.render_model() -> everything to paint, already decided
|
|
// client.typed_cell(r, c, s) -> the frame for "the user typed s here"
|
|
// client.on_key_event(k,c,a,s,m) -> the frame for a keypress, folded locally
|
|
// first where this client may answer it
|
|
//
|
|
// — and the two rules this file must not break are ordering and framing:
|
|
// frames are folded one at a time in the order they arrive (a cell update
|
|
// that overtakes its labels is dropped, not replayed), and the *first* frame
|
|
// out must be the viewport, because the server builds the snapshot at it.
|
|
//
|
|
// # Two transports, one client
|
|
//
|
|
// The wasm bundle is the SAME artifact either way (improvise-xks6: one
|
|
// envelope pair, two deployments). Only where the strings come from differs,
|
|
// so the difference is confined to `openTransport` and nothing below it knows
|
|
// which peer it has:
|
|
//
|
|
// default an in-page Web Worker hosting the whole App over OPFS
|
|
// (improvise-9x6). No server process; this is what makes a
|
|
// GitHub Pages copy of this directory a working application.
|
|
// ?ws=host:port
|
|
// a websocket to `improvise-ws-server`. That process is how a
|
|
// document opened in the TUI is shared, so the path is kept; the
|
|
// presence of the parameter is the whole selection mechanism,
|
|
// because a second knob naming the same fact is a second thing to
|
|
// get wrong. The scheme defaults to `wss://` — see `socketUrl`.
|
|
//
|
|
// Neither is reachable silently. Every transport writes what it is doing to
|
|
// `#status` — "starting", "connected", "cannot reach …", "disconnected" — so
|
|
// a peer that never arrives reads differently from one that went away.
|
|
// "disconnected" means exactly the second: a socket that opened and then
|
|
// closed. A socket that never opened keeps saying "cannot reach …", because
|
|
// the close event there is the tail of the failure and not news.
|
|
|
|
import init, { Client } from "./pkg/improvise_wasm_client.js";
|
|
|
|
const params = new URLSearchParams(location.search);
|
|
// present => socket, absent => in-page worker. The value is a host:port, and
|
|
// the scheme is NOT the caller's to get wrong: see `socketUrl`.
|
|
const WS_PEER = params.get("ws");
|
|
|
|
/**
|
|
* The websocket URL for `?ws=`, defaulting to TLS.
|
|
*
|
|
* `wss://` is the default and a bare `host:port` gets it, because the address
|
|
* that works from where this page is actually served is the one it should be
|
|
* easiest to write. This page ships to GitHub Pages, which is https, and an
|
|
* https origin refuses a `ws://` address as mixed content — so a `ws://`
|
|
* default would hand the browser a URL that cannot work and report it as a
|
|
* connection failure, which reads like "the server is not running".
|
|
*
|
|
* An explicit `ws://` is honoured only where it can function: a page served
|
|
* over plain http, which is the local `python3 -m http.server` case that pairs
|
|
* with `improvise-ws-server` (which speaks plain `ws://` only — it has no TLS,
|
|
* so reaching it from https means terminating TLS in front of it). From an
|
|
* https page an explicit `ws://` is upgraded rather than passed through, since
|
|
* passing it through can only produce a failure the user cannot act on.
|
|
*/
|
|
function socketUrl(peer) {
|
|
const secure = location.protocol === "https:";
|
|
if (/^wss:\/\//i.test(peer)) return peer;
|
|
if (/^ws:\/\//i.test(peer)) return secure ? peer.replace(/^ws:/i, "wss:") : peer;
|
|
return `${secure ? "wss" : "ws"}://${peer}`;
|
|
}
|
|
|
|
const WS_URL = WS_PEER ? socketUrl(WS_PEER) : null;
|
|
const DOC = params.get("doc") ?? "demo.improv";
|
|
const SEED = params.get("seed") ?? "demo.improv";
|
|
|
|
// Rough character metrics, used only to turn the window size into an extent.
|
|
// Being wrong here costs a row of over- or under-fetch, nothing more.
|
|
const ROW_PX = 26;
|
|
const COL_PX = 110;
|
|
|
|
const el = {
|
|
grid: document.getElementById("grid"),
|
|
categories: document.getElementById("categories"),
|
|
mode: document.getElementById("mode"),
|
|
hint: document.getElementById("hint"),
|
|
status: document.getElementById("status"),
|
|
viewName: document.getElementById("view-name"),
|
|
};
|
|
|
|
await init();
|
|
const client = new Client();
|
|
|
|
// `#status` carries two independent facts — what the transport is doing, and
|
|
// what the document last said — so it composes them rather than letting a
|
|
// repaint quietly erase a connection error.
|
|
let shellStatus = "";
|
|
let modelStatus = "";
|
|
function paintStatus() {
|
|
el.status.textContent = [shellStatus, modelStatus].filter(Boolean).join(" · ");
|
|
}
|
|
function setShellStatus(text) {
|
|
shellStatus = text;
|
|
paintStatus();
|
|
}
|
|
|
|
// What the grid's own frame costs, in whole cells, as of the last repaint.
|
|
// One header row and one header column was right until improvise-anr9: the
|
|
// column header is now one row PER LEVEL plus an optional group row, the row
|
|
// header is `row_levels` columns wide, and inline group headers take a row
|
|
// each. Reserving one of each announces a viewport the page cannot show, and
|
|
// `#grid` has `overflow: auto`, so the DOM would acquire a scroll position of
|
|
// its own while the client's cursor offsets were computed from the larger
|
|
// window — putting the selected cell off screen.
|
|
//
|
|
// It is measured off the painted model rather than predicted, and defaults to
|
|
// the old one-and-one before the first paint, which is the only moment there
|
|
// is nothing to measure. What it counts, and the one thing it deliberately
|
|
// does not, is at the tail of `repaint`.
|
|
let frameCost = { rows: 1, cols: 1 };
|
|
|
|
/** The extent this window can show, in whole DATA cells. */
|
|
function extent() {
|
|
const box = el.grid.getBoundingClientRect();
|
|
return {
|
|
rows: Math.max(1, Math.floor(box.height / ROW_PX) - frameCost.rows),
|
|
cols: Math.max(1, Math.floor(box.width / COL_PX) - frameCost.cols),
|
|
};
|
|
}
|
|
|
|
function sendViewport() {
|
|
const { rows, cols } = extent();
|
|
// `client.set_viewport`, NOT the associated `Client.viewport`: the same
|
|
// frame, but it also RECORDS the window. A client learns its window only
|
|
// from what it announced -- `row_frame.len()` is the window clipped to the
|
|
// grid, so the two differ exactly at the bottom edge, which is where the
|
|
// scroll arithmetic matters. Without this the client cannot clamp, so every
|
|
// cursor key goes upstream into `Host::handle`'s empty vec and the arrow
|
|
// keys silently do nothing (improvise-bpq2).
|
|
transport.send(client.set_viewport(rows, cols));
|
|
}
|
|
|
|
// A peer that never answers the handshake. The socket has an `error` event
|
|
// for this; the worker has none that fires, because worker.js catches its own
|
|
// `start()` rejection — so a browser with no OPFS, or a worker bundle that
|
|
// failed to load, would leave the page saying "in-page worker" over an empty
|
|
// grid forever. The watchdog is deliberately about the *first frame* rather
|
|
// than about either peer's own failure modes: it is the one condition that
|
|
// means the same thing in both deployments.
|
|
const HANDSHAKE_TIMEOUT_MS = 8000;
|
|
let watchdog = null;
|
|
function armWatchdog(message) {
|
|
clearTimeout(watchdog);
|
|
watchdog = setTimeout(() => setShellStatus(message), HANDSHAKE_TIMEOUT_MS);
|
|
}
|
|
|
|
// A peer that failed *loudly* has already said something more precise than
|
|
// the watchdog's guess, so the watchdog must be disarmed and not merely
|
|
// pre-empted: it fires later and would overwrite the better message with
|
|
// "did not answer" (PR #13 review). The watchdog is for silence only.
|
|
function setShellFailure(text) {
|
|
clearTimeout(watchdog);
|
|
watchdog = null;
|
|
setShellStatus(text);
|
|
}
|
|
|
|
/** One frame, one fold, in arrival order. Never batch or reorder these. */
|
|
function onFrame(text) {
|
|
clearTimeout(watchdog);
|
|
try {
|
|
client.on_frame(text);
|
|
} catch (err) {
|
|
// `on_frame` returns a Result, which wasm-bindgen throws. Thrown in a bare
|
|
// listener it reached the devtools console and nothing else, and the grid
|
|
// simply stopped updating with nothing on screen saying why (improvise-hwpi).
|
|
setShellStatus("bad frame — the grid has stopped updating (see console)");
|
|
console.error("improvise: frame rejected", text, err);
|
|
return;
|
|
}
|
|
repaint();
|
|
}
|
|
|
|
// ── transports ───────────────────────────────────────────────────────────
|
|
|
|
/** The in-page worker: the standalone deployment, no server process. */
|
|
function workerTransport() {
|
|
const query = new URLSearchParams({ doc: DOC, seed: SEED });
|
|
// `type: "module"` is not optional: worker.js is an ES module (it `import`s
|
|
// the worker bundle), and a classic worker would fail to parse it.
|
|
const worker = new Worker(`./worker.js?${query}`, { type: "module" });
|
|
worker.addEventListener("message", (event) => onFrame(event.data));
|
|
worker.addEventListener("messageerror", () =>
|
|
setShellFailure("the worker sent a frame this page could not read"),
|
|
);
|
|
worker.addEventListener("error", (event) => {
|
|
setShellFailure(`worker failed to start: ${event.message ?? "see console"}`);
|
|
console.error("improvise: worker error", event);
|
|
});
|
|
setShellStatus(`in-page worker · ${DOC}`);
|
|
// Frames posted before the worker has finished `start()` are held by
|
|
// worker.js and replayed in order, so the handshake can go out immediately.
|
|
return { send: (text) => worker.postMessage(text), ready: Promise.resolve() };
|
|
}
|
|
|
|
/** A websocket to `improvise-ws-server`: how a TUI-hosted document is shared. */
|
|
function socketTransport() {
|
|
const socket = new WebSocket(WS_URL);
|
|
const unreachable = `cannot reach ${WS_URL} — is improvise-ws-server running?`;
|
|
// Did this socket ever open? The page has to record that itself, because
|
|
// nothing on the close event carries it: `readyState` is already CLOSED by
|
|
// the time a close listener runs, whichever happened, and a refused
|
|
// connection and a mid-session network drop are BOTH code 1006 with
|
|
// `wasClean: false`. So "never connected" and "was connected and went away"
|
|
// are distinguishable only by remembering.
|
|
let opened = false;
|
|
const ready = new Promise((resolve) => {
|
|
socket.addEventListener("open", () => {
|
|
opened = true;
|
|
setShellStatus(`connected · ${WS_URL}`);
|
|
resolve();
|
|
});
|
|
});
|
|
socket.addEventListener("message", (event) => onFrame(event.data));
|
|
// Without this, a connection that never opens left #status blank and
|
|
// "server not running" read exactly like "still connecting" (improvise-hwpi).
|
|
socket.addEventListener("error", () => setShellFailure(unreachable));
|
|
// A socket that never opened did not "disconnect" — it failed to connect,
|
|
// and the close event is the tail of that failure rather than a second,
|
|
// vaguer thing that happened (improvise-taxx). Writing "disconnected" here
|
|
// unconditionally replaced the `error` listener's precise message with the
|
|
// least informative of the two facts.
|
|
//
|
|
// Same family as the watchdog bug and NOT the same fix: the watchdog is
|
|
// disarmed because it is for silence only, whereas suppressing `close`
|
|
// outright would hide a genuine mid-session disconnect, which is the one
|
|
// thing this listener is really for. Hence the split rather than a guard.
|
|
// Both arms go through `setShellFailure`, which DISARMS the watchdog. That
|
|
// matters on the opened path: the watchdog is armed after `transport.ready`
|
|
// resolves — i.e. on `open` — so a socket that opened and then dropped
|
|
// before its first frame reaches here with the timer still pending, and
|
|
// "disconnected" is more precise than the guess it would otherwise fire 8s
|
|
// later. On the never-opened path there is no timer yet and the clear is a
|
|
// no-op. Neither arm leaves one armed.
|
|
socket.addEventListener("close", () =>
|
|
setShellFailure(opened ? "disconnected" : unreachable),
|
|
);
|
|
setShellStatus(`connecting to ${WS_URL}…`);
|
|
return {
|
|
send: (text) => {
|
|
if (socket.readyState === WebSocket.OPEN) socket.send(text);
|
|
},
|
|
ready,
|
|
};
|
|
}
|
|
|
|
const transport = WS_URL ? socketTransport() : workerTransport();
|
|
|
|
// ── painting ─────────────────────────────────────────────────────────────
|
|
|
|
let painted = -1;
|
|
let editing = null; // {row, col} while an input is open, so a repaint cannot steal it
|
|
|
|
function repaint() {
|
|
if (editing) return;
|
|
let model;
|
|
try {
|
|
model = JSON.parse(client.render_model());
|
|
} catch (err) {
|
|
setShellStatus("cannot render the current state (see console)");
|
|
console.error("improvise: render failed", err);
|
|
return;
|
|
}
|
|
if (model.revision === painted) return;
|
|
painted = model.revision;
|
|
|
|
el.mode.textContent = model.mode;
|
|
// The mode's own hint, off `AppMode::hint_text()`. The page spells no
|
|
// keys itself; see `RenderModel::hint`.
|
|
el.hint.textContent = model.hint;
|
|
modelStatus = model.status;
|
|
paintStatus();
|
|
el.viewName.textContent = model.view_name;
|
|
el.grid.replaceChildren(gridTable(model));
|
|
el.categories.replaceChildren(categoryList(model));
|
|
|
|
// What the frame costs, so the NEXT viewport announcement is honest: the
|
|
// header lines, a group line if there is one, the row header's width in
|
|
// columns, and every inline group row.
|
|
//
|
|
// Inline group ROWS are NOT reserved, and that is a decision rather than
|
|
// an oversight. They take real height, but how many are in view depends on
|
|
// how many rows the window asked for, so every way of counting them is
|
|
// either a feedback loop (count the current ones: a smaller window crosses
|
|
// fewer groups and asks for a larger one) or a ratchet (keep the maximum:
|
|
// a group-dense grid stores an oversized cost on its first oversized
|
|
// projection and never lets it fall — one group header per row clamps the
|
|
// grid to a single row, permanently). Both were tried on this PR.
|
|
//
|
|
// What is left is an over-request bounded by the groups in view. The cost
|
|
// is that a grouped grid asks for a few rows more than the table shows,
|
|
// which matters only for a selection near the bottom edge — and that gap
|
|
// is not new and not specific to groups: nothing in this page calls
|
|
// `scrollIntoView` at all, so a selection the client has not yet scrolled
|
|
// to can already sit below the fold. Filed as its own ticket rather than
|
|
// answered with a heuristic in a file no test in this repo can execute.
|
|
const next = {
|
|
rows: model.col_levels.length + (model.col_groups.length > 0 ? 1 : 0),
|
|
cols: model.row_levels,
|
|
};
|
|
const changed = next.rows !== frameCost.rows || next.cols !== frameCost.cols;
|
|
frameCost = next;
|
|
if (changed) sendViewport();
|
|
}
|
|
|
|
/** The grid, painted from a model in which every frame decision is already
|
|
* made (improvise-anr9). The page decides nothing here: which labels repeat
|
|
* and are therefore blank, how many header levels there are, which columns
|
|
* share a group and whether it is collapsed all arrive answered, by the same
|
|
* `AxisFrame` the TUI widget reads. */
|
|
function gridTable(model) {
|
|
const table = document.createElement("table");
|
|
const head = table.createTHead();
|
|
const corner = () => {
|
|
const th = document.createElement("th");
|
|
th.colSpan = model.row_levels;
|
|
return th;
|
|
};
|
|
|
|
// The column-group line, when there is one. An empty `col_groups` means
|
|
// this grid has no column groups at all -- not that every group is blank.
|
|
if (model.col_groups.length > 0) {
|
|
const groups = head.insertRow();
|
|
groups.appendChild(corner());
|
|
for (const group of model.col_groups) {
|
|
const th = document.createElement("th");
|
|
th.colSpan = group.span;
|
|
// `label` is null for a run in no group. An empty STRING is a legal
|
|
// group name and still gets its indicator, which is why this is a null
|
|
// check and not a truthiness test.
|
|
th.textContent =
|
|
group.label === null ? "" : indicator(group.collapsed) + " " + group.label;
|
|
th.classList.add("group");
|
|
groups.appendChild(th);
|
|
}
|
|
}
|
|
|
|
// One header line per level. A blank entry is repeat suppression, decided
|
|
// in Rust -- never an absent label.
|
|
for (const level of model.col_levels) {
|
|
const tr = head.insertRow();
|
|
tr.appendChild(corner());
|
|
for (const label of level) {
|
|
const th = document.createElement("th");
|
|
th.textContent = label;
|
|
tr.appendChild(th);
|
|
}
|
|
}
|
|
|
|
const body = table.createTBody();
|
|
// The cell coordinates a click sends are DATA row indices, and a group
|
|
// header is not a data row -- so this counts rows rather than trusting the
|
|
// position in `model.rows`.
|
|
let r = -1;
|
|
for (const row of model.rows) {
|
|
const tr = body.insertRow();
|
|
if (row.kind === "group") {
|
|
const th = document.createElement("th");
|
|
th.colSpan = model.row_levels + model.col_levels[0].length;
|
|
th.textContent = indicator(row.collapsed) + " " + row.label;
|
|
th.classList.add("group");
|
|
tr.appendChild(th);
|
|
continue;
|
|
}
|
|
r += 1;
|
|
// `r` is one binding shared by every closure below, so a click handler
|
|
// that captured it would fire with the LAST row's index. Snapshot it.
|
|
const dataRow = r;
|
|
|
|
for (const label of row.labels) {
|
|
const header = document.createElement("th");
|
|
header.scope = "row";
|
|
header.textContent = label;
|
|
tr.appendChild(header);
|
|
}
|
|
|
|
row.cells.forEach((cell, c) => {
|
|
const td = tr.insertCell();
|
|
// A keyboard edit is open on the selected cell when `model.editing` is
|
|
// a string; it holds what has been typed so far. Both facts are
|
|
// decided in Rust (`RenderModel::editing`) — the page does not know
|
|
// which modes are editing modes or which buffer they type into.
|
|
// Without this the mode indicator would say INSERT while every
|
|
// keystroke vanished into a buffer nobody drew.
|
|
const typing = cell.selected && typeof model.editing === "string";
|
|
td.textContent = typing ? model.editing + "\u258f" : cell.text;
|
|
td.classList.toggle("numeric", cell.numeric && !typing);
|
|
td.classList.toggle("error", cell.error);
|
|
td.classList.toggle("selected", cell.selected);
|
|
td.classList.toggle("typing", typing);
|
|
// `editable` is the server's answer relayed through Rust: a stored
|
|
// cell. An aggregate is a drill target and drill is not on the wire
|
|
// yet, so it is simply not offered here.
|
|
td.classList.toggle("editable", cell.editable);
|
|
if (cell.editable)
|
|
td.addEventListener("click", () => edit(td, dataRow, c, cell.edit_text));
|
|
});
|
|
}
|
|
return table;
|
|
}
|
|
|
|
/** The collapse glyph. `collapsed` is the fact, decided by the view and
|
|
* carried on the frame; only this spelling is the page's (gotcha 26). */
|
|
function indicator(collapsed) {
|
|
return collapsed ? "\u25b6" : "\u25bc";
|
|
}
|
|
|
|
/** Open an inline editor. Committing sends a frame; the value that comes
|
|
* back is the server's, never the string typed here. */
|
|
function edit(td, row, col, seed) {
|
|
if (editing) return;
|
|
editing = { row, col };
|
|
// `seed` is `RenderCell::edit_text` -- the STORED value, not the drawn
|
|
// one. `td.textContent` has been through the number format, so a stored
|
|
// 1200 reads "1,200" and committing that stores the string "1,200".
|
|
const original = seed;
|
|
const input = document.createElement("input");
|
|
input.value = original;
|
|
td.replaceChildren(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
// Enter removes the focused <input>, which fires `blur`, which calls
|
|
// `close` again. Harmless before, but only by accident (improvise-hwpi).
|
|
let closed = false;
|
|
const close = (commit) => {
|
|
if (closed) return;
|
|
closed = true;
|
|
editing = null;
|
|
if (commit) {
|
|
const frame = client.typed_cell(row, col, input.value);
|
|
// null only if the grid moved under the editor; the repaint that
|
|
// follows restores whatever the server now says is there.
|
|
if (frame) transport.send(frame);
|
|
}
|
|
td.replaceChildren(original);
|
|
painted = -1;
|
|
repaint();
|
|
};
|
|
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") close(true);
|
|
else if (e.key === "Escape") close(false);
|
|
// The editor owns the keyboard while it is open; the page-level handler
|
|
// below must not also see these.
|
|
e.stopPropagation();
|
|
});
|
|
input.addEventListener("blur", () => close(false));
|
|
}
|
|
|
|
function categoryList(model) {
|
|
const list = document.createElement("ul");
|
|
for (const row of model.categories) {
|
|
const li = document.createElement("li");
|
|
li.className = row.is_category ? "category" : "item";
|
|
li.classList.toggle("selected", row.selected);
|
|
li.textContent = row.is_category
|
|
? `${row.expanded ? "▾" : "▸"} ${row.label} (${row.item_count}) ${row.axis}`
|
|
: row.label;
|
|
list.appendChild(li);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
// ── the keyboard ─────────────────────────────────────────────────────────
|
|
//
|
|
// `Client::on_key_event` classifies the event and folds it locally where this
|
|
// client may answer it — text entry, and since improvise-bpq2 the grid, panel
|
|
// and tile cursors too, which is why `sendViewport` must call `set_viewport`
|
|
// and not `Client.viewport` (keys.rs carries the list, and what is still
|
|
// upstream). Anything it cannot answer becomes a `ClientFrame::Key`, which
|
|
// `Host::handle` answers with an empty vec: those keys are sent and do
|
|
// nothing. That is settled rather than unfinished — the host must not know
|
|
// the keymap — and closing one means teaching this client, not that arm.
|
|
|
|
window.addEventListener("keydown", (e) => {
|
|
// The inline editor owns the keyboard while it is open. It stops
|
|
// propagation, so this is belt and braces for a focus we did not predict.
|
|
if (editing) return;
|
|
// BrowserKey drops anything held with the platform meta key precisely so
|
|
// Cmd-R does not arrive as a bare `r`; do not claim it here either.
|
|
if (e.metaKey) return;
|
|
|
|
const frame = client.on_key_event(e.key, e.ctrlKey, e.altKey, e.shiftKey, e.metaKey);
|
|
if (frame) {
|
|
// A frame means the page claimed the key, so the browser must not also
|
|
// act on it — an arrow that both moves the cursor and scrolls the page is
|
|
// the worse of the two errors. Combinations under Ctrl or Alt are left
|
|
// alone regardless: those are the browser's own shortcuts, and this
|
|
// client resolves nothing under them anyway.
|
|
if (!e.ctrlKey && !e.altKey) e.preventDefault();
|
|
transport.send(frame);
|
|
}
|
|
repaint();
|
|
});
|
|
|
|
// ── start ────────────────────────────────────────────────────────────────
|
|
|
|
await transport.ready;
|
|
sendViewport(); // the handshake: the snapshot is built at this extent
|
|
armWatchdog(
|
|
WS_URL
|
|
? `${WS_URL} accepted the connection but sent no document`
|
|
: "the in-page worker did not answer — this browser may not allow it (see console)",
|
|
);
|
|
new ResizeObserver(sendViewport).observe(el.grid);
|