oficina / sheets Public

Merge branch 'fix/cell-editing' into main

Fix browser editing and menus; add column auto-fit
paul@keymail.dev 992d6b8e249f56ef70fdd3d41530848aa5ead142 parent e4fb2fcparent 95ba600
18 files changed, +690 −190
  • docs/superpowers/specs/2026-09-16-browser-cell-editing.md +28 −0
  • docs/superpowers/specs/2026-09-16-browser-menus-autofit.md +41 −0
  • internal/sheets/browser_menu_test.go +41 −0
  • internal/sheets/static/autofit.js +76 −0
  • internal/sheets/static/clipboard.js +63 −146
  • internal/sheets/static/commands.js +1 −0
  • internal/sheets/static/formulabar.js +2 −0
  • internal/sheets/static/formulapick.js +83 −0
  • internal/sheets/static/menubar.js +19 −2
  • internal/sheets/static/sheets.css +13 −4
  • internal/sheets/static/sizes.js +23 −17
  • internal/sheets/templates/sheet.html +3 −0
  • tools/drive/autofit.mjs +73 −0
  • tools/drive/batch-checks.mjs +11 −14
  • tools/drive/canary-batch.mjs +2 −5
  • tools/drive/clipboard.mjs +4 −2
  • tools/drive/editing.mjs +105 −0
  • tools/drive/menu-actions.mjs +102 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-09-16-browser-cell-editing.md b/docs/superpowers/specs/2026-09-16-browser-cell-editing.md
