From e370f6ef0a0474bfe16073fa38ab88fff14df3e7 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Tue, 8 Sep 2026 21:20:49 +0200 Subject: [PATCH] feat(html): take a sheet edit back, and tell the host what the log holds `odr.editing.undo()` and `redo()` walk the commits the page made, and ctrl/cmd+Z and ctrl/cmd+shift+Z reach them from the keyboard. An undo shows the value the op replaced and drops the op, so what the log hands out and what the page shows stay the same thing: undoing the only write into a cell leaves that position out of the envelope entirely. `odr.onEditChange` carries `{dirty, operations, canUndo, canRedo}` on every commit, undo, redo and `committed()` - the save button a host lights, the back-press warning it raises, and the toolbar it greys. `committed()` is what a host calls once the file it saved matches the page, and undo starts over there. Step 1.4 of `docs/design/spreadsheet-editing.md`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012eNBvra2NSxLjUvVKzuXSY --- CHANGELOG.md | 4 ++ docs/design/spreadsheet-editing.md | 11 ++-- src/odr/internal/html/frontend.cpp | 98 ++++++++++++++++++++++++------ test/browser/sheet/README.md | 3 +- test/browser/sheet/editing.html | 74 +++++++++++++++++++++- 5 files changed, 164 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77cefff5c..1324af4da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ The release run heads these entries with the version and opens a fresh - `odr.sheet` also answers what the page shows: `valueAt`, `showValue`, `reflow` and `lower`. +- A sheet edit can be taken back: `odr.editing.undo()`, `redo()`, ctrl/cmd+Z, + and `committed()` after a save; `odr.onEditChange` reports the state of the + log to the host. + - **Fix**: a zip entry name with a leading slash is read relative to the archive root rather than throwing, and one named `/` alone is dropped. An `.odt` carrying such an entry now opens; LibreOffice still refuses it. diff --git a/docs/design/spreadsheet-editing.md b/docs/design/spreadsheet-editing.md index 2dc176b19..b9737eab8 100644 --- a/docs/design/spreadsheet-editing.md +++ b/docs/design/spreadsheet-editing.md @@ -234,8 +234,8 @@ in three hosts. **`onEditChange` is what decision 6 needs.** `dirty` is how the app lights its save button and warns on back-press while the page holds unsaved edits; -`canUndo`/`canRedo` drive the toolbar. It fires on every commit, undo, redo -and on `committed()`. +`operations` is how many ops the log would hand out, and `canUndo`/`canRedo` +drive the toolbar. It fires on every commit, undo, redo and on `committed()`. **Attaching**, per host: @@ -370,8 +370,11 @@ Each step ships on its own. "Both" means `.ods` and `.xlsx`. blank cell fills or a full one empties. `odr.sheet` gained `valueAt`, `showValue` and `reflow` for it, and `getOperations()` came with them: a log nothing hands out is a log nothing can check. -4. Undo/redo over the in-memory log; `committed()`; both raise `onEditChange`, - which is what a host's save button and back-press warning read. +4. **Landed.** Undo/redo over the in-memory log, from `odr.editing` and from + ctrl/cmd+Z; `committed()`; all of them raise `onEditChange`, which is what a + host's save button and back-press warning read. An undo shows the value the + op replaced and drops it from the log, so what the log hands out and what the + page shows stay the same thing. 5. `test/browser/sheet` grows the editing cases; the wasm example gets an edit-and-save button, which is also the host-wiring reference for droid/ios. diff --git a/src/odr/internal/html/frontend.cpp b/src/odr/internal/html/frontend.cpp index 912c05673..c88477968 100644 --- a/src/odr/internal/html/frontend.cpp +++ b/src/odr/internal/html/frontend.cpp @@ -2298,6 +2298,27 @@ constexpr std::string_view sheet_editing_js = R"js( var overlay = null; var editingAt = null; var history = []; + var undone = []; + + /// One op per position, the last write made. + function coalesced() { + var byPosition = new Map(); + for (var i = 0; i < history.length; ++i) { + var op = history[i].op; + byPosition.set(op.sheet + ":" + op.column + ":" + op.row, op); + } + return Array.from(byPosition.values()); + } + + /// What a host's save button and back-press warning read. + function changed() { + fire("onEditChange", { + dirty: history.length > 0, + operations: coalesced().length, + canUndo: history.length > 0, + canRedo: undone.length > 0, + }); + } var NUMBER = /^[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?$/; @@ -2342,9 +2363,19 @@ constexpr std::string_view sheet_editing_js = R"js( }, before: before, }); + undone = []; + changed(); return true; } + /// An undo and a redo are the same move on the page; the log tells them + /// apart. + function replay(entry, value) { + close(); + odr.sheet.showValue(entry.op.column, entry.op.row, value); + changed(); + } + // Offsets, not rects: blink scales a rect by the body zoom `viewport_js` // applies, and the overlay is laid out under that zoom. function place(cell) { @@ -2461,23 +2492,32 @@ constexpr std::string_view sheet_editing_js = R"js( /// What a pinned cell does with a key when no editor is open. Captured, so /// the keys taken here never reach the pin and the sort beneath. function pinnedKey(event) { + var target = event.target; if ( !editing || overlay !== null || - event.ctrlKey || - event.metaKey || - event.altKey + (target && + (target.isContentEditable || + /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) ) { return; } - var target = event.target; - if ( - target && - (target.isContentEditable || - /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) - ) { + + // ctrl/cmd is the undo chord here and nothing else. + if (event.ctrlKey || event.metaKey || event.altKey) { + var chord = event.key.toLowerCase(); + if (!event.altKey && (chord === "z" || chord === "y")) { + if (chord === "y" || event.shiftKey) { + odr.editing.redo(); + } else { + odr.editing.undo(); + } + event.stopPropagation(); + event.preventDefault(); + } return; } + var at = odr.sheet.pinned(); if (at === null || at.column === null || at.row === null) { return; @@ -2535,18 +2575,38 @@ constexpr std::string_view sheet_editing_js = R"js( return edit(column, row, null); }; - /// What a host hands to `Document::edit` before saving, coalesced per - /// position. + /// The envelope a host hands to `Document::edit` before saving. odr.editing.getOperations = function () { - var byPosition = new Map(); - for (var i = 0; i < history.length; ++i) { - var op = history[i].op; - byPosition.set(op.sheet + ":" + op.column + ":" + op.row, op); + return JSON.stringify({ version: 1, ops: coalesced() }); + }; + + /// Takes the last write back; false where there is none. + odr.editing.undo = function () { + if (history.length === 0) { + return false; } - return JSON.stringify({ - version: 1, - ops: Array.from(byPosition.values()), - }); + var entry = history.pop(); + undone.push(entry); + replay(entry, entry.before); + return true; + }; + + odr.editing.redo = function () { + if (undone.length === 0) { + return false; + } + var entry = undone.pop(); + history.push(entry); + replay(entry, entry.op.value); + return true; + }; + + /// The host saved the log: the page and the file agree, and undo starts + /// over. + odr.editing.committed = function () { + history = []; + undone = []; + changed(); }; })(); )js"; diff --git a/test/browser/sheet/README.md b/test/browser/sheet/README.md index 16fc7d61e..0fd3d19e6 100644 --- a/test/browser/sheet/README.md +++ b/test/browser/sheet/README.md @@ -33,7 +33,8 @@ it finds. - **`editing.html`** — the overlay editor, driven through `odr.editing` the way a host drives it, over the shapes a commit has to get right: a string cut where its neighbour shows something, a formula cell, a cell of several runs, - and one whose single run carries a style a write must keep. + and one whose single run carries a style a write must keep. Undo, redo and + the log a save resets follow. - **`sorting.html`** — the same questions after the sort control has moved every row. Nothing here is merged, because a merged sheet is offered no sort control; a row is found by the label it carries, so where it now sits does not diff --git a/test/browser/sheet/editing.html b/test/browser/sheet/editing.html index 65c028ff9..c706b90ac 100644 --- a/test/browser/sheet/editing.html +++ b/test/browser/sheet/editing.html @@ -78,9 +78,15 @@ function editor() { return document.querySelector(".odr-sheet-editor"); } - function press(target, key, shift) { + function press(target, key, modifiers) { + modifiers = modifiers || {}; target.dispatchEvent( - new KeyboardEvent("keydown", { key: key, shiftKey: !!shift, bubbles: true }) + new KeyboardEvent("keydown", { + key: key, + shiftKey: !!modifiers.shift, + ctrlKey: !!modifiers.ctrl, + bubbles: true, + }) ); document.body.offsetHeight; } @@ -246,6 +252,70 @@ .map(function (op) { return op.column + "," + op.row; }) .join(" ") === "0,3 2,2 0,1 1,0 2,0" ); + + // The log a host reads: the change event, undo and redo, and the reset a + // save leaves behind. + var changes = []; + function last() { + return changes[changes.length - 1]; + } + odr.onEditChange = function (event) { + changes.push(event); + }; + odr.editing.enable(); + odr.editing.committed(); + check( + "committing resets the log", + ops().length === 0 && last().dirty === false && last().canUndo === false + ); + + odr.editing.editAt(3, 0); + editor().value = "one"; + press(editor(), "Enter"); + check( + "a commit reports the log", + last().dirty === true && + last().operations === 1 && + last().canUndo === true && + last().canRedo === false + ); + + odr.editing.editAt(3, 0); + editor().value = "two"; + press(editor(), "Enter"); + check( + "a second write into the same cell is still one op", + ops().length === 1 && ops()[0].value.text === "two" + ); + + check("undo takes the last write back", odr.editing.undo() === true); + check("the cell shows the one before it", cell(3, 0).textContent === "one"); + check("and the op says so too", ops().length === 1 && ops()[0].value.text === "one"); + check("with a redo now open", last().canUndo === true && last().canRedo === true); + + check("undo again empties the cell it filled", odr.editing.undo() === true && cell(3, 0).textContent === ""); + check("and the log is clean", ops().length === 0 && last().dirty === false); + check("there is nothing further to take back", odr.editing.undo() === false); + + check("redo puts it back", odr.editing.redo() === true && cell(3, 0).textContent === "one"); + press(document.body, "z", { ctrl: true }); + check("ctrl+z is undo", cell(3, 0).textContent === ""); + press(document.body, "z", { ctrl: true, shift: true }); + check("ctrl+shift+z is redo", cell(3, 0).textContent === "one"); + + var box = document.body.appendChild(document.createElement("input")); + press(box, "z", { ctrl: true }); + check("a chord in another input is that input's", cell(3, 0).textContent === "one"); + box.remove(); + + odr.editing.committed(); + check( + "a save clears both stacks", + odr.editing.undo() === false && + odr.editing.redo() === false && + ops().length === 0 && + last().dirty === false + );