a93d668e5e
improvise-p7lh: `GridCache::page_coords` and `GridCache::totals` were projected to every session and folded correctly by every client, and NO front end read either. The shipped front page — `web/demo.improv` is `Quarter: page, Q1` — drew a grid filtered to Q1 with nothing saying so, and `[`/`]` resolve locally off that same `page_coords`, so a user could page through quarters watching every number change with no way to tell which one they were looking at. `RenderModel` gains `page_coords` and `totals`, fed straight off `state.grid`. The Page/Filter split is asked of `PageAxis::cycle` rather than re-derived from `items.is_some()`, which disagrees with it on an empty item list (gotcha 27). The totals are RELAYED, never summed in the client: the sum runs over every row including ones a viewport scrolled past (gotcha 44). `web/tests/render_surface.test.js` gains the direction that would have caught this when improvise-n10 added the fields. The existing guard is closed under what `RenderModel` chose to expose, so a cache field that never reaches it is invisible to it; the new one asks that every `pub` field of every widget cache is read somewhere the browser runs, or is named as debt with a reason. `grid.col_widths` is the one entry, pointing at improvise-pcje. improvise-b8ot: the five clickable surfaces moved from one listener per node, reallocated inside the render loops on every repaint, to one handler per stable delegation root — the user's ruling. The operands are carried on the node as data attributes and read back by a pure `cellAction`/`indexAction` in `web/shell.js`, so the handler is a LOOKUP rather than a second derivation of which data row was clicked: `gridPlan` counts data rows because a group header is not one, and walking the table back up would disagree the moment one appears. The editor's own keydown/blur stay direct — one transient node, one at a time, and `blur` does not bubble. All four documented behaviours a naive delegation loses are preserved and each is now asserted in `harness/tests/contract.spec.js`: mousedown rather than click (asserted by dispatching mousedown ALONE), `preventDefault` and only over a cell, the conditionally skipped repaint (asserted by the editor's survival), and closing a live editor before composing the click. Gates: 1598 passed / 0 failed / 7 ignored over 37 binaries (baseline 1595); node --test 47 over 6 files (baseline 36 over 5); Playwright 47 passed / 4 skipped (baseline 43 / 4); clippy, fmt and --check-evidence clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
617 lines
23 KiB
JavaScript
617 lines
23 KiB
JavaScript
// The browser half of improvise-cr3, talking to either of two peers: five
|
||
// string calls, no protocol knowledge. See context/code/clients.md.
|
||
|
||
import init, { Client } from "./pkg/improvise_wasm_client.js";
|
||
import {
|
||
COL_PX,
|
||
HANDSHAKE_TIMEOUT_MS,
|
||
ROW_PX,
|
||
cellAction,
|
||
cellAttrs,
|
||
classifyWorkerMessage,
|
||
createShell,
|
||
documentTitle,
|
||
extentOf,
|
||
frameCostOf,
|
||
gridPlan,
|
||
indexAction,
|
||
indicator,
|
||
isFatal,
|
||
noticeText,
|
||
pageEntries,
|
||
socketUrl,
|
||
} from "./shell.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");
|
||
|
||
const WS_URL = WS_PEER ? socketUrl(WS_PEER, location.protocol) : null;
|
||
const DOC = params.get("doc") ?? "demo.improv";
|
||
const SEED = params.get("seed") ?? "demo.improv";
|
||
|
||
const el = {
|
||
grid: document.getElementById("grid"),
|
||
categories: document.getElementById("categories"),
|
||
formulas: document.getElementById("formulas"),
|
||
views: document.getElementById("views"),
|
||
tiles: document.getElementById("tiles"),
|
||
pages: document.getElementById("pages"),
|
||
mode: document.getElementById("mode"),
|
||
minibuffer: document.getElementById("minibuffer"),
|
||
hint: document.getElementById("hint"),
|
||
help: document.getElementById("help"),
|
||
status: document.getElementById("status"),
|
||
viewName: document.getElementById("view-name"),
|
||
docName: document.getElementById("doc-name"),
|
||
};
|
||
|
||
await init();
|
||
const client = new Client();
|
||
|
||
// A peer that never answers the handshake, and the two facts `#status` shows.
|
||
// Both are decided in shell.js; this is the wire to the element.
|
||
const shell = createShell({
|
||
onStatus: (text) => {
|
||
el.status.textContent = text;
|
||
},
|
||
timeoutMs: HANDSHAKE_TIMEOUT_MS,
|
||
});
|
||
// The harness asks whether the watchdog is ARMED, because a negative assertion
|
||
// about it is vacuous where it is not (clients.md). Read by tests, nothing else.
|
||
window.__improviseShell = shell;
|
||
|
||
// What the grid's own frame costs, in whole cells, as of the last repaint —
|
||
// measured off the painted model, never predicted. context/code/clients.md.
|
||
let frameCost = { rows: 1, cols: 1 };
|
||
|
||
function sendViewport() {
|
||
const box = el.grid.getBoundingClientRect();
|
||
const metrics = { rowPx: ROW_PX, colPx: COL_PX };
|
||
const { rows, cols } = extentOf(box, frameCost, metrics);
|
||
// `set_viewport` RECORDS the window as well as framing it; the frame-only
|
||
// twin beside it is deleted, so there is nothing to call by mistake (seik).
|
||
// QUIET: an extent announcement is not a user edit, and a `ResizeObserver`
|
||
// firing after a drop must not overwrite the precise close message.
|
||
transport.send(client.set_viewport(rows, cols), { loud: false });
|
||
}
|
||
|
||
/** What the worker said that was not a frame: a boot or save failure carrying
|
||
* its REASON, which is the channel improvise-1wft exists to add. */
|
||
function onNotice(notice) {
|
||
// A notice is a LOUD failure, so it disarms the watchdog rather than being
|
||
// replaced by it eight seconds later (gotcha 48).
|
||
shell.fail(noticeText(notice));
|
||
if (isFatal(notice)) console.error("improvise: boot failed", notice);
|
||
}
|
||
|
||
/** One frame, one fold, in arrival order. Never batch or reorder these. */
|
||
function onFrame(text) {
|
||
shell.sawFrame();
|
||
try {
|
||
client.on_frame(text);
|
||
} catch (err) {
|
||
// `on_frame` returns a Result, which wasm-bindgen throws; thrown in a bare
|
||
// listener it reached the console alone and the grid just stopped.
|
||
shell.setShell("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) => {
|
||
// A protocol frame is a string; a shell notice is an object. The rule is
|
||
// `shell.js`'s and is tested there.
|
||
const message = classifyWorkerMessage(event.data);
|
||
if (message.kind === "frame") onFrame(message.text);
|
||
else if (message.kind === "notice") onNotice(message.notice);
|
||
else console.error("improvise: worker sent an unreadable message", event.data);
|
||
});
|
||
worker.addEventListener("messageerror", () =>
|
||
shell.fail("the worker sent a frame this page could not read"),
|
||
);
|
||
worker.addEventListener("error", (event) => {
|
||
shell.fail(`worker failed to start: ${event.message ?? "see console"}`);
|
||
console.error("improvise: worker error", event);
|
||
});
|
||
shell.setShell(`in-page worker · ${DOC}`);
|
||
// Frames posted before the worker has finished `start()` are held by
|
||
// worker.js in arrival 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 must record it itself: nothing on the
|
||
// close event can discriminate. See context/code/clients.md.
|
||
let opened = false;
|
||
const ready = new Promise((resolve) => {
|
||
socket.addEventListener("open", () => {
|
||
opened = true;
|
||
shell.setShell(`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".
|
||
socket.addEventListener("error", () => shell.fail(unreachable));
|
||
// A socket that never opened did not "disconnect" (improvise-taxx). BOTH arms
|
||
// route through `shell.fail`, which disarms the watchdog — clients.md.
|
||
socket.addEventListener("close", () =>
|
||
shell.fail(opened ? "disconnected" : unreachable),
|
||
);
|
||
shell.setShell(`connecting to ${WS_URL}…`);
|
||
return {
|
||
// CLOSING can THROW and CONNECTING/CLOSED drop silently, which loses an
|
||
// edit with nothing on screen to say so (improvise-ociy).
|
||
send: (text, { loud = true } = {}) => {
|
||
if (socket.readyState !== WebSocket.OPEN) {
|
||
if (loud) {
|
||
shell.fail(`not sent — ${opened ? "the connection dropped" : "not connected yet"}`);
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
socket.send(text);
|
||
} catch (err) {
|
||
if (loud) shell.fail("not sent — the connection is closing");
|
||
console.error("improvise: send failed", err);
|
||
}
|
||
},
|
||
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
|
||
// A render that threw is not retried: `painted` is unchanged on that path, so
|
||
// every later event re-ran the same failure forever (improvise-7gf8).
|
||
let renderBroken = false;
|
||
|
||
function repaint() {
|
||
if (editing || renderBroken) return;
|
||
let model;
|
||
try {
|
||
model = JSON.parse(client.render_model());
|
||
} catch (err) {
|
||
// Tripped, not counted: a render model that cannot be built is a permanent
|
||
// state, and a reload is the only recovery this page has.
|
||
renderBroken = true;
|
||
shell.fail("cannot render the current state — reload the page (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 `help::hint_line`. The page spells no keys
|
||
// itself; see `RenderModel::hint`.
|
||
el.hint.textContent = model.hint;
|
||
paintHelp(model);
|
||
paintMinibuffer(model);
|
||
shell.setModel(model.status);
|
||
el.viewName.textContent = model.view_name;
|
||
// The title bar, off the cache `src/draw.rs::draw_title` folds — one
|
||
// derivation, both front ends (improvise-z754, improvise-5jna).
|
||
el.docName.textContent = documentTitle(
|
||
model.doc_name,
|
||
model.doc_dirty,
|
||
model.doc_stored_as,
|
||
);
|
||
paintPages(model);
|
||
el.grid.replaceChildren(gridTable(model));
|
||
// The flag is the client's answer, not the page's: `C` toggles it and the
|
||
// TUI draws the panel on exactly the same condition (improvise-wsaw).
|
||
el.categories.hidden = !model.category_panel_open;
|
||
el.categories.replaceChildren(categoryList(model));
|
||
paintTiles(model);
|
||
paintPanel(el.views, model.views, viewRow);
|
||
paintPanel(el.formulas, model.formulas, formulaRow);
|
||
|
||
// The selection must be on screen. `#grid` scrolls on its own — ROW_PX and
|
||
// COL_PX under-estimate and group rows are uncharged — so a selection the
|
||
// projection did send can still sit below the fold (improvise-jq8l).
|
||
el.grid
|
||
.querySelector("td.selected")
|
||
?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||
|
||
// What the frame costs, so the NEXT viewport announcement is honest.
|
||
// Inline group ROWS are deliberately NOT counted — context/code/clients.md.
|
||
const next = frameCostOf(model);
|
||
const changed = next.rows !== frameCost.rows || next.cols !== frameCost.cols;
|
||
frameCost = next;
|
||
if (changed) sendViewport();
|
||
}
|
||
|
||
/** The bottom-bar prompt. `null` is "no text-entry mode is open", decided in
|
||
* `render.rs`, so the page asks nothing about the mode (improvise-fdt6). */
|
||
function paintMinibuffer(model) {
|
||
const mb = model.minibuffer;
|
||
el.minibuffer.hidden = mb === null || mb === undefined;
|
||
if (el.minibuffer.hidden) return;
|
||
el.minibuffer.className = mb.color.toLowerCase();
|
||
el.minibuffer.textContent = mb.prompt + mb.value;
|
||
}
|
||
|
||
/** The help overlay. An empty `help` is what says it is closed -- the page
|
||
* asks no question about the mode, and writes none of the content: the rows,
|
||
* their keys and the page titles are `improvise_protocol::help`'s, the same
|
||
* ones the TUI draws (improvise-ktv1). */
|
||
function paintHelp(model) {
|
||
el.help.hidden = model.help.length === 0;
|
||
if (el.help.hidden) {
|
||
el.help.replaceChildren();
|
||
return;
|
||
}
|
||
|
||
const tabs = document.createElement("div");
|
||
tabs.className = "help-tabs";
|
||
model.help_titles.forEach((title, i) => {
|
||
const tab = document.createElement("span");
|
||
tab.textContent = title;
|
||
tab.className = i === model.help_page ? "help-tab active" : "help-tab";
|
||
tabs.append(tab);
|
||
});
|
||
|
||
const body = document.createElement("dl");
|
||
body.className = "help-body";
|
||
for (const row of model.help) {
|
||
const keys = document.createElement("dt");
|
||
keys.textContent = row.keys;
|
||
keys.className = `help-${row.kind}`;
|
||
body.append(keys);
|
||
// A prose row has no description and spans both columns (its class says
|
||
// so); emitting an empty `dd` beside it would leave a blank grid cell.
|
||
if (row.description) {
|
||
const desc = document.createElement("dd");
|
||
desc.textContent = row.description;
|
||
body.append(desc);
|
||
}
|
||
}
|
||
|
||
el.help.replaceChildren(tabs, body);
|
||
}
|
||
|
||
/** The page bar: which item each fixed category is fixed to. Without it a
|
||
* grid filtered to Q1 says nothing at all, and `[`/`]` move it in silence
|
||
* (improvise-p7lh). An empty list is what says there are none. */
|
||
function paintPages(model) {
|
||
const entries = pageEntries(model);
|
||
el.pages.hidden = entries.length === 0;
|
||
el.pages.replaceChildren(
|
||
...entries.map((entry) => {
|
||
const span = document.createElement("span");
|
||
span.className = "page-axis";
|
||
// Only a Page axis is navigable; a Filter one constrains without being
|
||
// something `[`/`]` can move (gotcha 27).
|
||
span.classList.toggle("navigable", entry.navigable);
|
||
span.textContent = entry.position
|
||
? `${entry.text} (${entry.position})`
|
||
: entry.text;
|
||
return span;
|
||
}),
|
||
);
|
||
}
|
||
|
||
/** The grid, painted from a plan in which every decision is already made
|
||
* (improvise-anr9, and `shell.js::gridPlan`). Nothing below decides anything:
|
||
* 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 plan = gridPlan(model);
|
||
const table = document.createElement("table");
|
||
const head = table.createTHead();
|
||
const corner = () => {
|
||
const th = document.createElement("th");
|
||
th.colSpan = plan.cornerSpan;
|
||
return th;
|
||
};
|
||
|
||
// The column-group line, when there is one. An empty `groups` means this
|
||
// grid has no column groups at all -- not that every group is blank.
|
||
if (plan.groups.length > 0) {
|
||
const groups = head.insertRow();
|
||
groups.appendChild(corner());
|
||
for (const group of plan.groups) {
|
||
const th = document.createElement("th");
|
||
th.colSpan = group.span;
|
||
th.textContent = group.text;
|
||
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 plan.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();
|
||
for (const row of plan.body) {
|
||
const tr = body.insertRow();
|
||
if (row.kind === "group") {
|
||
const th = document.createElement("th");
|
||
th.colSpan = row.colSpan;
|
||
th.textContent = row.text;
|
||
th.classList.add("group");
|
||
tr.appendChild(th);
|
||
continue;
|
||
}
|
||
|
||
for (const label of row.labels) {
|
||
const header = document.createElement("th");
|
||
header.scope = "row";
|
||
header.textContent = label;
|
||
tr.appendChild(header);
|
||
}
|
||
|
||
for (const cell of row.cells) {
|
||
const td = tr.insertCell();
|
||
td.textContent = cell.text;
|
||
for (const [name, on] of Object.entries(cell.classes)) {
|
||
td.classList.toggle(name, on);
|
||
}
|
||
// The plan's own answers, CARRIED rather than re-derived: `row` counts
|
||
// data rows, which walking the table back up would get wrong (b8ot).
|
||
Object.assign(td.dataset, cellAttrs(cell));
|
||
}
|
||
}
|
||
|
||
// The totals strip, PROJECTED: the sum runs over every row including the
|
||
// ones this viewport scrolled past, so nothing here adds anything up.
|
||
if (plan.totals) {
|
||
const foot = table.createTFoot();
|
||
const tr = foot.insertRow();
|
||
const label = document.createElement("th");
|
||
label.scope = "row";
|
||
label.colSpan = plan.totals.span;
|
||
label.textContent = plan.totals.label;
|
||
tr.appendChild(label);
|
||
for (const cell of plan.totals.cells) {
|
||
const td = tr.insertCell();
|
||
td.textContent = cell.text;
|
||
for (const [name, on] of Object.entries(cell.classes)) {
|
||
td.classList.toggle(name, on);
|
||
}
|
||
td.classList.add("total");
|
||
}
|
||
}
|
||
return table;
|
||
}
|
||
|
||
/** 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;
|
||
// `seed` is `RenderCell::edit_text` -- the STORED value, not the drawn one:
|
||
// `td.textContent` has been through the number format.
|
||
const original = seed;
|
||
// What was on the screen, kept so closing can put it back: `close` no longer
|
||
// always repaints, so restoring `seed` would leave `1200` where `1,200` was.
|
||
const drawn = td.textContent;
|
||
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.
|
||
let closed = false;
|
||
// `close` does NOT repaint: a caller about to open another editor on a live
|
||
// <td> must not have the table rebuilt under it (improvise-0x9u).
|
||
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(drawn);
|
||
painted = -1;
|
||
};
|
||
const dismiss = (commit) => {
|
||
close(commit);
|
||
repaint();
|
||
};
|
||
editing = { row, col, close };
|
||
|
||
// These two stay DIRECT while the five surfaces above are delegated: one
|
||
// transient node, one at a time, and `blur` does not bubble (improvise-b8ot).
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") dismiss(true);
|
||
else if (e.key === "Escape") dismiss(false);
|
||
// The editor owns the keyboard while it is open; the page-level handler
|
||
// below must not also see these.
|
||
e.stopPropagation();
|
||
});
|
||
// Removing the focused <input> fires `blur` SYNCHRONOUSLY, so a caller that
|
||
// already closed this editor must not have the table rebuilt from inside its
|
||
// own `close` — that is what detached the <td> in improvise-0x9u's repair.
|
||
input.addEventListener("blur", () => {
|
||
if (!closed) dismiss(false);
|
||
});
|
||
}
|
||
|
||
/** A side panel. An empty row list is what says it is closed — the page asks
|
||
* no question about the mode, exactly as `paintHelp` does not. */
|
||
function paintPanel(host, rows, build) {
|
||
host.hidden = rows.length === 0;
|
||
const list = document.createElement("ul");
|
||
rows.forEach((row, index) => {
|
||
const li = build(row);
|
||
li.dataset.index = String(index);
|
||
list.appendChild(li);
|
||
});
|
||
host.replaceChildren(list);
|
||
}
|
||
|
||
function viewRow(row) {
|
||
const li = document.createElement("li");
|
||
li.classList.toggle("selected", row.selected);
|
||
li.classList.toggle("active", row.active);
|
||
// The active marker is the TUI's, and the axis line is the shared
|
||
// derivation's — the page spells neither the summary nor the axis letters.
|
||
const name = document.createElement("span");
|
||
name.className = "name";
|
||
name.textContent = (row.active ? "▶ " : " ") + row.name;
|
||
li.append(name);
|
||
if (row.axes) {
|
||
const axes = document.createElement("span");
|
||
axes.className = "axes";
|
||
axes.textContent = row.axes;
|
||
li.append(axes);
|
||
}
|
||
return li;
|
||
}
|
||
|
||
function formulaRow(row) {
|
||
const li = document.createElement("li");
|
||
li.classList.toggle("selected", row.selected);
|
||
li.textContent = row.text;
|
||
return li;
|
||
}
|
||
|
||
/** The tile bar. `selected` is only ever true in tile-select mode, which is
|
||
* decided in Rust. The strip is always laid out — context/code/clients.md. */
|
||
function paintTiles(model) {
|
||
const tiles = model.tiles.map((tile, index) => {
|
||
const span = document.createElement("span");
|
||
span.className = "tile";
|
||
span.classList.toggle("selected", tile.selected);
|
||
span.textContent = `${tile.category} ${tile.axis}`;
|
||
span.dataset.index = String(index);
|
||
return span;
|
||
});
|
||
el.tiles.replaceChildren(...tiles);
|
||
}
|
||
|
||
function categoryList(model) {
|
||
const list = document.createElement("ul");
|
||
model.categories.forEach((row, index) => {
|
||
const li = document.createElement("li");
|
||
li.className = row.is_category ? "category" : "item";
|
||
li.classList.toggle("selected", row.selected);
|
||
// `indicator`, not a second pair of triangles: this inlined U+25B8/U+25BE
|
||
// where the TUI and the page's own group headers draw U+25B6/U+25BC.
|
||
li.textContent = row.is_category
|
||
? `${indicator(!row.expanded)} ${row.label} (${row.item_count}) ${row.axis}`
|
||
: row.label;
|
||
li.dataset.index = String(index);
|
||
list.appendChild(li);
|
||
});
|
||
return list;
|
||
}
|
||
|
||
/** Send what a `click_*` call returned, and repaint. `null` means the click
|
||
* resolved to nothing, which is an answer and not a failure. */
|
||
function send(frame) {
|
||
if (frame) transport.send(frame);
|
||
repaint();
|
||
}
|
||
|
||
// The pointer. ONE handler per surface, on a root OUTSIDE what is redrawn:
|
||
// `replaceChildren` replaces an element's children, so these outlive every
|
||
// repaint (user ruling, improvise-b8ot). See context/code/clients.md.
|
||
|
||
/** A surface whose rows are named by one index. */
|
||
function delegateIndex(root, selector, act) {
|
||
root.addEventListener("click", (event) => {
|
||
const node = event.target.closest(selector);
|
||
if (!node || !root.contains(node)) return;
|
||
const index = indexAction(node.dataset);
|
||
if (index !== null) send(act(index));
|
||
});
|
||
}
|
||
|
||
delegateIndex(el.categories, "li[data-index]", (i) => client.click_category(i));
|
||
delegateIndex(el.views, "li[data-index]", (i) => client.click_view(i));
|
||
delegateIndex(el.formulas, "li[data-index]", (i) => client.click_formula(i));
|
||
delegateIndex(el.tiles, "span[data-index]", (i) => client.click_tile(i));
|
||
|
||
// MOUSEDOWN, not click, and deliberately: from an editing state the input's
|
||
// blur repaints first and the <td> is gone before `click` could fire
|
||
// (improvise-0x9u). Delegation does not change that — the target is detached.
|
||
el.grid.addEventListener("mousedown", (event) => {
|
||
const td = event.target.closest("td[data-row]");
|
||
// Defaulted away only once this really is a cell: a header had no listener
|
||
// before, so preventing its default would be a new behaviour.
|
||
if (!td || !el.grid.contains(td)) return;
|
||
// `editable` is the class the plan already set; the coordinates are the
|
||
// attributes it already wrote. Nothing here walks the table.
|
||
const action = cellAction(td.dataset, td.classList.contains("editable"));
|
||
if (!action) return;
|
||
event.preventDefault();
|
||
// The live editor closes FIRST, before the click is composed. Order matters.
|
||
if (editing) editing.close(false);
|
||
const frame = client.click_cell(action.row, action.col);
|
||
if (frame) transport.send(frame);
|
||
// Every cell moves the selection; an editable one also opens its editor.
|
||
// NOT repainted in that branch: a repaint would take the <input> away.
|
||
if (action.editable) edit(td, action.row, action.col, action.editText);
|
||
else repaint();
|
||
});
|
||
|
||
// The keyboard. `Client::on_key_event` folds locally where it can; what it
|
||
// cannot answer is sent and does nothing, by design (context/code/clients.md).
|
||
|
||
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. Ctrl/Alt combinations are left alone: they are the browser's.
|
||
if (!e.ctrlKey && !e.altKey) e.preventDefault();
|
||
transport.send(frame);
|
||
}
|
||
repaint();
|
||
});
|
||
|
||
// Start.
|
||
|
||
// ARMED FIRST. `transport.ready` never resolves for a socket that never
|
||
// opens, so arming after it left a hang with no watchdog at all (improvise-vep8).
|
||
shell.arm(
|
||
WS_URL
|
||
? `${WS_URL} never answered — is improvise-ws-server running, and did it send a document?`
|
||
: "the in-page worker did not answer (see console)",
|
||
);
|
||
await transport.ready;
|
||
sendViewport(); // the handshake: the snapshot is built at this extent
|
||
new ResizeObserver(sendViewport).observe(el.grid);
|