Merge branch 'fix/cell-editing' into main
Fix browser editing and menus; add column auto-fit
18 files changed,
+690
−190
docs/superpowers/specs/2026-09-16-browser-cell-editing.md+28 −0docs/superpowers/specs/2026-09-16-browser-menus-autofit.md+41 −0internal/sheets/browser_menu_test.go+41 −0internal/sheets/static/autofit.js+76 −0internal/sheets/static/clipboard.js+63 −146internal/sheets/static/commands.js+1 −0internal/sheets/static/formulabar.js+2 −0internal/sheets/static/formulapick.js+83 −0internal/sheets/static/menubar.js+19 −2internal/sheets/static/sheets.css+13 −4internal/sheets/static/sizes.js+23 −17internal/sheets/templates/sheet.html+3 −0tools/drive/autofit.mjs+73 −0tools/drive/batch-checks.mjs+11 −14tools/drive/canary-batch.mjs+2 −5tools/drive/clipboard.mjs+4 −2tools/drive/editing.mjs+105 −0tools/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();} |
| +} |