Skip to content

Make line-block toggles (lists/blockquote/code fence) undoable (#20)#28

Merged
Wavesonics merged 2 commits into
mainfrom
fix/issue-20-block-undo
Jun 12, 2026
Merged

Make line-block toggles (lists/blockquote/code fence) undoable (#20)#28
Wavesonics merged 2 commits into
mainfrom
fix/issue-20-block-undo

Conversation

@Wavesonics

Copy link
Copy Markdown
Owner

Problem

Toggling a numbered/bulleted list, blockquote, or code fence was not undoable (#20). Pressing Ctrl+Z after toggling a list did not remove the list — it reverted the previous edit.

The toggles in MarkdownExtension (toggleOrderedList/toggleBulletList/toggleBlockquote/toggleCodeFencetoggleLineBlock) called applyLineBlock/demoteLineBlock, which mutated both the line's paragraph indent (via updateLine) and a line-anchored rich span (via richSpanManager). Neither went through editManager.recordEdit, so the change was invisible to undo/redo. PR #26 made standalone rich spans undoable but deliberately left line-block toggles out; this PR finishes that work.

Design: dedicated TextEditOperation.LineBlock variant

I chose a dedicated variant over a generic Composite transaction. The line-block toggle has a fixed, well-defined shape, so a dedicated op lets us capture each affected line's prior/next AnnotatedString and block-span set directly and invert cleanly — simpler and less error-prone than threading a list of sub-operations each with their own metadata. transformOffset is a no-op because toggles change paragraph style only, not text length.

Each LineBlockChange snapshots, per affected line:

  • contentBefore / contentAfter — exact line content (so undo restores paragraph + text style precisely)
  • blockSpansBefore / blockSpansAfter — the line-anchored block span styles present in each state

Capturing the full before/after span set (not just the toggled style) means a mutually-excluded block demoted as a side effect (e.g. toggling an ordered list onto a bullet line) is also restored correctly in the same atomic step.

MarkdownExtension.toggleLineBlock now routes through editManager.toggleLineBlock, which captures before/after, applies the toggle via the direct (non-recording) path, then records exactly one LineBlock history entry. The inner span mutations deliberately use the direct richSpanManager path to avoid double-recording. importMarkdown is unchanged and stays on the direct path, so document loading still does not pollute undo history.

Exhaustiveness

Every exhaustive when (operation) over TextEditOperation was extended:

  • TextEditManagerapplyOperation, undo (new applyLineBlockState / undoLineBlock; redo reuses applyOperation)
  • RichSpanManager.updateSpans — passthrough (handleSpanOnly), toggles don't move text
  • SpellCheckState.invalidateSpellCheckSpansnull, no text change
  • SpellCheckingTextEditor.computeAffectedRanges already had an else branch.

Verified with the full ./gradlew check (the cross-module build that caught #26's first break).

Tests

LineBlockUndoTests and LineBlockRedoTests, all driving the real markdown.toggle* entry points:

  • ordered list undo/redo restores both span and paragraph indent
  • the reported scenario: type a char, toggle a list, undo → list fully gone, char intact; redo → list back, char intact
  • blockquote and code fence undo/redo
  • multi-line list toggle undo/redo
  • demote (toggle-off) is itself an atomic undoable entry

Verification

./gradlew checkBUILD SUCCESSFUL (iOS tasks skipped on Windows).

Fixes #20

Wavesonics and others added 2 commits June 12, 2026 00:35
Toggling a numbered/bulleted list, blockquote, or code fence changed both
the line's paragraph indent (via updateLine) and a line-anchored rich span
(via richSpanManager). Neither went through editManager.recordEdit, so the
toggle was invisible to undo/redo and Ctrl+Z reverted the previous edit
instead.

Introduce a dedicated TextEditOperation.LineBlock variant that snapshots each
affected line's content and block-span set both before and after the toggle.
Apply/redo restore the after state; undo restores the before state — including
any mutually-excluded block demoted as a side effect — in a single history
entry. The toggle now routes through editManager.toggleLineBlock; the inner
span mutations use the direct richSpanManager path to avoid double-recording,
and importMarkdown stays on the non-recording path so document loading does
not pollute undo history.

Extend every exhaustive when over TextEditOperation: RichSpanManager
(passthrough, toggles don't move text) and SpellCheckState (null, no text
change). SpellCheckingTextEditor already has an else branch.

Fixes #20

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A LineBlock toggle changes no text positions, so applyOperation must keep
the active selection like the other span operations do. Add LineBlock to the
selection-preserving set so the select-lines -> click-list flow no longer
clears the selection.

Add tests covering the highest-risk cases, all via the real markdown.toggle*
entry points: a character bold span and a standalone highlight rich span
survive toggle/undo/redo unchanged, and undoing an ordered-list toggle that
demoted an existing bullet restores the bullet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Wavesonics Wavesonics marked this pull request as ready for review June 12, 2026 07:54
@Wavesonics Wavesonics merged commit 4de864a into main Jun 12, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Undo/redo does not track list operations and undoes the wrong action

1 participant