diff --git a/client/editor.html b/client/editor.html index 46fb02f..37f32ba 100644 --- a/client/editor.html +++ b/client/editor.html @@ -7,14 +7,27 @@ + diff --git a/client/markdown-editor.html b/client/markdown-editor.html index 32d8442..993a1df 100644 --- a/client/markdown-editor.html +++ b/client/markdown-editor.html @@ -6,6 +6,7 @@ +
@@ -48,10 +49,20 @@ var cursors = cursor_highlights(the_editor, location.pathname) +var um = undo_manager( + () => the_editor.value, + (patches) => { + apply_patches_and_update_selection(the_editor, patches) + update_markdown_later() + simpleton.changed() + } +) + var simpleton = simpleton_client(location.pathname, { on_online: (online) => { online ? cursors.online() : cursors.offline() }, on_patches: (patches) => { the_editor.disabled = false + um.transform(patches) apply_patches_and_update_selection(the_editor, patches) cursors.on_patches(patches) update_markdown_later() @@ -64,10 +75,23 @@ the_editor.oninput = (e) => { set_acked_state(the_editor, false) - cursors.on_edit(simpleton.changed()) + var text_before = simpleton.client_state + var patches = simpleton.changed() + cursors.on_edit(patches) + if (patches) um.record(patches, text_before) update_markdown_later() } +the_editor.addEventListener('keydown', (e) => { + var is_undo = (e.key === 'z' && !e.shiftKey) && (e.ctrlKey || e.metaKey) + var is_redo = (e.key === 'y' && (e.ctrlKey || e.metaKey)) || + (e.key === 'z' && e.shiftKey && (e.ctrlKey || e.metaKey)) + if (!is_undo && !is_redo) return + e.preventDefault() + if (is_undo) um.undo() + else um.redo() +}) + document.body.onkeydown = (e) => { if (e.keyCode === 27) { // Escape key e.stopPropagation() diff --git a/client/simpleton-sync.js b/client/simpleton-sync.js index 98cb14b..bd4684c 100644 --- a/client/simpleton-sync.js +++ b/client/simpleton-sync.js @@ -248,6 +248,10 @@ function simpleton_client(url, { // ── abort() — cancel the subscription ───────────────────────── abort: () => ac.abort(), + // ── client_state — the text as of the last acknowledged version ─ + // Useful for capturing text_before when recording undo entries. + get client_state() { return client_state }, + // ── changed() — call when local edits occur ─────────────────── // This is the entry point for sending local edits. It: // 1. Diffs client_state vs current state diff --git a/client/undo-sync.js b/client/undo-sync.js new file mode 100644 index 0000000..2bacfe3 --- /dev/null +++ b/client/undo-sync.js @@ -0,0 +1,217 @@ +// undo-sync.js -- Collaborative undo/redo for Braid-Text +// +// Implements the Loro-style OT-based undo algorithm: +// https://github.com/loro-dev/loro/pull/361 +// +// Core idea: undo is local-only (only reverts the current user's edits, +// not remote peers'). The undo stack stores inverse patches. When remote +// edits arrive, every entry in the undo/redo stack is transformed through +// them so positions stay valid. +// +// --- API --- +// +// var um = undo_manager(get_state, apply, options) +// +// get_state() +// Returns the current text (e.g. () => textarea.value). +// +// apply(patches) +// Applies an array of patches to the document. +// Patches are {range: [start, end], content: string} in JS-string indices. +// You are responsible for feeding the result back to simpleton.changed(). +// +// options (all optional): +// max_items: max undo stack depth (default: 100) +// capture_timeout: ms to group rapid edits (default: 500) +// +// um.record(patches, text_before) +// Call after each local edit with the patches that were applied and +// the text BEFORE the edit. +// +// um.transform(remote_patches) +// Call whenever remote patches arrive (in your on_patches callback). +// Keeps the undo/redo stack valid against the current document state. +// +// um.undo() -- undo the last local edit group, returns true if anything was undone +// um.redo() -- redo the last undone edit group, returns true if anything was redone +// +// um.can_undo() -- true if undo stack is non-empty +// um.can_redo() -- true if redo stack is non-empty +// +// um.clear() -- clear both stacks +// + +function undo_manager(get_state, apply, options) { + var max_items = (options && options.max_items != null) ? options.max_items : 100 + var capture_timeout = (options && options.capture_timeout != null) ? options.capture_timeout : 500 + + // Each stack entry is { inverse: {range, content}, ts: timestamp }. + // One entry per patch (not merged). Grouping by capture_timeout is + // handled at undo/redo time by popping consecutive entries within + // the timeout window. + var undo_stack = [] + var redo_stack = [] + + // --- clone_patch --- + // Deep-clone a patch so apply() can mutate ranges without corrupting + // stored undo/redo entries. apply_patches_and_update_selection does + // p.range[0] += offset in-place. + function clone_patch(p) { + return { range: [p.range[0], p.range[1]], content: p.content } + } + + // --- inverse --- + // Given a forward patch {range: [s, e], content} applied to text_before, + // return the patch that undoes it. + function inverse_patch(patch, text_before) { + var s = patch.range[0] + var e = patch.range[1] + var deleted = text_before.slice(s, e) + return { range: [s, s + patch.content.length], content: deleted } + } + + // --- transform_pos --- + // Adjust a position through a single edit (same logic as cursor-sync.js). + function transform_pos(pos, del_start, del_len, ins_len, bias) { + if (del_len === 0) { + if (pos < del_start) return pos + if (pos === del_start && bias === 'left') return pos + return pos + ins_len + } + if (pos <= del_start) return pos + if (pos <= del_start + del_len) return del_start + ins_len + return pos - del_len + ins_len + } + + // --- transform_patch --- + // Transform patch a through patch b so a is valid in b's post-state. + function transform_patch(a, b) { + var b_del_start = b.range[0] + var b_del_len = b.range[1] - b.range[0] + var b_ins_len = b.content.length + + // Start uses right bias (expand into inserts), end uses left bias + // (don't expand delete range through co-located inserts). + var new_start = transform_pos(a.range[0], b_del_start, b_del_len, b_ins_len, 'right') + var new_end = transform_pos(a.range[1], b_del_start, b_del_len, b_ins_len, 'left') + if (new_end < new_start) new_end = new_start + + return { range: [new_start, new_end], content: a.content } + } + + // --- transform_entries --- + // Transform each stack entry through the remote patches independently. + // Each entry was recorded at a different point in time (different text + // state), so we cannot advance r through the stack (that assumes all + // entries share a common coordinate space). Instead, each entry is + // transformed through the original remote patches as-is. + function transform_entries(entries, remote_patches) { + for (var i = 0; i < entries.length; i++) { + for (var r of remote_patches) { + entries[i].inverse = transform_patch(entries[i].inverse, r) + } + } + } + + // --- record --- + function record(patches, text_before) { + if (!patches || !patches.length) return + + var now = Date.now() + var current_text = text_before + + for (var p of patches) { + var inv = inverse_patch(p, current_text) + undo_stack.push({ inverse: inv, ts: now }) + // Advance text through this forward patch for the next inverse + var s = p.range[0], e = p.range[1] + current_text = current_text.slice(0, s) + p.content + current_text.slice(e) + } + + // Cap the stack + while (undo_stack.length > max_items) undo_stack.shift() + + // New edit clears redo + redo_stack = [] + } + + // --- transform --- + function transform(remote_patches) { + if (!remote_patches || !remote_patches.length) return + transform_entries(undo_stack, remote_patches) + transform_entries(redo_stack, remote_patches) + } + + // --- collect_group --- + // Pop entries from the top of `stack` that are within capture_timeout + // of each other, returning them in the order they were pushed. + function collect_group(stack) { + if (!stack.length) return null + var entries = [stack.pop()] + while (stack.length > 0) { + var top = stack[stack.length - 1] + if (entries[0].ts - top.ts < capture_timeout) { + entries.unshift(stack.pop()) + } else { + break + } + } + return entries + } + + // --- undo --- + function undo() { + var group = collect_group(undo_stack) + if (!group) return false + + var redo_entries = [] + for (var i = 0; i < group.length; i++) { + var entry = group[i] + var text_before = get_state() + var redo_inv = inverse_patch(entry.inverse, text_before) + apply([clone_patch(entry.inverse)]) + redo_entries.push({ inverse: redo_inv, ts: Date.now() }) + + // Transform remaining entries through this undo patch so + // their positions are valid against the new text state. + for (var j = i + 1; j < group.length; j++) { + group[j].inverse = transform_patch(group[j].inverse, entry.inverse) + } + } + + redo_stack.push.apply(redo_stack, redo_entries) + return true + } + + // --- redo --- + function redo() { + var group = collect_group(redo_stack) + if (!group) return false + + var undo_entries = [] + for (var i = 0; i < group.length; i++) { + var entry = group[i] + var text_before = get_state() + var undo_inv = inverse_patch(entry.inverse, text_before) + apply([clone_patch(entry.inverse)]) + undo_entries.push({ inverse: undo_inv, ts: Date.now() }) + + for (var j = i + 1; j < group.length; j++) { + group[j].inverse = transform_patch(group[j].inverse, entry.inverse) + } + } + + undo_stack.push.apply(undo_stack, undo_entries) + return true + } + + return { + record, + transform, + undo, + redo, + can_undo: function() { return undo_stack.length > 0 }, + can_redo: function() { return redo_stack.length > 0 }, + clear: function() { undo_stack = []; redo_stack = [] }, + } +} diff --git a/test/test.js b/test/test.js index 6b7b4c2..e8d2077 100755 --- a/test/test.js +++ b/test/test.js @@ -5,6 +5,7 @@ const http = require('http') const {fetch: braid_fetch} = require('braid-http') const defineTests = require('./tests.js') const defineCursorTests = require('./cursor-tests.js') +const defineUndoTests = require('./undo-tests.js') // Parse command line arguments const args = process.argv.slice(2) @@ -226,9 +227,14 @@ async function runConsoleTests() { global.AbortController = AbortController global.crypto = require('crypto').webcrypto + // Expose undo_manager so undo-tests.js can call it as a global + eval(require('fs').readFileSync(`${__dirname}/../client/undo-sync.js`, 'utf8')) + global.undo_manager = undo_manager + // Run all tests defineTests(runTest, testBraidFetch) defineCursorTests(runTest, testBraidFetch) + defineUndoTests(runTest, testBraidFetch) // Run tests sequentially (not in parallel) to avoid conflicts var test_timeout_ms = 15000 diff --git a/test/undo-tests.js b/test/undo-tests.js new file mode 100644 index 0000000..506943a --- /dev/null +++ b/test/undo-tests.js @@ -0,0 +1,365 @@ +// test/undo-tests.js — Unit and integration tests for undo-sync.js + +function defineUndoTests(runTest, braid_fetch) { + +// ── Helpers ──────────────────────────────────────────────────────────── + +// apply_text applies a set of absolute patches to a string. +// Patches are {range:[s,e], content} in original-document coords. +function apply_text(text, patches) { + var offset = 0 + for (var p of patches) { + var s = p.range[0] + offset + var e = p.range[1] + offset + text = text.slice(0, s) + p.content + text.slice(e) + offset += p.content.length - (p.range[1] - p.range[0]) + } + return text +} + +// build_um creates an undo_manager whose state variable is visible to the caller. +function build_um(initial, opts) { + var state = { text: initial } + var um = undo_manager( + () => state.text, + (patches) => { state.text = apply_text(state.text, patches) }, + opts + ) + return { um, state } +} + +function p(s, e, content) { return { range: [s, e], content } } + +// ── Unit: inverse ────────────────────────────────────────────────────── + +runTest( + "undo: inverse of insert", + () => { + var { um, state } = build_um('hello world') + // Forward: insert ' dear' at 5 (no deletion) + var text_before = state.text + state.text = apply_text(state.text, [p(5, 5, ' dear')]) + um.record([p(5, 5, ' dear')], text_before) + // text is now 'hello dear world' + um.undo() + return state.text === 'hello world' ? 'ok' : 'fail: ' + state.text + }, + 'ok' +) + +runTest( + "undo: inverse of delete", + () => { + var { um, state } = build_um('hello world') + var text_before = state.text + state.text = apply_text(state.text, [p(4, 5, '')]) // delete 'o' + um.record([p(4, 5, '')], text_before) + um.undo() + return state.text === 'hello world' ? 'ok' : 'fail: ' + state.text + }, + 'ok' +) + +runTest( + "undo: inverse of replace", + () => { + var { um, state } = build_um('hello world') + var text_before = state.text + state.text = apply_text(state.text, [p(6, 11, 'earth')]) + um.record([p(6, 11, 'earth')], text_before) + um.undo() + return state.text === 'hello world' ? 'ok' : 'fail: ' + state.text + }, + 'ok' +) + +// ── Unit: redo ───────────────────────────────────────────────────────── + +runTest( + "undo: undo then redo restores original edit", + () => { + var { um, state } = build_um('hello') + var text_before = state.text + state.text = apply_text(state.text, [p(5, 5, ' world')]) + um.record([p(5, 5, ' world')], text_before) + if (state.text !== 'hello world') return 'setup: ' + state.text + um.undo() + if (state.text !== 'hello') return 'after undo: ' + state.text + um.redo() + return state.text === 'hello world' ? 'ok' : 'fail after redo: ' + state.text + }, + 'ok' +) + +runTest( + "undo: multiple undo steps", + () => { + // Use capture_timeout:0 so each edit gets its own undo group + var { um, state } = build_um('', { capture_timeout: 0 }) + + var t = state.text + state.text = apply_text(t, [p(0, 0, 'a')]); um.record([p(0, 0, 'a')], t) + t = state.text + state.text = apply_text(t, [p(1, 1, 'b')]); um.record([p(1, 1, 'b')], t) + t = state.text + state.text = apply_text(t, [p(2, 2, 'c')]); um.record([p(2, 2, 'c')], t) + + um.undo(); if (state.text !== 'ab') return 'step1: ' + state.text + um.undo(); if (state.text !== 'a') return 'step2: ' + state.text + um.undo(); if (state.text !== '') return 'step3: ' + state.text + return 'ok' + }, + 'ok' +) + +runTest( + "undo: new edit clears redo stack", + () => { + var { um, state } = build_um('hello') + var t = state.text + state.text = apply_text(t, [p(5, 5, ' world')]); um.record([p(5, 5, ' world')], t) + um.undo() + if (!um.can_redo()) return 'should have redo entry' + t = state.text + state.text = apply_text(t, [p(5, 5, ' there')]); um.record([p(5, 5, ' there')], t) + return !um.can_redo() ? 'ok' : 'redo stack not cleared' + }, + 'ok' +) + +// ── Unit: transform through remote edit ─────────────────────────────── + +runTest( + "undo: transform undo entry through remote insert before undo range", + () => { + // State after local edit: 'hello ' (deleted 'world' from 'hello world') + // Remote inserts 'dear ' at position 0: state becomes 'dear hello ' + // After transform + undo, should restore to 'dear hello world' + var { um, state } = build_um('hello world') + var t = state.text + state.text = apply_text(t, [p(6, 11, '')]) // delete 'world' + um.record([p(6, 11, '')], t) // records inverse: insert 'world' at 6 + + // Remote arrives: insert 'dear ' at 0 + um.transform([p(0, 0, 'dear ')]) + state.text = apply_text(state.text, [p(0, 0, 'dear ')]) // 'dear hello ' + + um.undo() + return state.text === 'dear hello world' ? 'ok' : 'fail: ' + state.text + }, + 'ok' +) + +runTest( + "undo: transform undo entry through remote insert after undo range", + () => { + // Local: insert ' dear' at 5 in 'hello world' -> 'hello dear world' + // Remote: insert '!' at end (position 16) -> 'hello dear world!' + // After transform + undo, should get 'hello world!' + var { um, state } = build_um('hello world') + var t = state.text + state.text = apply_text(t, [p(5, 5, ' dear')]) + um.record([p(5, 5, ' dear')], t) + + // Remote inserts '!' at position 16 (after our insertion point) + um.transform([p(16, 16, '!')]) + state.text = apply_text(state.text, [p(16, 16, '!')]) // 'hello dear world!' + + um.undo() + return state.text === 'hello world!' ? 'ok' : 'fail: ' + state.text + }, + 'ok' +) + +// ── Unit: capture timeout ────────────────────────────────────────────── + +runTest( + "undo: rapid edits grouped into one step (capture timeout)", + () => { + var { um, state } = build_um('') + + // All three edits happen "instantly" — within the default 500ms window + var t = state.text + state.text = apply_text(t, [p(0, 0, 'a')]); um.record([p(0, 0, 'a')], t) + t = state.text + state.text = apply_text(t, [p(1, 1, 'b')]); um.record([p(1, 1, 'b')], t) + t = state.text + state.text = apply_text(t, [p(2, 2, 'c')]); um.record([p(2, 2, 'c')], t) + + // Since capture_timeout=500ms and all 3 happened at roughly the same time, + // they should merge into one group. One undo should remove all three. + um.undo() + return state.text === '' ? 'ok' : 'expected empty, got: ' + state.text + }, + 'ok' +) + +runTest( + "undo: can_undo and can_redo reflect stack state", + () => { + var { um, state } = build_um('hello') + if (um.can_undo()) return 'should start empty' + if (um.can_redo()) return 'should start empty' + var t = state.text + state.text = apply_text(t, [p(5, 5, '!')]); um.record([p(5, 5, '!')], t) + if (!um.can_undo()) return 'should have undo after record' + um.undo() + if (um.can_undo()) return 'should be empty after full undo' + if (!um.can_redo()) return 'should have redo after undo' + return 'ok' + }, + 'ok' +) + +runTest( + "undo: clear empties both stacks", + () => { + var { um, state } = build_um('hello') + var t = state.text + state.text = apply_text(t, [p(5, 5, '!')]); um.record([p(5, 5, '!')], t) + um.undo() + um.clear() + return !um.can_undo() && !um.can_redo() ? 'ok' : 'stacks not cleared' + }, + 'ok' +) + +// ── Edge cases from Loro's limitations ──────────────────────────────── + +runTest( + "undo: remote delete overlapping undo range collapses gracefully", + () => { + // Local: insert 'XY' at 5 in 'hello world' -> 'helloXY world' + // Remote: deletes 'oXY w' (positions 4-9 in post-insert state) + // After transform, the undo entry's range collapses. + // Undo should still not crash and should remove whatever is left. + var { um, state } = build_um('hello world') + var t = state.text + state.text = apply_text(t, [p(5, 5, 'XY')]) + um.record([p(5, 5, 'XY')], t) + // state: 'helloXY world' + + // Remote deletes 'oXY w' = positions 4 to 9 + um.transform([p(4, 9, '')]) + state.text = apply_text(state.text, [p(4, 9, '')]) + // state: 'hellorld' + + // Undo should not crash. The undo entry's original range [5,7] + // (delete 'XY') gets transformed through the remote delete. + // Both positions land inside the deleted region and collapse. + um.undo() + // The exact result depends on transform_pos semantics. + // The key property: no crash, text stays consistent. + return typeof state.text === 'string' ? 'ok' : 'fail: crash' + }, + 'ok' +) + +runTest( + "undo: multi-patch grouped undo+redo round-trip", + () => { + // Three rapid edits merge into one group (default 500ms timeout). + // Undo should revert all three; redo should restore all three. + var { um, state } = build_um('hello') + + var t = state.text + state.text = apply_text(t, [p(5, 5, ' ')]); um.record([p(5, 5, ' ')], t) + t = state.text + state.text = apply_text(t, [p(6, 6, 'w')]); um.record([p(6, 6, 'w')], t) + t = state.text + state.text = apply_text(t, [p(7, 7, '!')]); um.record([p(7, 7, '!')], t) + // state: 'hello w!' + + if (state.text !== 'hello w!') return 'setup: ' + state.text + + um.undo() + if (state.text !== 'hello') return 'after undo: ' + state.text + + um.redo() + return state.text === 'hello w!' ? 'ok' : 'after redo: ' + state.text + }, + 'ok' +) + +runTest( + "undo: undo-redo-undo cycle", + () => { + var { um, state } = build_um('abc', { capture_timeout: 0 }) + + var t = state.text + state.text = apply_text(t, [p(3, 3, 'd')]); um.record([p(3, 3, 'd')], t) + // 'abcd' + + um.undo() + if (state.text !== 'abc') return 'undo1: ' + state.text + + um.redo() + if (state.text !== 'abcd') return 'redo1: ' + state.text + + um.undo() + return state.text === 'abc' ? 'ok' : 'undo2: ' + state.text + }, + 'ok' +) + +// ── Integration: undo with concurrent peer via HTTP ─────────────────── + +runTest( + "undo: local undo does not revert remote peer's edit", + async () => { + var key = 'undo-concurrent-' + Math.random().toString(36).slice(2) + var peer_a = 'peer-a-' + Math.random().toString(36).slice(2) + var peer_b = 'peer-b-' + Math.random().toString(36).slice(2) + var base = 'http://localhost:8889' + + // Set initial state + await braid_fetch(`${base}/${key}`, { + method: 'PUT', body: 'hello world', + headers: { 'Content-Type': 'text/plain' } + }) + + // A inserts ' dear' at position 5 + await braid_fetch(`${base}/${key}`, { + method: 'PUT', + headers: { Peer: peer_a, 'Content-Type': 'text/plain' }, + patches: [{ unit: 'text', range: '[5:5]', content: ' dear' }] + }) + + // B inserts '!' at end of original text (pos 11) + await braid_fetch(`${base}/${key}`, { + method: 'PUT', + headers: { Peer: peer_b, 'Content-Type': 'text/plain' }, + patches: [{ unit: 'text', range: '[11:11]', content: '!' }] + }) + + // Read merged text + var current_r = await braid_fetch(`${base}/${key}`) + var current = await current_r.text() + + + // Simulate A's undo: find and remove ' dear' + var idx = current.indexOf(' dear') + if (idx === -1) return 'dear not found in: ' + current + + var undo_res = await braid_fetch(`${base}/${key}`, { + method: 'PUT', + headers: { Peer: peer_a + '-undo', 'Content-Type': 'text/plain' }, + patches: [{ unit: 'text', range: '[' + idx + ':' + (idx + 5) + ']', content: '' }] + }) + + var after_r = await braid_fetch(`${base}/${key}`) + var after = await after_r.text() + + if (after.includes(' dear')) return 'dear still present: ' + after + if (!after.includes('!')) return '! removed unexpectedly: ' + after + + return 'ok' + }, + 'ok' +) + +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = defineUndoTests +}