Match cell editor styling, defer cut, and pick formula ranges
10 files changed,
+312
−171
docs/superpowers/specs/2026-09-16-browser-cell-editing.md+28 −0internal/sheets/static/clipboard.js+63 −146internal/sheets/static/formulabar.js+2 −0internal/sheets/static/formulapick.js+83 −0internal/sheets/static/sheets.css+13 −4internal/sheets/templates/sheet.html+1 −0tools/drive/batch-checks.mjs+11 −14tools/drive/canary-batch.mjs+2 −5tools/drive/clipboard.mjs+4 −2tools/drive/editing.mjs+105 −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/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/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/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/templates/sheet.html b/internal/sheets/templates/sheet.html| index 88091f4..f617629 100644 |
| --- a/internal/sheets/templates/sheet.html |
| +++ b/internal/sheets/templates/sheet.html |
| @@ -15,6 +15,7 @@ |
| <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> |
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();} |