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 + );