From 7937322af31a1e2da7df525ae6383112bc2dd6fa Mon Sep 17 00:00:00 2001 From: Alex Currie-Clark Date: Tue, 12 Mar 2024 22:36:19 +0000 Subject: [PATCH] Properly track where changes should be applied when a sequence of changes is broken up by an untracked change --- README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++- src/index.ts | 38 +++++++++++++++++++------- tests/index.test.ts | 46 +++++++++++++++++++++++++++++-- 3 files changed, 137 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 010bd06..6a937f1 100644 --- a/README.md +++ b/README.md @@ -1 +1,65 @@ -# Automerge Repo Undo Redo +# Automerge Repo Undo Redo (very much WIP) + +This is a simple wrapper around an Automerge Repo `DocHandle` which adds undo and redo functionality. + +It allows you to make specific changes which you wish to be able to undo and redo, while any external changes (eg. changes from connected peers) will be untouched. + +## Usage +```ts + +const handle = repo.create( + age: 34; + name: "Jeremy" +}) +const undoRedo = new AutomergeRepoUndoRedo(handle) + +undoRedo.change((doc) => { + doc.age = 35; +}, "Update Age") + +// You can also make changes directly to your handle at any time +// which will not be tracked and won't form part of the undo/redo tree. +handle.change(doc => { + next.updateText(doc, ['name'], "Jeremy Irons") +}) + +undoRedo.undo(); // doc => { age: 34, name: "Jeremy Irons" } + +undoRedo.redo(); // doc.age => 35 +``` + + +## Concepts + +Undo and redo patches are stored along with the heads they a based on. When an undo or redo is invoked, if there have been no untracked changes to the document, then the undo change is applied to the head of the document. If there is an untracked change, then the change is made at the heads at which the original change occurred. This helps to preserve the untracked change. + +## Pitfalls +In the example below, some text is appended to a string by the tracked user. An untracked change is then made to that appended text, before the original change is undone. One might expect the text to revert to the original, but the untracked change remains, tacked unatractively on to the initial string. + +```ts + const handle = repo.create({ + text: "The jolly farmer enjoyed harvesting his ripe crop." + }) + const undoRedo = new AutomergeRepoUndoRedo(handle); + undoRedo.change((doc) => { + next.updateText( + doc, + ["text"], + "The jolly farmer enjoyed harvesting his ripe crop with his friends.", + ); + }); + + handle.change((doc) => { + next.updateText( + doc, + ["text"], + "The jolly farmer enjoyed harvesting his ripe crop with some friends.", + ); + }); + + undoRedo.undo(); + expect(handle.docSync().text).toBe( + "The jolly farmer enjoyed harvesting his ripe crop.", + ); // fails with => "The jolly farmer enjoyed harvesting his ripe cropome.", + +``` diff --git a/src/index.ts b/src/index.ts index c598311..2ad91b4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,8 +20,6 @@ const equalArrays = (a: any[], b: any[]) => export class AutomergeRepoUndoRedo { #docHandle: DocHandle; - #lastTrackedHeads: string[] = []; - get handle() { return this.#docHandle; } @@ -48,15 +46,13 @@ export class AutomergeRepoUndoRedo { options.patchCallback(patches, patchInfo); } - this.#lastTrackedHeads = next.getHeads(patchInfo.after); - this.#undos.push({ redo: { - heads: next.getHeads(patchInfo.after), + heads: next.getHeads(patchInfo.before), patches, }, undo: { - heads: this.#lastTrackedHeads, + heads: next.getHeads(patchInfo.after), patches: unpatchAll(patchInfo.before, patches), }, message, @@ -86,7 +82,8 @@ export class AutomergeRepoUndoRedo { const change = this.#undos.pop(); if (change) { const doc = this.#docHandle.docSync(); - if (doc && equalArrays(next.getHeads(doc), this.#lastTrackedHeads)) { + let heads = next.getHeads(doc!); + if (doc && equalArrays(heads, change.undo.heads)) { this.#docHandle.change((doc) => { change.undo.patches.forEach((p) => { patch(doc, p); @@ -94,7 +91,17 @@ export class AutomergeRepoUndoRedo { }); const after = this.#docHandle.docSync(); - this.#lastTrackedHeads = next.getHeads(after!); + const afterHeads = next.getHeads(after!); + + if (this.#undos.length > 0) { + const nextUndo = this.#undos[this.#undos.length - 1]; + + if (equalArrays(nextUndo.undo.heads, change.redo.heads)) { + nextUndo.undo.heads = afterHeads; + } + } + + change.redo.heads = afterHeads; } else { const heads = this.#docHandle.changeAt(change.undo.heads, (doc) => { change.undo.patches.forEach((p) => { @@ -115,7 +122,8 @@ export class AutomergeRepoUndoRedo { const change = this.#redos.pop(); if (change) { const doc = this.#docHandle.docSync(); - if (doc && equalArrays(next.getHeads(doc), this.#lastTrackedHeads)) { + let heads = next.getHeads(doc!); + if (doc && equalArrays(heads, change.redo.heads)) { this.#docHandle.change((doc) => { change.redo.patches.forEach((p) => { patch(doc, p); @@ -123,7 +131,17 @@ export class AutomergeRepoUndoRedo { }); const after = this.#docHandle.docSync(); - this.#lastTrackedHeads = next.getHeads(after!); + const afterHeads = next.getHeads(after!); + + if (this.#redos.length > 0) { + const nextRedo = this.#redos[this.#redos.length - 1]; + + if (equalArrays(nextRedo.redo.heads, change.undo.heads)) { + nextRedo.redo.heads = afterHeads; + } + } + + change.undo.heads = afterHeads; } else { const heads = this.#docHandle.changeAt(change.redo.heads, (doc) => { change.redo.patches.forEach((p) => { diff --git a/tests/index.test.ts b/tests/index.test.ts index 4c7dec7..16a7323 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -90,13 +90,55 @@ describe("basic tests", () => { next.updateText( doc, ["text"], - "The silly farmer enjoyed harvesting his ripe crop at the weekend.", + "The elated farmer enjoyed harvesting his ripe crop at the weekend.", ); }); undoRedo.undo(); + expect(next.getHeads(handle.docSync()).length).toBe(2); expect(handle.docSync().text).toBe( - "The silly farmer enjoyed harvesting his ripe crop.", + "The elated farmer enjoyed harvesting his ripe crop.", + ); + }); + + test("a tracked change can be undone at the head of the document even when another untracked change has been made", () => { + const undoRedo = new AutomergeRepoUndoRedo(handle); + undoRedo.change((doc) => { + next.updateText( + doc, + ["text"], + "The jolly farmer enjoyed harvesting his ripe crop at the weekend.", + ); + }); + + handle.change((doc) => { + next.updateText( + doc, + ["text"], + "The elated farmer enjoyed harvesting his ripe crop at the weekend.", + ); + }); + + undoRedo.change((doc) => { + next.updateText( + doc, + ["text"], + "The elated farmer enjoyed reaping his ripe crop at the weekend.", + ); + }); + + undoRedo.undo(); + // this should have been applied to the head of the document, so we have one head + expect(next.getHeads(handle.docSync()).length).toBe(1); + expect(handle.docSync().text).toBe( + "The elated farmer enjoyed harvesting his ripe crop at the weekend.", + ); + + undoRedo.undo(); + // there has been an untracked change here, so the history has been rewritten + expect(next.getHeads(handle.docSync()).length).toBe(2); + expect(handle.docSync().text).toBe( + "The elated farmer enjoyed harvesting his ripe crop.", ); });