Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 7 additions & 4 deletions docs/design/spreadsheet-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.

Expand Down
98 changes: 79 additions & 19 deletions src/odr/internal/html/frontend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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]+)?$/;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down
3 changes: 2 additions & 1 deletion test/browser/sheet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 72 additions & 2 deletions test/browser/sheet/editing.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
);
</script>
</body>
</html>
Loading