See https://codesandbox.io/s/mobx-undo-redo-v2-ofty1 for example.
Last active
February 10, 2022 12:34
-
-
Save steveruizok/96511ddae38858a4afe450ba6bbfb22d to your computer and use it in GitHub Desktop.
Add undo/redo JSON patches to mobx-utils deepObserve method.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { deepObserve, IChange } from "mobx-utils"; | |
import { Patch, getUndoRedoPatch } from "./getUndoRedoPatch"; | |
export type IListenerWithPatches = <T = any>( | |
undo: Patch, | |
redo: Patch, | |
change: IChange, | |
parent: string, | |
root: T | |
) => void; | |
export function deepObserveWithUndoRedoPatches<T>( | |
target: T, | |
listener: IListenerWithPatches | |
) { | |
return deepObserve(target, (change: IChange, parent: string, root: T) => { | |
const { undo, redo } = getUndoRedoPatch(change, parent); | |
listener(undo, redo, change, parent, root); | |
}); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { IChange } from "mobx-utils"; | |
import type { Operation } from "fast-json-patch"; | |
export type Patch = Operation[]; | |
export interface UndoRedoPatch { | |
undo: Patch; | |
redo: Patch; | |
} | |
export function getUndoRedoPatch( | |
change: IChange, | |
parent: string | |
): UndoRedoPatch { | |
const redo: Patch = []; | |
const undo: Patch = []; | |
const { name, index, newValue, oldValue } = change; | |
const path = (parent ? "/" + parent : "") + "/" + (name ?? index); | |
switch (change.type) { | |
case "add": { | |
undo.push({ op: "remove", path }); | |
redo.push({ op: "add", path, value: newValue }); | |
break; | |
} | |
case "update": { | |
undo.push({ op: "replace", path, value: oldValue }); | |
redo.push({ op: "replace", path, value: newValue }); | |
break; | |
} | |
case "remove": | |
case "delete": { | |
undo.push({ op: "add", path, value: oldValue }); | |
redo.push({ op: "remove", path }); | |
break; | |
} | |
case "splice": { | |
const { index, removed, removedCount, added, addedCount } = change; | |
for (let i = 0; i < removedCount; i++) { | |
redo.push({ op: "remove", path }); | |
} | |
for (let i = 0; i < addedCount; i++) { | |
undo.push({ op: "remove", path }); | |
redo.push({ op: "add", path, value: added[i] }); | |
} | |
for (let i = 0; i < removedCount; i++) { | |
const path = (parent ? "/" + parent : "") + "/" + (i + index); | |
undo.push({ op: "add", path, value: removed[i] }); | |
} | |
break; | |
} | |
} | |
return { undo, redo }; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { applyPatch, compare } from "fast-json-patch"; | |
import { action, makeObservable, observable, toJS } from "mobx"; | |
import { | |
Patch, | |
deepObserveWithUndoRedoPatches | |
} from "./deepObserveWithUndoRedoPatches"; | |
type Doc = { | |
name: string; | |
age: number; | |
address: { | |
street: string; | |
city: string; | |
state: string; | |
country?: string; | |
}; | |
inventory: string[]; | |
}; | |
class TestApp { | |
constructor() { | |
makeObservable(this); | |
this._prev = toJS(this.document); | |
this._disposable = deepObserveWithUndoRedoPatches( | |
this.document, | |
(undo, redo) => { | |
if (this._isPatching) return; | |
// Get patch from compare | |
const snapshot = toJS(this.document); | |
this.fjpUndos.push(compare(snapshot, this._prev)); | |
this.fjpRedos.push(compare(this._prev, snapshot)); | |
this._prev = snapshot; | |
// Get patch from our undo / redo methods | |
this.undos.push(undo); | |
this.redos.push(redo); | |
} | |
); | |
} | |
@observable document: Doc = { | |
name: "steve", | |
age: 93, | |
address: { | |
street: "12 Monroe Ave", | |
city: "Chicago", | |
state: "Illinois" | |
}, | |
inventory: ["keys", "phone", "wallet"] | |
}; | |
redos: Patch[] = []; | |
undos: Patch[] = []; | |
fjpRedos: Patch[] = []; | |
fjpUndos: Patch[] = []; | |
_disposable: () => void; | |
_isPatching = false; | |
_prev: Doc; | |
@action update = (fn: (doc: Doc) => void) => { | |
fn(this.document); | |
}; | |
@action patch = (patch: Patch) => { | |
this._isPatching = true; | |
applyPatch(this.document, patch); | |
this._isPatching = false; | |
}; | |
} | |
describe("The test app", () => { | |
it("Updates the document", () => { | |
const app = new TestApp(); | |
app.update((d) => d.age++); | |
expect(app.document.age).toBe(94); | |
}); | |
it("Creates undos and redos", () => { | |
const app = new TestApp(); | |
app.update((d) => d.age++); | |
expect(app.undos.length).toBe(1); | |
expect(app.redos.length).toBe(1); | |
app.update((d) => d.age++); | |
expect(app.undos.length).toBe(2); | |
expect(app.redos.length).toBe(2); | |
}); | |
}); | |
describe("When replacing...", () => { | |
it("Creates the correct patches", () => { | |
const app = new TestApp(); | |
app.update((d) => (d.age = 4)); | |
expect(app.document.age).toBe(4); | |
expect(app.undos[0]).toMatchObject([ | |
{ | |
op: "replace", | |
path: "/age", | |
value: 93 | |
} | |
]); | |
expect(app.redos[0]).toMatchObject([ | |
{ | |
op: "replace", | |
path: "/age", | |
value: 4 | |
} | |
]); | |
}); | |
it("undoes and redoes correctly", () => { | |
const app = new TestApp(); | |
app.update((d) => (d.age = 4)); | |
expect(app.document.age).toBe(4); | |
app.patch(app.undos[0]); | |
expect(app.document.age).toBe(93); | |
app.patch(app.redos[0]); | |
expect(app.document.age).toBe(4); | |
}); | |
it("produces the same result as fjp", () => { | |
const ctrl = new TestApp(); | |
ctrl.update((d) => (d.age = 4)); | |
ctrl.patch(ctrl.fjpUndos[0]); | |
ctrl.patch(ctrl.fjpRedos[0]); | |
const app = new TestApp(); | |
app.update((d) => (d.age = 4)); | |
app.patch(app.undos[0]); | |
app.patch(app.redos[0]); | |
expect(app.document).toMatchObject(ctrl.document); | |
}); | |
}); | |
describe("When adding...", () => { | |
it("Creates the correct patches", () => { | |
const app = new TestApp(); | |
app.update((d) => (d.address.country = "United States")); | |
expect(app.document.address.country).toBe("United States"); | |
expect(app.undos[0]).toMatchObject([ | |
{ | |
op: "remove", | |
path: "/address/country" | |
} | |
]); | |
expect(app.redos[0]).toMatchObject([ | |
{ | |
op: "add", | |
path: "/address/country", | |
value: "United States" | |
} | |
]); | |
}); | |
it("undoes and redoes correctly", () => { | |
const app = new TestApp(); | |
app.update((d) => (d.address.country = "United States")); | |
expect(app.document.address.country).toBe("United States"); | |
app.patch(app.undos[0]); | |
expect(app.document.address.country).toBeUndefined(); | |
app.patch(app.redos[0]); | |
expect(app.document.address.country).toBe("United States"); | |
}); | |
it("produces the same result as fjp", () => { | |
const ctrl = new TestApp(); | |
ctrl.update((d) => (d.address.country = "United States")); | |
ctrl.patch(ctrl.fjpUndos[0]); | |
ctrl.patch(ctrl.fjpRedos[0]); | |
const app = new TestApp(); | |
app.update((d) => (d.address.country = "United States")); | |
app.patch(app.undos[0]); | |
app.patch(app.redos[0]); | |
expect(app.document).toMatchObject(ctrl.document); | |
}); | |
}); | |
describe("When updating...", () => { | |
it("Creates the correct patches", () => { | |
const app = new TestApp(); | |
app.update((d) => (d.inventory = ["sand"])); | |
expect(app.document.inventory).toMatchObject(["sand"]); | |
expect(app.undos[0]).toMatchObject([ | |
{ | |
op: "replace", | |
path: "/inventory", | |
value: ["keys", "phone", "wallet"] | |
} | |
]); | |
expect(app.redos[0]).toMatchObject([ | |
{ | |
op: "replace", | |
path: "/inventory", | |
value: ["sand"] | |
} | |
]); | |
}); | |
}); | |
describe("When deleting...", () => { | |
it("Creates the correct patches", () => { | |
const app = new TestApp(); | |
app.update((d) => delete d.inventory); | |
expect(app.document.inventory).toBeUndefined(); | |
expect(app.undos[0]).toMatchObject([ | |
{ | |
op: "add", | |
path: "/inventory", | |
value: ["keys", "phone", "wallet"] | |
} | |
]); | |
expect(app.redos[0]).toMatchObject([ | |
{ | |
op: "remove", | |
path: "/inventory" | |
} | |
]); | |
}); | |
it("undoes and redoes correctly", () => { | |
const app = new TestApp(); | |
app.update((d) => delete d.inventory); | |
expect(app.document.inventory).toBeUndefined(); | |
app.patch(app.undos[0]); | |
expect(app.document.inventory).toMatchObject(["keys", "phone", "wallet"]); | |
app.patch(app.redos[0]); | |
expect(app.document.inventory).toBeUndefined(); | |
}); | |
it("produces the same result as fjp", () => { | |
const ctrl = new TestApp(); | |
ctrl.update((d) => delete d.inventory); | |
ctrl.patch(ctrl.fjpUndos[0]); | |
ctrl.patch(ctrl.fjpRedos[0]); | |
const app = new TestApp(); | |
app.update((d) => delete d.inventory); | |
app.patch(app.undos[0]); | |
app.patch(app.redos[0]); | |
expect(app.document).toMatchObject(ctrl.document); | |
}); | |
}); | |
describe("When splicing...", () => { | |
it("Creates the correct patches", () => { | |
const app = new TestApp(); | |
app.update((d) => d.inventory.splice(1, 2, "sand")); | |
expect(app.document.inventory).toMatchObject(["keys", "sand"]); | |
expect(app.undos[0]).toMatchObject([ | |
{ | |
op: "remove", | |
path: "/inventory/1" | |
}, | |
{ | |
op: "add", | |
path: "/inventory/1", | |
value: "phone" | |
}, | |
{ | |
op: "add", | |
path: "/inventory/2", | |
value: "wallet" | |
} | |
]); | |
expect(app.redos[0]).toMatchObject([ | |
{ | |
op: "remove", | |
path: "/inventory/1" | |
}, | |
{ | |
op: "remove", | |
path: "/inventory/1" | |
}, | |
{ | |
op: "add", | |
path: "/inventory/1", | |
value: "sand" | |
} | |
]); | |
}); | |
it("undoes and redoes correctly", () => { | |
const app = new TestApp(); | |
app.update((d) => d.inventory.splice(1, 2, "sand")); | |
expect(app.document.inventory).toMatchObject(["keys", "sand"]); | |
app.patch(app.undos[0]); | |
expect(app.document.inventory).toMatchObject(["keys", "phone", "wallet"]); | |
app.patch(app.redos[0]); | |
expect(app.document.inventory).toMatchObject(["keys", "sand"]); | |
}); | |
it("produces the same result as fjp", () => { | |
const ctrl = new TestApp(); | |
ctrl.update((d) => d.inventory.splice(1, 2, "sand")); | |
ctrl.patch(ctrl.fjpUndos[0]); | |
ctrl.patch(ctrl.fjpRedos[0]); | |
const app = new TestApp(); | |
app.update((d) => d.inventory.splice(1, 2, "sand")); | |
app.patch(app.undos[0]); | |
app.patch(app.redos[0]); | |
expect(app.document).toMatchObject(ctrl.document); | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment