Files
improvise/web/app.js
T
Edward Langley a8737f3fa5 One page rule for both ends; the help overlay shows how to page
improvise-5xbq: resolve::page_rows is now the one home of the page-scroll
rule; the TUI's Move calls it instead of repeating visible_rows * 3 / 4.
A lockstep case pages (PgDn/PgUp, Ctrl-d/Ctrl-u) on a non-square window,
8 rows by 3 columns, and pins the TUI's landing rows as well as comparing
host and client, since one home moves both ends together. Both faults
the ticket injected (page_rows = window_rows / 2; paging by window_cols)
now fail at the first PgDn, before and after the move to one home.

improvise-1c5m: the help overlay draws the mode's own hint (hint_line,
the string #hint carries) in a sticky footer, so a narrow window whose
app bar wraps under #help still says how to page. Playwright case at
600px, red at base, and red again with the footer made non-sticky.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 07:41:03 -07:00

816 lines
31 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
createToasts,
documentTitle,
extentOf,
frameCostOf,
gridPlan,
indexAction,
indicator,
isFatal,
MENU_ARROW,
menuKey,
NOTICE_KEY,
pageItemAction,
noticeOpen,
noticeState,
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"),
pageMenu: document.getElementById("page-menu"),
mode: document.getElementById("mode"),
minibuffer: document.getElementById("minibuffer"),
toast: document.getElementById("toast"),
hint: document.getElementById("hint"),
help: document.getElementById("help"),
status: document.getElementById("status"),
viewName: document.getElementById("view-name"),
docName: document.getElementById("doc-name"),
notice: document.getElementById("backend-notice"),
};
// The deployment notice, and the one thing this page remembers about its
// visitor. `localStorage` THROWS in a private window and in a context with
// site data blocked, and returns null having forgotten — so every access is
// guarded and the absent answer is the default, not a third state. What is
// stored is per-viewer and per-origin: it never reaches Rust, never travels
// to a peer and never reaches another tab's document.
const remembered = (key) => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
const remember = (key, value) => {
try {
localStorage.setItem(key, value);
} catch {
// A viewer whose browser will not remember still gets a working page: the
// disclosure stays operable, it just re-opens at the default next time.
}
};
el.notice.open = noticeOpen(remembered(NOTICE_KEY));
// `toggle` fires for the keyboard and the pointer alike, because the opening
// is the BROWSER's: this listener records an answer rather than making one.
el.notice.addEventListener("toggle", () =>
remember(NOTICE_KEY, noticeState(el.notice.open)),
);
await init();
const client = new Client();
// The document's own messages, out of flow and on a timer. `status_msg` is
// STATE, so a repaint re-offers it: the identity check is in `createToasts`.
const toasts = createToasts({
onShow: (text) => {
el.toast.textContent = text;
el.toast.hidden = false;
},
// The text is deliberately LEFT in place — see index.html.
onHide: () => {
el.toast.hidden = true;
},
});
// A peer that never answers the handshake, and the two facts the page shows.
// Two SINKS, never joined: the transport's is `#status` and lasts, the
// document's is a toast. Both are decided in shell.js; this is the wire.
const shell = createShell({
onShell: (text) => {
el.status.textContent = text;
// The line never wraps, so a long peer address ellipsises. The whole of
// it stays reachable rather than being lost to the cap.
el.status.title = text;
},
onModel: (text) => toasts.offer(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);
});
// Not yet a claim that it works: the first frame makes that (improvise-i0v9).
shell.setShell(`starting the 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;
// The bar is about to be rebuilt, so the chip this menu is anchored to is
// gone. Closing is the honest answer; re-anchoring would be a guess.
closePageMenu();
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);
}
}
// The mode's own hint again, as the TUI popup's footer: once the app bar
// wraps, the overlay covers `#hint` (improvise-1c5m).
const footer = document.createElement("p");
footer.className = "help-footer";
footer.textContent = model.hint;
el.help.replaceChildren(tabs, body, footer);
}
/** 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);
pageAxes = entries;
el.pages.hidden = entries.length === 0;
el.pages.replaceChildren(...entries.map(pageChip));
}
/** One page chip. A navigable axis is a <button> carrying the arrow, because
* it opens a menu; a Filter axis is a <span> with neither (improvise-rw6l). */
function pageChip(entry) {
const label = entry.position
? `${entry.text} (${entry.position})`
: entry.text;
if (!entry.navigable) {
// Only a Page axis is navigable; a Filter one constrains without being
// something `[`/`]` or a menu can move (gotcha 27).
const span = document.createElement("span");
span.className = "page-axis";
span.textContent = label;
return span;
}
const button = document.createElement("button");
button.type = "button";
button.className = "page-axis navigable";
button.dataset.axis = String(entry.index);
button.setAttribute("aria-haspopup", "listbox");
const text = document.createElement("span");
text.textContent = label;
const arrow = document.createElement("span");
arrow.className = "page-arrow";
arrow.setAttribute("aria-hidden", "true");
arrow.textContent = MENU_ARROW;
button.append(text, arrow);
return button;
}
// The open page menu: which axis it belongs to and which row the keyboard is
// on. `null` is closed, exactly as `editing` is.
let pageAxes = [];
let pageMenu = null;
function openPageMenu(axis, anchor) {
const entry = pageAxes[axis];
// A Filter axis carries no items, so there is nothing to open — the chip is
// not a button either, and both answers come off the same `cycle`.
if (!entry || entry.items.length === 0) return;
const active = entry.items.findIndex((item) => item.selected);
pageMenu = { axis, active: active === -1 ? 0 : active };
const box = anchor.getBoundingClientRect();
el.pageMenu.style.left = `${Math.round(box.left)}px`;
el.pageMenu.style.top = `${Math.round(box.bottom + 2)}px`;
paintPageMenu();
}
function closePageMenu() {
pageMenu = null;
el.pageMenu.hidden = true;
el.pageMenu.replaceChildren();
}
function paintPageMenu() {
if (!pageMenu) return;
const entry = pageAxes[pageMenu.axis];
el.pageMenu.hidden = false;
el.pageMenu.replaceChildren(
...entry.items.map((item) => {
const row = document.createElement("button");
row.type = "button";
row.className = "page-item";
row.setAttribute("role", "option");
// `selected` is which item the DOCUMENT is fixed to; `active` is where
// the keyboard is. They start together and part on the first arrow key.
row.setAttribute("aria-selected", String(item.selected));
row.classList.toggle("selected", item.selected);
row.classList.toggle("active", item.item === pageMenu.active);
row.dataset.axis = String(entry.index);
row.dataset.item = String(item.item);
row.textContent = item.selected ? `✓ ${item.label}` : ` ${item.label}`;
return row;
}),
);
}
/** Pick an item and close. The value that comes back is the server's: nothing
* here moves the page bar, exactly as an editor does not move a cell. */
function pickPageItem(axis, item) {
closePageMenu();
send(client.click_page_item(axis, item));
}
/** 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;
// WHICH cell, captured now: rows renumber while the editor is open, so the
// position is not an address by commit time (improvise-2wu3).
const target = client.edit_target(row, col);
if (!target) 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(target, input.value);
// null only before any basis arrived; 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. `null` is what says it is closed, and `[]` is open and empty:
* two facts, never inferred from a count (improvise-df2w). */
function paintPanel(host, rows, build) {
host.hidden = rows === null;
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));
});
}
// The page bar and its menu are two surfaces and ONE meaning: an action with
// an axis and maybe an item, decided in `shell.js` (improvise-rw6l, b8ot).
for (const root of [el.pages, el.pageMenu]) {
root.addEventListener("click", (event) => {
const node = event.target.closest("[data-axis]");
if (!node || !root.contains(node)) return;
const action = pageItemAction(node.dataset);
if (!action) return;
// A live editor closes first, before the click is composed — the order
// the grid's own handler keeps, and for the same reason.
if (editing) editing.close(false);
if (action.kind === "pick") pickPageItem(action.axis, action.item);
else if (pageMenu && pageMenu.axis === action.axis) closePageMenu();
else openPageMenu(action.axis, node);
});
}
// A click anywhere else dismisses it. `pointerdown` rather than `click`, so a
// press that lands on the grid closes this before the grid acts on it.
window.addEventListener("pointerdown", (event) => {
if (!pageMenu) return;
if (el.pages.contains(event.target) || el.pageMenu.contains(event.target)) return;
closePageMenu();
});
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).
/** The menu's own keys, decided in `shell.js`. Every one of them is claimed:
* an unhandled key must not reach the grid behind the menu. */
function pageMenuKeydown(e) {
// Ctrl and Alt combinations are the browser's, exactly as below, so the menu
// neither acts on one nor claims it — `Ctrl-j` is not `j` (improvise-rw6l).
if (e.ctrlKey || e.altKey) return;
const items = pageAxes[pageMenu.axis].items;
const action = menuKey(e.key, { count: items.length, active: pageMenu.active });
if (action.kind === "close") closePageMenu();
else if (action.kind === "pick") pickPageItem(pageMenu.axis, action.item);
else if (action.kind === "move") {
pageMenu.active = action.item;
paintPageMenu();
}
// Every unmodified key is CLAIMED, `ignore` included: the grid is behind
// the menu and must not also act on it.
e.preventDefault();
}
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;
// …and the page menu owns the keyboard too, which is the third state of
// that kind: `hjkl` reaching the grid would scroll it out from under it.
if (pageMenu) {
pageMenuKeydown(e);
return;
}
// The notice's <summary> is a real control and owns Enter and Space while
// it has focus. Claiming them here would call `preventDefault` on the
// browser's own disclosure toggle, so a keyboard visitor could focus it and
// not open it — which is the whole affordance.
if (el.notice.contains(e.target)) 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)",
{ answer: WS_URL ? null : `in-page worker · ${DOC}` },
);
await transport.ready;
sendViewport(); // the handshake: the snapshot is built at this extent
new ResizeObserver(sendViewport).observe(el.grid);