oficina / sheets Public

Match cell editor styling, defer cut, and pick formula ranges

Paul Campbell pushed by paul@keymail.dev 7a843c2b11265335309758ea856f54f542f7df1e parent e4fb2fc
10 files changed, +312 −171
  • docs/superpowers/specs/2026-09-16-browser-cell-editing.md +28 −0
  • internal/sheets/static/clipboard.js +63 −146
  • internal/sheets/static/formulabar.js +2 −0
  • internal/sheets/static/formulapick.js +83 −0
  • internal/sheets/static/sheets.css +13 −4
  • internal/sheets/templates/sheet.html +1 −0
  • tools/drive/batch-checks.mjs +11 −14
  • tools/drive/canary-batch.mjs +2 −5
  • tools/drive/clipboard.mjs +4 −2
  • tools/drive/editing.mjs +105 −0

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();}