Skip to content

feat: collaborative undo/redo for simpleton editors#7

Open
bxff wants to merge 1 commit intobraid-org:masterfrom
bxff:feat/undo-manager
Open

feat: collaborative undo/redo for simpleton editors#7
bxff wants to merge 1 commit intobraid-org:masterfrom
bxff:feat/undo-manager

Conversation

@bxff
Copy link
Copy Markdown
Member

@bxff bxff commented Mar 27, 2026

Ctrl+Z is unreliable in collaborative sessions. Remote patches arrive through apply_patches_and_update_selection, which sets textarea.value programmatically. Browsers clear their undo stack for everything typed before a .value set, so every incoming remote edit wipes the undo history. In practice, you can only undo the last few keystrokes since the most recent remote patch, and in an active session with frequent edits that means undo barely works.

This adds a client-side undo manager that maintains its own stack. On each local edit, it records an inverse patch (the deleted text and the range needed to undo the insertion). On Ctrl+Z it pops the stack and applies the inverse. Rapid keystrokes within 500ms get grouped into a single undo step.

The tricky part is making this work with concurrent editing. If another user inserts text before your undo range, the stored positions are now wrong. To handle this, every time remote patches arrive through on_patches, the undo manager transforms its entire stack through the remote edit. Each stored range gets shifted by the remote insertion/deletion using the same transform_pos logic that cursor-sync.js already uses for cursor positions. This is the same approach described in loro-dev/loro#361.

Changes

client/undo-sync.js: new file. The undo manager (~217 lines). record() captures inverse patches. transform() walks both stacks adjusting ranges through remote edits. undo()/redo() apply and swap between stacks. One implementation detail: apply_patches_and_update_selection mutates patch range arrays in-place, so inverse computation has to run before the apply call.

client/simpleton-sync.js: expose a client_state getter so the undo manager can read the pre-edit text for inverse computation.

client/editor.html, client/markdown-editor.html: wire the undo manager into the simpleton/cursor flow. Intercept Ctrl+Z, Ctrl+Shift+Z, Ctrl+Y.

test/undo-tests.js: new file. 15 tests covering inverse correctness, redo round-trips, transform through remote inserts and deletes, capture timeout grouping, overlapping remote deletes, and a two-peer HTTP integration test.

test/test.js: register the undo test suite and expose undo_manager as a global for the test runner.

Limitations

  • The undo stack lives in memory. Lost on page refresh. Persisting to localStorage or recovering state from the server are natural follow-ups.
  • If a remote user deletes content that overlaps an undo entry's target range, the entry collapses and can't perfectly restore the original state. This is a fundamental OT limitation that also shows up in Google Docs and Notion. Loro documents this in their PR as well.
  • Undo is scoped to the current tab. Cross-session or cross-peer undo would need server-side support.

Adds a client-side undo/redo system for simpleton editors, modeled after
Loro's OT-based approach (loro-dev/loro#361). The undo stack stores
inverse patches and transforms them through remote edits so positions
stay valid across concurrent editing.

New files:
  client/undo-sync.js  - undo_manager with inverse, transform, record/undo/redo
  test/undo-tests.js   - 15 unit + integration tests

Modified files:
  client/simpleton-sync.js    - expose client_state getter for text_before capture
  client/editor.html          - wire undo_manager, intercept Ctrl+Z/Ctrl+Shift+Z
  client/markdown-editor.html - wire undo_manager, intercept Ctrl+Z/Ctrl+Shift+Z
  test/test.js                - register undo test suite

Key design decisions:
  - Patches in undo groups use offset-accumulation (same as apply_patches_and_update_selection)
  - capture_timeout (500ms default) groups rapid keystrokes into single undo steps
  - transform_group implements Loro's d_i := transform(d_i, r); r := transform(r, d_i)
  - Overlapping remote deletes collapse undo entries gracefully (acknowledged limitation)
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.

1 participant