new file mode 100644
index 0000000..02c8b89
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-16-browser-cell-editing.md
@@ -0,0 +1,28 @@
+# Browser cell editing
+
+Requested by Paul, 2026-09-16: editing text should match the cell; Cut should
+leave the source in place until Paste; formula entry should accept cell and
+range selection. This changes browser interaction, not server calculation.
+
+- The in-cell editor inherits the cell's font, alignment, and colours.
+- Cut remembers a pending move. Paste writes destinations and clears only
+ non-overlapping source coordinates in one atomic batch. Failed paste keeps
+ the source. Escape, another copy/cut, or leaving the window cancels the
+ pending move. A changed source, out-of-bounds move, or a move exceeding the
+ existing 500-write atomic limit is refused without deleting anything.
+- Formula entry accepts a click or drag where an operand belongs (after `=`,
+ an operator, `(`, or a separator), or over selected formula text after `=`.
+ It inserts an A1 reference without changing the cell being edited. Enter
+ commits normally and Escape discards the edit. The same selection works in
+ the formula bar. References can also be typed directly.
+
+Validation: `tools/drive/editing.mjs` drives the real vendored grid in Chromium
+with a mocked transport. It covers font inheritance, deferred and overlapping
+cuts, rejected/retried paste, cancellation, changed source, sheet bounds,
+formula range drag, formula-bar insertion, and formula cancellation.
+The existing deployed browser scripts have been updated for deferred Cut.
+
+Source review: GPT-6 Astra, 2026-09-16. Findings about native text-field copy
+cancellation and selection of an entire existing formula were fixed and
+covered by regression checks. Deployed-canary review remains required before
+approval/merge under AGENTS.md; these component checks do not replace it.
diff --git a/docs/superpowers/specs/2026-09-16-browser-menus-autofit.md b/docs/superpowers/specs/2026-09-16-browser-menus-autofit.md
new file mode 100644
index 0000000..3493668
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-16-browser-menus-autofit.md
@@ -0,0 +1,41 @@
+# Browser menus and column auto-fit
+
+Requested by Paul, 2026-09-16: fix File → New sheet, audit other inert menu
+items, and support fitting one or several columns by double-clicking a divider.
+
+The New sheet form and server route were already wired. Reproduced with actual
+server-rendered markup/assets: Chromium submitted it, WebKit did not. WebKit
+blurs a menu summary without focusing the clicked button; the focusout handler
+closed the menu before click could activate the button. Keep that menu open
+when the pointer is activating an item inside it. Keyboard departures, Escape,
+and outside clicks continue to close it.
+
+The audit also found grid keyboard handling swallowing native menu activation,
+and menu arrow handling intercepting number-field stepping. Menu keys remain
+inside the menu; resize fields retain their native editing/submission behavior.
+
+Column auto-fit measures displayed content with each cell's rendered font,
+letter spacing, padding, and borders. It includes loaded rows below the viewport
+and header labels. A double-click on the existing right-edge resize handle fits
+that column, or each column in the current selection when it includes that
+handle. Format → Auto-fit columns provides the keyboard/menu equivalent.
+Widths are bounded by the existing 16–2000px server limits. Truncated sheets
+report that auto-fit measures only loaded grid contents. A failed write stops
+the remaining columns. Multi-column manual resizing now persists every column,
+with ordered requests rather than racing full-size-table responses.
+
+Validation:
+
+- `TestBrowserMenuFixture` exports a synthetic server-rendered page and its real
+ linked assets, without session cookies, for browser component checks.
+- `tools/drive/menu-actions.mjs` activates every menu link, form, command and
+ formatting choice in Chromium and WebKit against mocked transport. It also
+ checks keyboard submission, resize stepping, Escape and outside clicks.
+- `tools/drive/autofit.mjs` exercises fresh-grid double-click, selected headers,
+ offscreen formatted values, independent widths, persisted widths on reload,
+ manual group resizing, formula display values, bounds and failed-write retry.
+
+Source review: GPT-6 Astra, 2026-09-16; no remaining blocking findings after
+fixing fresh-grid divider fitting and matching the vendor's resize hit area.
+Deployed-canary review remains outstanding; these checks are component tests,
+not deployed-server approval.
diff --git a/internal/sheets/browser_menu_test.go b/internal/sheets/browser_menu_test.go
new file mode 100644
index 0000000..20d22ae
--- /dev/null
+++ b/internal/sheets/browser_menu_test.go
@@ -0,0 +1,41 @@
+package sheets
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "regexp"
+ "testing"
+)
+
+// Opt-in fixture for tools/drive/menu-actions.mjs: actual server-rendered menus,
+// with a synthetic owner and sheet. No session cookie is exported.
+func TestBrowserMenuFixture(t *testing.T) {
+ path := os.Getenv("SHEETS_MENU_FIXTURE")
+ if path == "" {
+ t.Skip("set SHEETS_MENU_FIXTURE to render the browser fixture")
+ }
+ app, cookies := signIn(t)
+ created := do(t, app, as(postForm("/sheets", url.Values{}), cookies))
+ page := do(t, app, as(httptest.NewRequest(http.MethodGet, created.Header().Get("Location"), nil), cookies))
+ if page.Code != http.StatusOK {
+ t.Fatalf("sheet page: %d", page.Code)
+ }
+ assets := map[string]string{}
+ for _, match := range regexp.MustCompile(`(?:src|href)="(/static/[^" ]+)"`).FindAllStringSubmatch(page.Body.String(), -1) {
+ asset := do(t, app, as(httptest.NewRequest(http.MethodGet, match[1], nil), cookies))
+ if asset.Code != http.StatusOK {
+ t.Fatalf("asset %s: %d", match[1], asset.Code)
+ }
+ assets[match[1]] = asset.Body.String()
+ }
+ data, err := json.Marshal(map[string]any{"html": page.Body.String(), "assets": assets})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/sheets/static/autofit.js b/internal/sheets/static/autofit.js
new file mode 100644
index 0000000..add3598
--- /dev/null
+++ b/internal/sheets/static/autofit.js
@@ -0,0 +1,76 @@
+/* Fit columns to displayed values, using each cell's actual font and padding.
+ * Raw formulas are deliberately not measured: their displayed results are. */
+(function () {
+ 'use strict';
+ var host = document.querySelector('[data-sheet-grid]');
+ if (!host) return;
+ var canvas = document.createElement('canvas');
+ var context = canvas.getContext('2d');
+ var fitting = false;
+
+ function width(el) {
+ if (!el || !el.textContent) return 0;
+ var style = getComputedStyle(el);
+ context.font = style.font || [style.fontStyle, style.fontWeight, style.fontSize, style.fontFamily].join(' ');
+ var spacing = parseFloat(style.letterSpacing) || 0;
+ var widest = 0;
+ el.textContent.split(/\r?\n/).forEach(function (line) {
+ widest = Math.max(widest, context.measureText(line).width + Math.max(0, line.length - 1) * spacing);
+ });
+ return widest + (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0) +
+ (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.borderRightWidth) || 0) + 4;
+ }
+
+ window.SheetsAutoFit = async function (column) {
+ var g = window.SheetsGrid;
+ if (!g || !g.mayWrite || fitting || !context) return;
+ var ws = g.worksheet(), s = g.selection();
+ if (!ws || (!s && typeof column !== 'number')) return;
+ var first = s ? Math.min(s.c, s.c2) : column;
+ var last = s ? Math.max(s.c, s.c2) : column;
+ // A divider outside the selection fits just its own column.
+ if (typeof column === 'number' && (column < first || column > last)) first = last = column;
+ fitting = true;
+ try {
+ if (document.fonts) await document.fonts.ready;
+ if (window.SheetsSizes) await window.SheetsSizes.settled();
+ for (var c = first; c <= last; c++) {
+ var size = Math.max(16, width(ws.headers[c]));
+ for (var r = 0; r < g.rows; r++) {
+ var td = ws.records[r] && ws.records[r][c] && ws.records[r][c].element;
+ size = Math.max(size, width(td));
+ }
+ // The server's existing width bounds also govern auto-fit.
+ if (!await g.post('/size', { col: c, size: Math.min(2000, Math.ceil(size)) })) break;
+ }
+ var surface = host.closest('[data-sheet-surface]');
+ if (surface.dataset.windowTruncated && window.SheetsNote) {
+ window.SheetsNote.say('Columns fitted to the loaded cells; this sheet extends beyond the grid window.', 'warn');
+ }
+ } finally { fitting = false; }
+ };
+
+ // The vendor treats even an unmoved resize click as a group resize. Suppress
+ // that no-op so the two clicks before auto-fit don't first equalize widths.
+ document.addEventListener('mouseup', function () {
+ var g = window.SheetsGrid, ws = g && g.worksheet();
+ var resize = ws && ws.resizing;
+ if (!resize || resize.column === undefined) return;
+ var col = ws.cols[resize.column];
+ if (!col || Number(col.colElement.getAttribute('width')) !== Number(resize.width)) return;
+ host.querySelectorAll('.resizing').forEach(function (el) { el.classList.remove('resizing'); });
+ ws.resizing = null;
+ }, true);
+
+ host.addEventListener('dblclick', function (e) {
+ var td = e.target.closest('thead td[data-x]');
+ if (!td) return;
+ var rect = td.getBoundingClientRect();
+ var col = Number(td.dataset.x);
+ // Match the vendor's resize handle so mousedown preserves the selection.
+ if (rect.right - e.clientX > 6) return;
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ window.SheetsAutoFit(col);
+ }, true);
+}());
diff --git a/internal/sheets/static/clipboard.js b/internal/sheets/static/clipboard.js
index 0623c09..254b18a 100644
--- a/internal/sheets/static/clipboard.js
+++ b/internal/sheets/static/clipboard.js
@@ -1,73 +1,28 @@
-/* Cut, copy and paste.
- *
- * Ours, not the vendored grid's, and that is a fix rather than a
- * preference. Measured in a real browser on 2026-09-03:
- *
- * - the Copy menu item threw inside the library and did nothing;
- * - the Cut menu item silently did nothing;
- * - there was no Paste anywhere;
- * - and the library's own Ctrl+V DROPPED CELLS: copying A1:B3 and
- * pasting at F1 lost F3. The survivors arrived through onchange and
- * the casualties were written by a path that does not fire it, so
- * they never reached the server — while the grid painted them
- * anyway, which is silent data loss that looks fine until a reload.
- *
- * So this module takes the keydown AND the three clipboard events at
- * the capture phase, and every cell it writes goes through the atomic batch
- * route. tsv.js is the wire format and cellqueue.js
- * sends the writes.
- *
- * It also carries spec §4.2's translation pass. A pasted formula names
- * the cell it came from, and the server offsets its relative references
- * — so pasting `=A1*2` one row down stores `=A2*2`, which is what
- * copying a formula has always meant.
- */
+/* Clipboard events and menu commands share atomic cell writes. */
(function () {
'use strict';
var surface = document.querySelector('[data-sheet-surface]');
if (!surface) return;
- /* Keep a paste within one atomic batch. Raising this cap needs a
- * separate decision about failures across multiple chunks. */
var MAX_CELLS = 500;
function ready() {
var g = window.SheetsGrid;
if (!g) return;
- /* note.js. This module needs to talk because the menu has a Paste
- * item and the menu cannot always paste: reading the clipboard from
- * a button is a permission the browser may refuse, where Ctrl+V
- * hands us the data with no permission at all. Told that, a person
- * presses Ctrl+V; not told, the menu item is exactly the dead
- * control this app has a rule against. */
function say(text, tone) {
if (window.SheetsNote) window.SheetsNote.say(text, tone);
}
- /* lastCopy is how a paste knows where it came from.
- *
- * The clipboard carries text and no provenance, so the origin is
- * remembered here and claimed only on an EXACT match of the text we
- * put there. Anything else — a paste from another app, another
- * tab, or a clipboard that has moved on — has no origin and is
- * pasted verbatim, which is right: text from outside has no
- * references to translate. */
- var lastCopy = null;
+ // Copy offsets relative formulas; Cut moves raw formulas without translation.
+ // Text equality is only provenance within this focused window.
+ var lastCopy = null, pendingCut = null;
- /* Forgotten when the window loses focus. That is the moment the
- * clipboard can have been replaced by another application, and
- * after it an identical string is no longer evidence of anything.
- *
- * It narrows the window rather than closing it: two cells holding
- * the same text, copied here and then copied again from elsewhere
- * without leaving the tab, still match. The real fix is a custom
- * clipboard MIME type carrying the origin, which is what Google
- * Sheets does and what the spec's deferred table now names. Raised
- * by the branch review, which is right that exact equality reduces
- * false provenance without establishing it. */
- window.addEventListener('blur', function () { lastCopy = null; });
+ window.addEventListener('blur', function () { lastCopy = null; pendingCut = null; });
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') pendingCut = null;
+ }, true);
function box() {
var s = g.selection();
@@ -78,19 +33,14 @@
};
}
- /* The TSV codec is tsv.js. Resolved once so no call site guards,
- * and so a module that failed to load costs the clipboard rather
- * than throwing on the first copy. */
var TSV = window.SheetsTSV;
if (!TSV) return;
- /* cellqueue.js sends atomic batches in order and stops on failure. */
var writeAll = window.SheetsCellQueue;
if (!writeAll) return;
- /* ---- the commands ------------------------------------------------ */
-
function copyText() {
+ pendingCut = null;
var b = box();
if (!b) return null;
var text = TSV.encode(b, g.rawAt);
@@ -98,20 +48,19 @@
return text;
}
- function clearBox(b) {
- var jobs = [], r, c;
- for (r = b.r1; r <= b.r2; r++) {
- for (c = b.c1; c <= b.c2; c++) {
- if (g.rawAt(r, c) !== '') jobs.push({ r: r, c: c, raw: '' });
- }
- }
- writeAll(jobs, g.post);
+ // Cut is a pending move. Nothing is deleted until the atomic paste succeeds.
+ function rememberCut(text, b) {
+ lastCopy = null;
+ pendingCut = { text: text, box: b };
}
function paste(text) {
+ if (!g.mayWrite) return;
var b = box();
if (!b || !text) return;
var rows = TSV.decode(text);
+ var move = pendingCut && pendingCut.text === text ? pendingCut : null;
+ if (!move) pendingCut = null;
var from = (lastCopy && lastCopy.text === text) ? lastCopy : null;
var count = rows.length * (rows[0] ? rows[0].length : 0);
if (count > MAX_CELLS) {
@@ -125,37 +74,51 @@
c = b.c1 + j;
if (r >= g.rows || c >= g.cols) { dropped++; continue; }
cell = { r: r, c: c, raw: rows[i][j] };
- /* The origin is the SOURCE cell for this destination, so the
- * server's offset is the same for every cell in the block —
- * which is what makes a pasted block's formulas agree with
- * each other. */
+
if (from) { cell.fromR = from.r + i; cell.fromC = from.c + j; }
jobs.push(cell);
}
}
- writeAll(jobs, g.post, function () {
+ if (move) {
+ if (move.busy) return;
+ if (dropped) {
+ say('This move extends beyond the sheet. Nothing was changed.', 'warn');
+ return;
+ }
+ var source = move.box;
+ for (r = source.r1; r <= source.r2; r++) {
+ for (c = source.c1; c <= source.c2; c++) {
+ // Destinations win when the source and destination overlap.
+ if (r < b.r1 || r >= b.r1 + rows.length ||
+ c < b.c1 || c >= b.c1 + rows[0].length) {
+ jobs.push({ r: r, c: c, raw: '' });
+ }
+ }
+ }
+ if (jobs.length > MAX_CELLS) {
+ say('This move exceeds the 500-cell atomic batch limit. Nothing was changed.', 'warn');
+ return;
+ }
+ move.busy = true;
+ }
+ function post(path, body) {
+ if (move && TSV.encode(move.box, g.rawAt) !== move.text) {
+ say('The cut cells changed. Cut them again before moving them.', 'warn');
+ return Promise.resolve(false);
+ }
+ return g.post(path, body);
+ }
+ writeAll(jobs, post, function () {
+ if (pendingCut === move) pendingCut = null;
g.focus();
- /* Cells that fell outside the grid's window are SAID, not
- * silently discarded. A paste that quietly loses its last four
- * rows is the same failure this module was written to remove,
- * arriving through a different door. */
+
if (dropped) {
say(dropped + (dropped === 1 ? ' cell' : ' cells') +
' fell outside this sheet and were not pasted.', 'warn');
}
- });
+ }).then(function () { if (move) move.busy = false; });
}
- /* ---- the events --------------------------------------------------
- *
- * Capture, on document, so this runs before the vendored grid's own
- * document-level handlers and can stop them. stopImmediatePropagation
- * and not stopPropagation: the library may well have registered on
- * the same node, and only the first stops a listener sharing it.
- *
- * A text field is left entirely alone. Ctrl+C in the formula bar
- * belongs to the formula bar, and taking it would make the one
- * reliable way to read a formula unable to copy one. */
function inTextField(el) {
return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' ||
el.isContentEditable);
@@ -164,10 +127,7 @@
function own(e) {
if (inTextField(e.target)) return false;
if (!e.clipboardData) return false;
- /* Nothing selected in the grid means this event is not ours —
- * somebody is copying the page's own text. Checked BEFORE
- * cancelling, because cancelling first and finding out after
- * swallows a perfectly ordinary Ctrl+C over a heading. */
+
if (!g.selection()) return false;
var pageSel = window.getSelection && window.getSelection();
if (pageSel && !pageSel.isCollapsed) return false;
@@ -176,32 +136,7 @@
return true;
}
- /* The library's own Ctrl+C/X/V, taken away from it.
- *
- * This is the fix for the bug that made the three handlers below
- * dead for anybody using a mouse, and it is worth writing down in
- * full because the module looked correct and tested green.
- *
- * The vendored grid registers keyDownControls on `document` and
- * acts on 67/88/86 whenever `jspreadsheet.current` is set — which a
- * mousedown inside a worksheet does. It calls its own copy(), which
- * moves focus into a hidden <textarea>, writes the DISPLAY strings
- * there and runs execCommand('copy'); then it calls preventDefault
- * on the keydown, so the browser never fires the native copy event
- * these listeners are built on. Measured: click B1 (holding
- * `=A1*2`), press Ctrl+C, and the clipboard holds "20".
- *
- * The drive script did not catch it because it never CLICKED a
- * cell — every selection went through updateSelectionFromCoords and
- * every menu click was a mousedown outside the grid, which nulls
- * `current`. So it measured the one state a person with a mouse is
- * never in. Found by review, then reproduced.
- *
- * Capture on document runs before a bubble listener on document, so
- * this gets there first. It deliberately does NOT preventDefault:
- * the whole point is to let the browser's own clipboard action
- * proceed, so the native copy/cut/paste event fires and the
- * handlers below receive a real clipboardData. */
+ // Stop the grid's keyboard handler, but allow native clipboard events.
document.addEventListener('keydown', function (e) {
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
var k = e.key.toLowerCase();
@@ -211,27 +146,26 @@
e.stopImmediatePropagation();
}, true);
+ // Even copying inside a text field cancels a previous pending move.
document.addEventListener('copy', function (e) {
+ pendingCut = null;
+ lastCopy = null;
if (!own(e)) return;
var text = copyText();
if (text !== null) e.clipboardData.setData('text/plain', text);
}, true);
document.addEventListener('cut', function (e) {
+ pendingCut = null;
+ lastCopy = null;
if (!own(e)) return;
if (!g.mayWrite) return;
var text = copyText(), b = box();
if (text === null) return;
- /* A cut forgets where it came from, and that is spec §4.2 rather
- * than an omission: "Cut-paste a cell — the cell keeps its
- * (row_id, col_id) key and its CONTENT MOVES." A moved formula
- * points where it always pointed; only a COPIED one is
- * re-resolved by offset. Leaving lastCopy set here made cut
- * translate, which is Excel's behaviour for copy and nobody's for
- * cut. */
+
lastCopy = null;
e.clipboardData.setData('text/plain', text);
- clearBox(b);
+ rememberCut(text, b);
}, true);
document.addEventListener('paste', function (e) {
@@ -240,26 +174,8 @@
paste(e.clipboardData.getData('text/plain'));
}, true);
- /* ---- the menu ----------------------------------------------------
- *
- * The menu items cannot use the events above — those only fire for a
- * real keystroke — so they go through the async Clipboard API.
- * Writing is allowed from a click; reading may be refused, and that
- * refusal is what `say` exists for. */
window.SheetsClipboard = {
- /* execCommand, NOT navigator.clipboard, and that is why these two
- * items were broken for everybody on the live instance.
- * writeText needs a permission a real browser does not grant, so
- * both rejected; the shortcuts worked throughout, because a
- * keystroke hands us the data for free. The drive script had been
- * granting itself the permission, which is why it was green.
- * Reported against the canary, 2026-09-04.
- *
- * execCommand needs no permission and fires a real copy EVENT —
- * the one the handler above already owns — so the menu takes
- * exactly the keyboard's path and cannot drift from it. Deprecated
- * and universally supported; the vendored grid uses it too. The
- * async API stays as the fallback. */
+
copy: function () {
g.focus();
if (document.execCommand && document.execCommand('copy')) return;
@@ -270,13 +186,14 @@
});
},
cut: function () {
+ if (!g.mayWrite) return;
g.focus();
if (document.execCommand && document.execCommand('cut')) return;
var text = copyText(), b = box();
- lastCopy = null; /* see the cut event handler: §4.2, content moves */
+ lastCopy = null;
if (text === null || !navigator.clipboard) return;
navigator.clipboard.writeText(text).then(function () {
- clearBox(b);
+ rememberCut(text, b);
}, function () {
say('This browser would not let the menu cut. Press Ctrl+X instead.', 'warn');
});
diff --git a/internal/sheets/static/commands.js b/internal/sheets/static/commands.js
index 6dfb90a..1177c5b 100644
--- a/internal/sheets/static/commands.js
+++ b/internal/sheets/static/commands.js
@@ -76,6 +76,7 @@
'fill-down': function () { fillFrom(1, 0); },
'fill-right': function () { fillFrom(0, 1); },
+ 'autofit-columns': function () { if (window.SheetsAutoFit) window.SheetsAutoFit(); },
'select-all': function () {
var ws = g.worksheet();
if (ws && ws.updateSelectionFromCoords) {
diff --git a/internal/sheets/static/formulabar.js b/internal/sheets/static/formulabar.js
index f7a04b8..e22b4db 100644
--- a/internal/sheets/static/formulabar.js
+++ b/internal/sheets/static/formulabar.js
@@ -107,6 +107,8 @@
if (!bar || bar.readOnly) return;
bar.addEventListener('keydown', function (e) {
+ // Keep the grid's document handler from treating bar typing as a cell edit.
+ e.stopPropagation();
var sel = g.selection();
if (!sel) return;
diff --git a/internal/sheets/static/formulapick.js b/internal/sheets/static/formulapick.js
new file mode 100644
index 0000000..594951e
--- /dev/null
+++ b/internal/sheets/static/formulapick.js
@@ -0,0 +1,83 @@
+/* Point at cells while entering a formula, without committing the edited cell.
+ * This inserts references only; calculation remains on the server. */
+(function () {
+ 'use strict';
+ var host = document.querySelector('[data-sheet-grid]');
+ if (!host) return;
+ var drag = null;
+
+ function editor() {
+ var el = document.activeElement;
+ if (!el || !el.matches('input, textarea') || el.readOnly) return null;
+ if (!el.matches('[data-formula-input]') && !host.contains(el)) return null;
+ if (el.value.charAt(0) !== '=' || el.selectionStart === null) return null;
+ return el;
+ }
+ function cell(target) {
+ var td = target.closest && target.closest('td[data-x][data-y]');
+ return td && host.contains(td) ? td : null;
+ }
+ function ref(c, r) {
+ var name = '';
+ for (c++; c; c = Math.floor((c - 1) / 26)) {
+ name = String.fromCharCode(65 + (c - 1) % 26) + name;
+ }
+ return name + (r + 1);
+ }
+ function clear() {
+ host.querySelectorAll('[data-formula-reference]').forEach(function (td) {
+ td.removeAttribute('data-formula-reference');
+ });
+ }
+ function point(td) {
+ var c = Number(td.dataset.x), r = Number(td.dataset.y);
+ var c1 = Math.min(c, drag.c), r1 = Math.min(r, drag.r);
+ var c2 = Math.max(c, drag.c), r2 = Math.max(r, drag.r);
+ var text = ref(c1, r1);
+ if (c1 !== c2 || r1 !== r2) text += ':' + ref(c2, r2);
+ drag.el.value = drag.before + text + drag.after;
+ drag.end = drag.before.length + text.length;
+ drag.el.setSelectionRange(drag.end, drag.end);
+ drag.el.dispatchEvent(new Event('input', { bubbles: true }));
+ clear();
+ host.querySelectorAll('td[data-x][data-y]').forEach(function (item) {
+ if (+item.dataset.x >= c1 && +item.dataset.x <= c2 &&
+ +item.dataset.y >= r1 && +item.dataset.y <= r2) {
+ item.setAttribute('data-formula-reference', '');
+ }
+ });
+ }
+ document.addEventListener('mousedown', function (e) {
+ if (e.button !== 0) return;
+ var el = editor(), td = cell(e.target);
+ if (!el || !td || td.contains(el)) return;
+ var start = el.selectionStart, end = el.selectionEnd;
+ if (start < 1) return;
+ // Point mode begins where an operand belongs, or replaces selected text.
+ // Else leave ordinary clicks alone so a completed formula can be committed.
+ if (start === end && !/[=+\-*/^(,:<>;&]\s*$/.test(el.value.slice(0, start))) return;
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ drag = { el: el, before: el.value.slice(0, start), after: el.value.slice(end),
+ c: Number(td.dataset.x), r: Number(td.dataset.y) };
+ point(td);
+ }, true);
+ document.addEventListener('mousemove', function (e) {
+ if (!drag) return;
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ var td = cell(e.target);
+ if (td) point(td);
+ }, true);
+ document.addEventListener('mouseup', function (e) {
+ if (!drag) return;
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ drag.el.focus();
+ drag.el.setSelectionRange(drag.end, drag.end);
+ drag = null;
+ }, true);
+ document.addEventListener('keydown', function () { if (!drag) clear(); }, true);
+ document.addEventListener('focusout', function () { if (!drag) clear(); });
+ window.addEventListener('blur', function () { drag = null; clear(); });
+}());
diff --git a/internal/sheets/static/menubar.js b/internal/sheets/static/menubar.js
index ba2d976..586e308 100644
--- a/internal/sheets/static/menubar.js
+++ b/internal/sheets/static/menubar.js
@@ -56,7 +56,7 @@
function walk(node) {
Array.prototype.forEach.call(node.children, function (el) {
- if (el.hidden) return;
+ if (el.hidden || el.disabled || el.type === 'hidden') return;
if (el.tagName === 'FORM') { walk(el); return; }
if (el.tagName === 'DETAILS') {
var s = summaryOf(el);
@@ -67,7 +67,7 @@
}
return;
}
- if (el.tagName === 'A' || el.tagName === 'BUTTON') out.push(el);
+ if (el.tagName === 'A' || el.tagName === 'BUTTON' || el.tagName === 'INPUT') out.push(el);
else if (el.children.length) walk(el);
});
}
@@ -117,6 +117,11 @@
var target = e.target;
var menu = ownerMenu(target);
if (!menu) return;
+ // Menu keys must not reach the grid, including native button activation.
+ e.stopPropagation();
+
+ // Resize fields own their cursor, number stepping, and form submission.
+ if (target.matches('input, textarea, select')) { e.stopPropagation(); return; }
var isTop = summaryOf(menu) === target;
var consumed = true;
@@ -194,10 +199,22 @@
* bubble and the focus may be leaving from any depth. The relatedTarget
* check is what distinguishes "moved to another item in this menu"
* from "left the menu entirely". */
+ // WebKit does not focus buttons on pointer activation. Its mousedown can
+ // blur the summary with no relatedTarget, before click/submit happens.
+ // Keep the menu visible through that activation; otherwise its item vanishes
+ // before the click and native form actions (including New sheet) never run.
+ var pointerMenu = null;
+ document.addEventListener('pointerdown', function (e) {
+ pointerMenu = ownerMenu(e.target);
+ }, true);
+ document.addEventListener('pointerup', function () { pointerMenu = null; }, true);
+ document.addEventListener('pointercancel', function () { pointerMenu = null; }, true);
+
bar.addEventListener('focusout', function (e) {
var menu = ownerMenu(e.target);
if (!menu || !menu.open) return;
var to = e.relatedTarget;
+ if (!to && pointerMenu === menu) return;
if (to && menu.contains(to)) return;
menu.open = false;
});
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css
index be9c11d..294690c 100644
--- a/internal/sheets/static/sheets.css
+++ b/internal/sheets/static/sheets.css
@@ -786,8 +786,8 @@ form[data-doc-title]:not(:focus-within) button {
/* The rows the fill handle is being dragged over — the preview of what
* a release would fill, NOT the range a formula is pointing at. There is
- * no formula chrome in this app: grid.js sets `parseFormulas: false`,
- * because our engine computes and the client only paints. The library
+ * separate formula reference chrome below. `parseFormulas: false` keeps
+ * calculation on the server. The library
* adds these classes from updateCopySelection, off `selectedCorner`.
* Vendored as rgba(0,0,0,0.05) — a darkening, which on a dark grid is
* nothing at all. */
@@ -875,8 +875,11 @@ form[data-doc-title]:not(:focus-within) button {
* alone it inherits the browser's own white. */
[data-sheet-grid] .jss_worksheet td input,
[data-sheet-grid] .jss_worksheet td textarea {
- background-color: var(--rst-bg);
- color: var(--rst-text);
+ font: inherit;
+ letter-spacing: inherit;
+ text-align: inherit;
+ background-color: inherit;
+ color: inherit;
}
[data-sheet-grid] .jss_container,
@@ -1428,3 +1431,9 @@ form[data-doc-title]:not(:focus-within) button {
[data-size-form] button {
padding-inline: var(--rst-sp-2);
}
+
+/* The referenced range is separate from the cell receiving the formula. */
+[data-sheet-grid] .jss_worksheet > tbody > tr > td[data-formula-reference] {
+ outline: 2px solid var(--rst-accent);
+ outline-offset: -2px;
+}
diff --git a/internal/sheets/static/sizes.js b/internal/sheets/static/sizes.js
index 9c71226..c2af959 100644
--- a/internal/sheets/static/sizes.js
+++ b/internal/sheets/static/sizes.js
@@ -20,6 +20,7 @@
* back — two tabs open, and one person's drag becomes an endless
* round trip between them. */
var applying = false;
+ var pending = Promise.resolve();
function apply(ws, sizes) {
if (!ws || !sizes) return;
@@ -50,7 +51,10 @@
if (!ctx.mayWrite) return opts;
opts.onresizecolumn = function (instance, col, width) {
- post(ctx, { col: parseInt(col, 10), size: Math.round(width) });
+ // A multi-column drag reports an array; persist every selected column.
+ (Array.isArray(col) ? col : [col]).forEach(function (index) {
+ post(ctx, { col: parseInt(index, 10), size: Math.round(width) });
+ });
};
opts.onresizerow = function (instance, row, height) {
post(ctx, { row: parseInt(row, 10), size: Math.round(height) });
@@ -62,22 +66,24 @@
if (applying) return;
if (!isFinite(body.size) || body.size <= 0) return;
body.client = ctx.client;
- fetch(ctx.base + '/size', {
- method: 'POST',
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body)
- }).then(function (resp) {
- /* A refused resize is not repainted from the response — the
- * column is already the width the person dragged it to, and
- * snapping it back under their cursor is worse than leaving a
- * width the server did not keep. The next reload is the
- * correction, and a refusal here means a bound was hit, which is
- * visible in the drag itself. */
- if (!resp.ok) return;
- return resp.json().then(function (payload) { ctx.paint(payload); });
- }).catch(function () { /* the stream's own reconnect will resync */ });
+ pending = pending.then(function () {
+ return fetch(ctx.base + '/size', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body)
+ }).then(function (resp) {
+ /* A refused resize is not repainted from the response — the
+ * column is already the width the person dragged it to, and
+ * snapping it back under their cursor is worse than leaving a
+ * width the server did not keep. The next reload is the
+ * correction, and a refusal here means a bound was hit, which is
+ * visible in the drag itself. */
+ if (!resp.ok) return;
+ return resp.json().then(function (payload) { ctx.paint(payload); });
+ }).catch(function () { /* the stream's own reconnect will resync */ });
+ });
}
- window.SheetsSizes = { apply: apply, extend: extend };
+ window.SheetsSizes = { apply: apply, extend: extend, settled: function () { return pending; } };
}());
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html
index 88091f4..8de06d1 100644
--- a/internal/sheets/templates/sheet.html
+++ b/internal/sheets/templates/sheet.html
@@ -12,9 +12,11 @@
<script defer src="{{asset "static/live.js"}}"></script>
<script defer src="{{asset "static/paint.js"}}"></script>
<script defer src="{{asset "static/sizes.js"}}"></script>
+<script defer src="{{asset "static/autofit.js"}}"></script>
<script defer src="{{asset "static/grid.js"}}"></script>
<script defer src="{{asset "static/menubar.js"}}"></script>
<script defer src="{{asset "static/formulabar.js"}}"></script>
+<script defer src="{{asset "static/formulapick.js"}}"></script>
<script defer src="{{asset "static/note.js"}}"></script>
<script defer src="{{asset "static/tsv.js"}}"></script>
<script defer src="{{asset "static/cellqueue.js"}}"></script>
@@ -261,6 +263,7 @@
They post through commands.js because the size route takes JSON;
the scriptless path to a resize is named in the spec's §14 with
the rest of the table view's formatting debt. */}}
+<button type="button" data-cmd="autofit-columns">Auto-fit columns</button>
<form data-size-form data-axis="col">
<label for="colw">Column width</label>
<input rst-input id="colw" name="size" type="number" min="16" max="2000" step="1" placeholder="px">
diff --git a/tools/drive/autofit.mjs b/tools/drive/autofit.mjs
new file mode 100644
index 0000000..ec34534
--- /dev/null
+++ b/tools/drive/autofit.mjs
@@ -0,0 +1,73 @@
+// Uses TestBrowserMenuFixture's rendered page with synthetic cells and size API.
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+const pw=await import(process.env.PLAYWRIGHT||'playwright');
+const fixture=JSON.parse(fs.readFileSync(process.env.SHEETS_MENU_FIXTURE));
+for(const name of ['chromium','webkit']) {
+ const browser=await pw[name].launch(name==='webkit'&&process.env.WEBKIT_EXECUTABLE_PATH?{executablePath:process.env.WEBKIT_EXECUTABLE_PATH}:{});
+ try {
+ const page=await browser.newPage({viewport:{width:1400,height:900}});
+ let requests=[],sizes={cols:{}},reject=false;
+ const errors=[];page.on('pageerror',e=>errors.push(e.message));
+ const cells=[{r:0,c:0,raw:'=SUM(B1:B80)',display:'5'},
+ {r:80,c:0,raw:'The widest value is below the viewport',display:'The widest value is below the viewport',f:'bold'},
+ {r:1,c:1,raw:'Medium length',display:'Medium length'},
+ {r:0,c:2,raw:'=A_VERY_LONG_FORMULA_WITH_A_SHORT_RESULT()',display:'1'},
+ {r:0,c:3,raw:'W'.repeat(1000),display:'W'.repeat(1000)}];
+ await page.route('https://sheets.test/**',async route=>{
+ const req=route.request(),path=new URL(req.url()).pathname;
+ if(fixture.assets[path])return route.fulfill({body:fixture.assets[path],contentType:path.endsWith('.js')?'application/javascript':path.endsWith('.css')?'text/css':'image/svg+xml'});
+ if(path.endsWith('/stream'))return route.fulfill({body:'',contentType:'text/event-stream'});
+ if(path.endsWith('/size')&&req.method()==='POST') {
+ const value=req.postDataJSON();requests.push(value);
+ if(reject)return route.fulfill({status:400,body:'refused'});
+ sizes.cols[value.col]=value.size;
+ }
+ if(path.startsWith('/api/'))return route.fulfill({json:{version:1,cells,formats:{bold:{b:true,i:true}},sizes}});
+ return route.fulfill({body:fixture.html,contentType:'text/html'});
+ });
+ async function load() {
+ requests=[];sizes={cols:{}};reject=false;
+ await page.goto('https://sheets.test/sheets/1');await page.waitForFunction(()=>SheetsGrid?.worksheet());
+ await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+ }
+ const header=c=>page.locator(`.jss_worksheet > thead > tr:first-child > td[data-x="${c}"]`);
+ async function divider(c) {
+ const rect=await header(c).boundingBox();
+ await page.mouse.dblclick(rect.x+rect.width-2,rect.y+rect.height/2);
+ }
+ async function resized(c) {
+ await page.waitForFunction(c=>Number(SheetsGrid.worksheet().getWidth(c))!==100,c);
+ }
+ await load();await divider(0);await resized(0);
+ assert.equal(requests.length,1);assert.equal(requests[0].col,0);
+ assert.ok(requests[0].size>200,'must measure content below viewport');
+ const first=requests[0].size;
+ await page.reload();await page.waitForFunction(()=>SheetsGrid?.worksheet());
+ assert.equal(Number(await page.evaluate(()=>SheetsGrid.worksheet().getWidth(0))),first,'width survives reload');
+ console.log(name+': single-column divider fits offscreen formatted content and persists');
+ await load();await header(0).click();await header(1).click({modifiers:['Shift']});
+ const selected=await page.evaluate(()=>SheetsGrid.selection());
+ assert.equal(Math.min(selected.c,selected.c2),0);assert.equal(Math.max(selected.c,selected.c2),1);
+ await divider(0);await page.waitForFunction(()=>Number(SheetsGrid.worksheet().getWidth(1))!==100);
+ assert.deepEqual(requests.map(r=>r.col),[0,1]);assert.ok(sizes.cols[0]>sizes.cols[1]);
+ assert.equal(sizes.cols[0],first);
+ console.log(name+': selected columns fit individually without first equalizing widths');
+ // The existing manual group resize must persist both columns too.
+ requests=[];const rect=await header(0).boundingBox();
+ await page.mouse.move(rect.x+rect.width-2,rect.y+rect.height/2);await page.mouse.down();
+ await page.mouse.move(rect.x+rect.width+30,rect.y+rect.height/2);await page.mouse.up();
+ await page.evaluate(()=>SheetsSizes.settled());
+ assert.deepEqual(requests.map(r=>r.col),[0,1]);assert.equal(sizes.cols[0],sizes.cols[1]);
+ console.log(name+': manual group resize persists all columns');
+ await load();await header(2).click();await page.evaluate(()=>SheetsAutoFit());
+ assert.ok(sizes.cols[2]<100,'measure formula result, not raw expression');
+ await header(3).click();await page.evaluate(()=>SheetsAutoFit());assert.equal(sizes.cols[3],2000);
+ await header(4).click();await page.evaluate(()=>SheetsAutoFit());assert.ok(sizes.cols[4]>=16&&sizes.cols[4]<100);
+ console.log(name+': formula display, empty columns, and maximum width');
+ await load();await header(0).click();await header(1).click({modifiers:['Shift']});reject=true;
+ await page.evaluate(()=>SheetsAutoFit());assert.equal(requests.length,1);assert.deepEqual(sizes.cols,{});
+ reject=false;await page.evaluate(()=>SheetsAutoFit());assert.equal(requests.length,3);
+ assert.deepEqual(errors,[]);console.log(name+': failed resize stops and can retry');
+ } finally {await browser.close();}
+}
diff --git a/tools/drive/batch-checks.mjs b/tools/drive/batch-checks.mjs
index 90bfe58..1aff5fe 100644
--- a/tools/drive/batch-checks.mjs
+++ b/tools/drive/batch-checks.mjs
@@ -22,14 +22,8 @@ export async function checkCellQueue(page) {
page.off('request', record);
}
- assert.equal(await page.evaluate(() => window.SheetsGrid.write(24, 20, '501')), true);
- await page.evaluate(() => { window.SheetsGrid.worksheet().updateSelectionFromCoords(0, 0, 20, 24); window.SheetsGrid.focus(); });
- const count = await page.evaluate(() => {
- let n = 0;
- for (let r = 0; r < 25; r++) for (let c = 0; c < 21; c++) if (window.SheetsGrid.rawAt(r, c) !== '') n++;
- return n;
- });
- assert.ok(count > 500, 'cut must need at least two chunks');
+ // Cut never writes. A failed paste leaves the source untouched.
+ await page.evaluate(() => { window.SheetsGrid.worksheet().updateSelectionFromCoords(0, 0, 1, 1); window.SheetsGrid.focus(); });
const cuts = [];
const refuse = async route => {
cuts.push(route.request().postDataJSON());
@@ -37,16 +31,19 @@ export async function checkCellQueue(page) {
};
await page.route('**/cells/batch', refuse);
try {
- const attempted = page.waitForRequest(isBatch);
await page.keyboard.press('Control+x');
+ await page.evaluate(() => window.SheetsCellQueue([], window.SheetsGrid.post));
+ assert.equal(cuts.length, 0, 'cut must leave the source in place');
+ await page.evaluate(() => { window.SheetsGrid.worksheet().updateSelectionFromCoords(3, 3, 3, 3); window.SheetsGrid.focus(); });
+ const attempted = page.waitForRequest(isBatch);
+ await page.keyboard.press('Control+v');
await attempted;
await page.evaluate(() => window.SheetsCellQueue([], window.SheetsGrid.post));
- assert.equal(cuts.length, 1, 'failed first chunk must stop the cut queue');
- assert.equal(cuts[0].cells.length, 500);
- assert.ok(cuts[0].cells.every(cell => cell.raw === ''));
- await page.waitForFunction(() => window.SheetsGrid.rawAt(0, 0) === '1' && window.SheetsGrid.rawAt(24, 20) === '501');
+ assert.equal(cuts.length, 1, 'move must use one atomic batch');
+ assert.equal(cuts[0].cells.length, 8);
+ await page.waitForFunction(() => window.SheetsGrid.rawAt(0, 0) === '1' && window.SheetsGrid.rawAt(1, 1) === '22');
} finally {
await page.unroute('**/cells/batch', refuse);
}
- return {pasteRequests: 1, pasteCells: 500, failedCutRequests: 1, failedCutStopped: true};
+ return {pasteRequests: 1, pasteCells: 500, failedMoveRequests: 1, sourcePreserved: true};
}
diff --git a/tools/drive/canary-batch.mjs b/tools/drive/canary-batch.mjs
index e37b64f..2febbcf 100644
--- a/tools/drive/canary-batch.mjs
+++ b/tools/drive/canary-batch.mjs
@@ -47,11 +47,8 @@ try {
await page.bringToFront();
await page.evaluate(()=>{window.SheetsGrid.worksheet().updateSelectionFromCoords(0,0,19,24);window.SheetsGrid.focus();});
await page.keyboard.press('Control+x');
- await page.waitForFunction(()=>window.SheetsGrid.rawAt(0,0)===''&&window.SheetsGrid.rawAt(24,19)==='');
- await page.evaluate(async text=>{await navigator.clipboard.writeText(text);},text);
- await page.locator('[data-sheet-grid] tbody tr:first-child td:nth-child(2)').click();
- await page.keyboard.press('Control+v');
- await page.waitForFunction(()=>window.SheetsGrid.rawAt(24,19)==='500');
+ assert.equal(await page.evaluate(()=>SheetsGrid.rawAt(0,0)), '1');
+ await page.keyboard.press('Escape');
const snap=await page.evaluate(async api=>{const r=await fetch(api);return r.json();},api);
assert.equal(snap.cells.length,500);
const before=snap.cells.map(c=>[c.r,c.c,c.raw]).sort();
diff --git a/tools/drive/clipboard.mjs b/tools/drive/clipboard.mjs
index b3e977e..709b96a 100644
--- a/tools/drive/clipboard.mjs
+++ b/tools/drive/clipboard.mjs
@@ -110,11 +110,13 @@ await sel(3,20,3,20); await page.waitForTimeout(200);
await page.keyboard.press('Control+v'); await page.waitForTimeout(900);
check('a cell holding a tab and a newline round-trips', await raw(20,3)==='has\ttab and\nnewline', JSON.stringify(await raw(20,3)));
-console.log('\n— cut clears the source');
+console.log('\n— cut holds the source until paste');
await sel(0,0,0,0); await page.waitForTimeout(200);
await page.evaluate(()=>window.SheetsGrid.focus());
await page.keyboard.press('Control+x'); await page.waitForTimeout(900);
-check('Ctrl+X emptied A1', await raw(0,0)==='', 'got '+JSON.stringify(await raw(0,0)));
+check('Ctrl+X kept A1', await raw(0,0)==='10', 'got '+JSON.stringify(await raw(0,0)));
+await sel(10,0,10,0); await page.keyboard.press('Control+v');
+await page.waitForFunction(()=>SheetsGrid.rawAt(0,0)==='' && SheetsGrid.rawAt(0,10)==='10');
console.log('\n— the menu items');
// B1 holds =A1*2 (sel takes col,row).
diff --git a/tools/drive/editing.mjs b/tools/drive/editing.mjs
new file mode 100644
index 0000000..c65f604
--- /dev/null
+++ b/tools/drive/editing.mjs
@@ -0,0 +1,105 @@
+// Browser-level component checks with the real vendored grid and mocked transport.
+// PLAYWRIGHT=/path/to/playwright/index.mjs node tools/drive/editing.mjs
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+const pw = await import(process.env.PLAYWRIGHT || 'playwright');
+const browser = await pw.chromium.launch({headless: true});
+const page = await browser.newPage();
+const root = new URL('../../internal/sheets/static/', import.meta.url);
+let cells, batches, fail;
+const errors = [];
+page.on('pageerror', e => errors.push(e.message));
+await page.route('https://sheets.test/**', async route => {
+ const req = route.request(), path = new URL(req.url()).pathname;
+ if (path === '/') return route.fulfill({contentType:'text/html', body:`
+ <link rel="stylesheet" href="/static/vendor/jspreadsheet.css">
+ <link rel="stylesheet" href="/static/sheets.css">
+ <style>td {font: 24px Arial} :root {--rst-line-strong: blue}</style>
+ <input data-formula-input><div data-sheet-surface data-sheet-id="1" data-tab-id="1"
+ data-rows="30" data-cols="30" data-may-write="1"><div data-sheet-grid tabindex="0"></div></div>
+ ${['vendor/jsuites.js','vendor/jspreadsheet.js','tsv.js','cellqueue.js','grid.js','formulabar.js','formulapick.js','clipboard.js'].map(x=>`<script src="/static/${x}"></script>`).join('')}`});
+ if (path.startsWith('/static/')) return route.fulfill({body:fs.readFileSync(new URL(path.slice(8),root)),
+ contentType:path.endsWith('.css')?'text/css':'application/javascript'});
+ if (req.method() === 'POST') {
+ const data=req.postDataJSON();
+ const jobs=data.cells || [data];
+ batches.push(jobs);
+ if(fail) return route.fulfill({status:400,body:'rejected'});
+ jobs.forEach(c=>cells.set(`${c.r},${c.c}`, {...c,display:c.raw}));
+ }
+ return route.fulfill({json:{cells:[...cells.values()],version:batches.length+1}});
+});
+async function reset(seed=[]) {
+ cells=new Map(seed.map(([r,c,raw])=>[`${r},${c}`,{r,c,raw,display:raw}]));batches=[];fail=false;
+ await page.goto('https://sheets.test/');
+ await page.waitForFunction(()=>window.SheetsGrid?.worksheet());
+}
+const td=(r,c)=>page.locator(`td[data-x="${c}"][data-y="${r}"]`);
+async function select(r,c,r2=r,c2=c) {
+ await page.evaluate(([r,c,r2,c2])=>{SheetsGrid.worksheet().updateSelectionFromCoords(c,r,c2,r2);SheetsGrid.focus();},[r,c,r2,c2]);
+}
+async function clip(type,text) {
+ return page.evaluate(([type,text])=>{
+ const data=new DataTransfer();if(text!==undefined)data.setData('text/plain',text);
+ document.activeElement.dispatchEvent(new ClipboardEvent(type,{bubbles:true,cancelable:true,clipboardData:data}));
+ return data.getData('text/plain');
+ },[type,text]);
+}
+async function settled() {await page.evaluate(()=>SheetsCellQueue([],SheetsGrid.post));}
+try {
+ await reset([[0,0,'one'],[1,0,'two']]);await select(0,0,1,0);
+ const text=await clip('cut');await settled();assert.equal(batches.length,0);
+ assert.equal(cells.get('0,0').raw,'one');
+ await select(1,0);await clip('paste',text);await settled();
+ assert.equal(batches.length,1);assert.equal(cells.get('0,0').raw,'');
+ assert.equal(cells.get('1,0').raw,'one');assert.equal(cells.get('2,0').raw,'two');
+ assert.equal(new Set(batches[0].map(c=>`${c.r},${c.c}`)).size,batches[0].length);
+ console.log('ok deferred cut and overlapping atomic move');
+ await reset([[0,0,'=B1']]);await select(0,0);const formula=await clip('cut');
+ await select(1,0);fail=true;await clip('paste',formula);await settled();
+ assert.equal(cells.get('0,0').raw,'=B1');fail=false;
+ await clip('paste',formula);await settled();assert.equal(cells.get('0,0').raw,'');
+ assert.equal(cells.get('1,0').raw,'=B1');assert.ok(!('fromR' in batches.at(-1)[0]));
+ console.log('ok rejected paste preserves cut for retry; moved formulas stay verbatim');
+ await reset([[0,0,'hello']]);await select(0,0);await clip('cut');
+ await page.keyboard.press('Escape');await select(1,0);await clip('paste','hello');await settled();
+ assert.equal(cells.get('0,0').raw,'hello');
+ await select(0,0);await clip('cut');await page.locator('[data-formula-input]').focus();
+ await clip('copy');await select(2,0);await clip('paste','hello');await settled();
+ assert.equal(cells.get('0,0').raw,'hello');console.log('ok Escape and text-field copy cancel pending cuts');
+ await reset([[0,0,'one']]);await select(0,0);await clip('cut');
+ cells.set('0,0',{r:0,c:0,raw:'changed',display:'changed'});
+ await page.evaluate(()=>SheetsGrid.refetch());await select(1,0);await clip('paste','one');await settled();
+ assert.equal(batches.length,0);console.log('ok changed source rejects stale cut');
+ await reset([[0,0,'one'],[1,0,'two']]);await select(0,0,1,0);await clip('cut');
+ await select(29,0);await clip('paste','one\ntwo');await settled();assert.equal(batches.length,0);
+ console.log('ok out-of-bounds move keeps source');
+ await reset([[0,0,'Room three']]);await td(0,0).dblclick();await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+ const input=td(0,0).locator('input,textarea');await input.waitFor();
+ const fonts=await input.evaluate(el=>[getComputedStyle(el).fontSize,getComputedStyle(el.parentElement).fontSize]);
+ assert.equal(fonts[0],fonts[1]);console.log('ok editor font matches cell');
+ await input.fill('=SUM(');
+ const a=await td(0,1).boundingBox(),b=await td(2,2).boundingBox();
+ await page.mouse.move(a.x+10,a.y+10);await page.mouse.down();
+ await page.mouse.move(b.x+10,b.y+10);await page.mouse.up();
+ assert.equal(await input.inputValue(),'=SUM(B1:C3)'.slice(0,-1));
+ assert.equal(batches.length,0);await input.press('End');await input.type(')');await input.press('Enter');await page.waitForFunction(()=>SheetsGrid.rawAt(0,0)==='=SUM(B1:C3)');
+ assert.equal(cells.get('0,0').raw,'=SUM(B1:C3)');console.log('ok in-cell formula range drag and commit');
+ await select(3,0);const bar=page.locator('[data-formula-input]');await bar.fill('=SUM(');
+ await td(0,1).click();assert.equal(await bar.inputValue(),'=SUM(B1');
+ await bar.type(')');assert.equal(await bar.inputValue(),'=SUM(B1)');await bar.press('Enter');await page.waitForFunction(()=>SheetsGrid.rawAt(3,0)==='=SUM(B1)');assert.equal(cells.get('3,0').raw,'=SUM(B1)');
+ console.log('ok formula bar inserts cell reference and commits to original cell');
+ await reset([[0,0,'=SUM(B1:B3)']]);await td(0,0).dblclick();await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+ await td(0,0).locator('input,textarea').waitFor();
+ await page.waitForFunction(()=>document.activeElement.selectionStart===0);
+ await td(0,1).click();assert.equal(cells.get('0,0').raw,'=SUM(B1:B3)');
+ console.log('ok clicking away from a selected existing formula preserves it');
+ await reset([[0,0,'original']]);await td(0,0).dblclick();await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
+ const cancel=td(0,0).locator('input,textarea');
+ await page.waitForFunction(()=>document.activeElement.value==='original' && document.activeElement.selectionEnd===8);
+ await cancel.fill('=SUM(');
+ await td(0,1).click();await cancel.press('Escape');
+ assert.equal(cells.get('0,0').raw,'original');assert.equal(batches.length,0);
+ console.log('ok Escape discards formula reference edit');
+ assert.deepEqual(errors,[]);
+} finally {await browser.close();}
diff --git a/tools/drive/menu-actions.mjs b/tools/drive/menu-actions.mjs
new file mode 100644
index 0000000..953747c
--- /dev/null
+++ b/tools/drive/menu-actions.mjs
@@ -0,0 +1,102 @@
+// Real server-rendered menus and assets, mocked transport; no live user data.
+// SHEETS_MENU_FIXTURE=/tmp/menus.json go test ./internal/sheets -run TestBrowserMenuFixture
+// PLAYWRIGHT=/path/to/playwright/index.mjs SHEETS_MENU_FIXTURE=/tmp/menus.json node tools/drive/menu-actions.mjs
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+const pw = await import(process.env.PLAYWRIGHT || 'playwright');
+const fixture = JSON.parse(fs.readFileSync(process.env.SHEETS_MENU_FIXTURE));
+const origin = 'https://sheets.test';
+for (const name of ['chromium', 'webkit']) {
+ const options = name === 'webkit' && process.env.WEBKIT_EXECUTABLE_PATH ? {executablePath:process.env.WEBKIT_EXECUTABLE_PATH} : {};
+ const browser = await pw[name].launch(options);
+ try {
+ const page = await browser.newPage({viewport:{width:1440,height:1000}});
+ let requests = [], errors = [];
+ page.on('pageerror', e=>errors.push(e.message));
+ // Force the Clipboard API fallback so command dispatch is deterministic in
+ // both engines. Real clipboard behavior is covered by clipboard.mjs.
+ await page.addInitScript(()=>{
+ window.menuClipboard = '';
+ document.execCommand = ()=>false;
+ Object.defineProperty(navigator,'clipboard',{value:{
+ writeText:async text=>{window.menuClipboard=text;},
+ readText:async ()=>'pasted'
+ }});
+ });
+ await page.route(origin+'/**',async route=>{
+ const req=route.request(),path=new URL(req.url()).pathname;
+ if(path in fixture.assets) return route.fulfill({body:fixture.assets[path],contentType:
+ path.endsWith('.js')?'application/javascript':path.endsWith('.css')?'text/css':'image/svg+xml'});
+ if(path.endsWith('/stream'))return route.fulfill({body:'',contentType:'text/event-stream'});
+ requests.push({method:req.method(),path,body:req.postData()});
+ if(path.startsWith('/api/'))return route.fulfill({json:{version:1,cells:[
+ {r:0,c:0,raw:'10',display:'10'},{r:1,c:0,raw:'20',display:'20'},
+ {r:2,c:0,raw:'30',display:'30'}]}});
+ return route.fulfill({body:fixture.html,contentType:'text/html'});
+ });
+ async function load() {
+ await page.goto(origin+'/sheets/1');
+ await page.waitForFunction(()=>window.SheetsGrid?.worksheet());
+ await page.locator('td[data-x="0"][data-y="0"]').click();
+ await page.evaluate(()=>SheetsGrid.worksheet().updateSelectionFromCoords(0,0,1,1));
+ requests=[];
+ }
+ await load();
+ const inventory=await page.locator('[data-menubar] a, [data-menubar] button').evaluateAll(els=>els.map((el,index)=>{
+ const top=el.closest('[data-menubar] > details'), sub=el.closest('[rst-menu-group]');
+ return {index,label:el.textContent.trim(),cmd:el.dataset.cmd,href:el.getAttribute('href'),
+ action:el.form?.getAttribute('action'),axis:el.form?.dataset.axis,
+ menu:[...top.parentElement.children].indexOf(top),
+ sub:sub?[...top.querySelectorAll('[rst-menu-group]')].indexOf(sub):null};
+ }));
+ for(const item of inventory) {
+ await load();
+ if(item.cmd==='fn')await page.evaluate(()=>SheetsGrid.worksheet().updateSelectionFromCoords(0,3,0,3));
+ const menu=page.locator('[data-menubar] > details').nth(item.menu);
+ await menu.locator(':scope > summary').click();
+ if(item.sub!==null)await menu.locator('[rst-menu-group]').nth(item.sub).locator(':scope > summary').click();
+ if(item.axis)await menu.locator(`[data-axis="${item.axis}"] input`).fill('48');
+ const button=page.locator('[data-menubar] a, [data-menubar] button').nth(item.index);
+ if(item.href || item.action) {
+ const method=item.action?'POST':'GET',path=item.action||item.href;
+ const sent=page.waitForRequest(req=>req.method()===method&&new URL(req.url()).pathname===path,{timeout:3000});
+ await button.click();await sent;
+ } else {
+ const suffix=item.axis || item.cmd==='autofit-columns'?'/size':item.cmd==='paste'?'/cells/batch':
+ ['fn','clear','fill-down','fill-right'].includes(item.cmd)?'/cells':'/format';
+ const posted=['copy','cut','select-all'].includes(item.cmd)?null:
+ page.waitForRequest(req=>req.method()==='POST'&&new URL(req.url()).pathname.endsWith(suffix),{timeout:3000});
+ await button.click();
+ if(['copy','cut'].includes(item.cmd)) {
+ await page.waitForFunction(()=>window.menuClipboard!== '',{},{timeout:3000});
+ assert.equal(requests.filter(r=>r.method==='POST').length,0,item.label+' must not write cells');
+ } else if(item.cmd==='select-all') {
+ assert.equal(await page.evaluate(()=>SheetsGrid.selection().r2===SheetsGrid.rows-1&&SheetsGrid.selection().c2===SheetsGrid.cols-1),true);
+ } else {
+ await posted;
+ assert.ok(requests.some(r=>r.method==='POST'&&r.path.endsWith(suffix)),`${name}: ${item.label} must POST ${suffix}`);
+ }
+ }
+ console.log(`${name}: OK ${item.label.replace(/\s+/g,' ')}`);
+ }
+ // Keyboard field editing must not be swallowed by menu navigation.
+ await load();
+ const format=page.locator('[data-menubar] > details').filter({has:page.locator(':scope > summary', {hasText:'Format'})});
+ await format.locator(':scope > summary').click();
+ const width=page.locator('#colw');await width.fill('48');await width.press('ArrowUp');
+ assert.equal(await width.inputValue(),'49');
+ await width.press('Enter');
+ assert.ok(requests.some(r=>r.path.endsWith('/size')&&JSON.parse(r.body).size===49));
+ await load();
+ const file=page.locator('[data-menubar] > details').first();
+ await file.locator('summary').press('Enter');
+ const submitted=page.waitForRequest(req=>req.method()==='POST'&&new URL(req.url()).pathname==='/sheets');
+ await page.keyboard.press('Enter');await submitted;
+ await load();await file.locator('summary').click();await page.keyboard.press('Escape');
+ assert.equal(await file.getAttribute('open'),null);
+ await file.locator('summary').click();await page.locator('[data-formula-input]').click();
+ assert.equal(await file.getAttribute('open'),null);
+ assert.deepEqual(errors,[]);
+ console.log(`${name}: ${inventory.length} actions, keyboard submission, resize, Escape, and outside click passed`);
+ } finally {await browser.close();}
+}