From fea6a20ffee631a288ba1a110b422b671da41379 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Mon, 14 Sep 2026 09:10:55 +0200 Subject: [PATCH 1/2] fix(html): one undo takes back a whole edit An edit can take several steps: Enter inside a run shortens the run, puts its tail beside it and splits the paragraph. `undo()` and `redo()` took one step at a time, so one edit needed several undos, and the page showed states between the steps that the reader never made. Decision 6 of `docs/design/document-editing.md` says that one `beforeinput` is one undo step. Each way into the editor, `beforeinput`, `compositionend` and `format()`, now opens a gesture, and every step it performs carries that gesture. `undo()` and `redo()` take all steps of the top gesture. The operation log does not change. The browser checks expected the extra steps after a split. They now expect one step, and two new checks pin that redo replays the split whole and that two edits stay two steps. 174 checks, 0 failed. Closes #903. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LosSkagYUDarkPra2pGTnP --- CHANGELOG.md | 5 ++++ src/odr/internal/html/frontend/document.js | 29 +++++++++++++++++----- test/browser/text/tests.html | 14 ++++++++--- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96380c327..f2e384322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- **Fix**: one undo in the document view's editor took back only part of an + edit that took several steps, such as Enter inside a run, and showed states + between the steps that the reader never made. `odr.editing.undo()` and + `redo()` now take back and replay the whole edit. + ## v7.0.0 - 2026-09-13 - **Breaking**: `DocumentPath`, `Element::document_path()` and diff --git a/src/odr/internal/html/frontend/document.js b/src/odr/internal/html/frontend/document.js index 6670c540b..f0e2a161b 100644 --- a/src/odr/internal/html/frontend/document.js +++ b/src/odr/internal/html/frontend/document.js @@ -98,10 +98,18 @@ var done = []; var undone = []; + // One key press can take several steps: Enter inside a run cuts the run, + // puts its tail beside it and splits the paragraph. Each way in - an input, + // a composition, a format - opens a gesture, every step it performs carries + // it, and undo and redo take a gesture whole, so they never show a state + // the reader did not make. + var gesture = 0; + function perform(step) { if (step === null) { return null; } + step.gesture = gesture; step.apply(); done.push(step); undone.length = 0; @@ -745,6 +753,7 @@ /// paragraph holding no run, the mark waits for the next typed text. /// Formatting sits behind the scope gate whole. function format(style, at) { + gesture += 1; if (!odr.editing.isEnabled()) { refuse(null, "readOnly", at); return false; @@ -1334,6 +1343,7 @@ }); root.addEventListener("compositionend", function () { + gesture += 1; var run = composing; composing = null; if (!odr.editing.isEnabled()) { @@ -1354,6 +1364,7 @@ }); root.addEventListener("beforeinput", function (event) { + gesture += 1; var type = event.inputType; var at = rangeOf(event); @@ -1520,9 +1531,12 @@ if (done.length === 0) { return false; } - var step = done.pop(); - step.revert(); - undone.push(step); + var taken = done[done.length - 1].gesture; + while (done.length > 0 && done[done.length - 1].gesture === taken) { + var step = done.pop(); + step.revert(); + undone.push(step); + } odr.editing.changed(); return true; }, @@ -1530,9 +1544,12 @@ if (undone.length === 0) { return false; } - var step = undone.pop(); - step.apply(); - done.push(step); + var given = undone[undone.length - 1].gesture; + while (undone.length > 0 && undone[undone.length - 1].gesture === given) { + var step = undone.pop(); + step.apply(); + done.push(step); + } odr.editing.changed(); return true; }, diff --git a/test/browser/text/tests.html b/test/browser/text/tests.html index 72cf297e0..169f3a60a 100644 --- a/test/browser/text/tests.html +++ b/test/browser/text/tests.html @@ -375,11 +375,19 @@ "and the paragraph is one again", texts() === "first run a link and a tail|bold|third|" ); - check("undo goes on to the run that was cut", odr.editing.undo() === true); - check("and to the run that was shortened", odr.editing.undo() === true); - check("until there is nothing left", odr.editing.undo() === false); + check("in one step, so nothing is left", odr.editing.undo() === false); check("with the page as the renderer wrote it", texts() === "first run a link and a tail|bold|third|"); check("and the log empty", ops().length === 0); + check("redo splits it again", odr.editing.redo() === true && ops().length > 0); + check("in one step too", odr.editing.redo() === false); + + reset(); + select(run(11).firstChild, 5); + input("insertText", "A"); + select(run(11).firstChild, 6); + input("insertText", "B"); + check("two edits are two steps", odr.editing.undo() === true); + check("and the first one stays", run(11).textContent === "firstA run "); reset(); select(run(21).firstChild, 0); From efea5ac68171bc22232d4d61d407f1cdda6c5f1e Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sat, 19 Sep 2026 08:38:47 +0200 Subject: [PATCH 2/2] docs(html): trim the comment on the gesture counter Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01C725vurM2ZqBY6ddV2FMZ7 --- src/odr/internal/html/frontend/document.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/odr/internal/html/frontend/document.js b/src/odr/internal/html/frontend/document.js index f0e2a161b..0c63e6440 100644 --- a/src/odr/internal/html/frontend/document.js +++ b/src/odr/internal/html/frontend/document.js @@ -98,11 +98,7 @@ var done = []; var undone = []; - // One key press can take several steps: Enter inside a run cuts the run, - // puts its tail beside it and splits the paragraph. Each way in - an input, - // a composition, a format - opens a gesture, every step it performs carries - // it, and undo and redo take a gesture whole, so they never show a state - // the reader did not make. + // undo and redo take all steps of one gesture together var gesture = 0; function perform(step) {