Skip to content

Commit 61aea75

Browse files
authored
Merge pull request #834 from streamich/undo-redo
Peritext undo/redo stacks
2 parents fd73f01 + 863893b commit 61aea75

File tree

25 files changed

+1066
-106
lines changed

25 files changed

+1066
-106
lines changed

src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {render} from './render';
55

66
const runInlineSlicesTests = (
77
desc: string,
8-
insertNumbers = (editor: Editor) => editor.insert('abcdefghijklmnopqrstuvwxyz'),
8+
insertNumbers = (editor: Editor) => void editor.insert('abcdefghijklmnopqrstuvwxyz'),
99
) => {
1010
const setup = () => {
1111
const model = Model.withLogicalClock();

src/json-crdt-extensions/peritext/editor/Editor.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {PersistedSlice} from '../slice/PersistedSlice';
1212
import {ValueSyncStore} from '../../../util/events/sync-store';
1313
import {formatType} from '../slice/util';
1414
import {CommonSliceType, type SliceType} from '../slice';
15-
import {tick} from '../../../json-crdt-patch';
15+
import {tick, Timespan, type ITimespanStruct} from '../../../json-crdt-patch';
1616
import type {ChunkSlice} from '../util/ChunkSlice';
1717
import type {Peritext} from '../Peritext';
1818
import type {Point} from '../rga/Point';
@@ -182,26 +182,30 @@ export class Editor<T = string> implements Printable {
182182
* Insert inline text at current cursor position. If cursor selects a range,
183183
* the range is removed and the text is inserted at the start of the range.
184184
*/
185-
public insert0(cursor: Cursor<T>, text: string): void {
185+
public insert0(cursor: Cursor<T>, text: string): ITimespanStruct | undefined {
186186
if (!text) return;
187187
if (!cursor.isCollapsed()) this.delRange(cursor);
188188
const after = cursor.start.clone();
189189
after.refAfter();
190190
const txt = this.txt;
191191
const textId = txt.ins(after.id, text);
192+
const span = new Timespan(textId.sid, textId.time, text.length);
192193
const shift = text.length - 1;
193194
const point = txt.point(shift ? tick(textId, shift) : textId, Anchor.After);
194195
cursor.set(point, point, CursorAnchor.Start);
196+
return span;
195197
}
196198

197199
/**
198200
* Inserts text at the cursor positions and collapses cursors, if necessary.
199201
* The applies any pending inline formatting to the inserted text.
200202
*/
201-
public insert(text: string): void {
203+
public insert(text: string): ITimespanStruct[] {
204+
const spans: ITimespanStruct[] = [];
202205
if (!this.hasCursor()) this.addCursor();
203206
for (let cursor: Cursor<T> | undefined, i = this.cursors0(); (cursor = i()); ) {
204-
this.insert0(cursor, text);
207+
const span = this.insert0(cursor, text);
208+
if (span) spans.push(span);
205209
const pending = this.pending.value;
206210
if (pending.size) {
207211
this.pending.next(new Map());
@@ -211,6 +215,7 @@ export class Editor<T = string> implements Printable {
211215
for (const [type, data] of pending) this.toggleRangeExclFmt(range, type, data);
212216
}
213217
}
218+
return spans;
214219
}
215220

216221
/**

src/json-crdt-extensions/peritext/editor/__tests__/Editor-movement.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,12 @@ const runTestsWithAlphabetKit = (setup: () => Kit) => {
320320

321321
runAlphabetKitTestSuite(runTestsWithAlphabetKit);
322322

323-
const setup = (insert = (editor: Editor) => editor.insert('Hello world!'), sid?: number) => {
323+
const setup = (
324+
insert = (editor: Editor) => {
325+
editor.insert('Hello world!');
326+
},
327+
sid?: number,
328+
) => {
324329
const model = Model.create(void 0, sid);
325330
model.api.root({
326331
text: '',

src/json-crdt-extensions/peritext/editor/__tests__/Editor-selection.spec.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@ import {Anchor} from '../../rga/constants';
44
import {CursorAnchor} from '../../slice/constants';
55
import type {Editor} from '../Editor';
66

7-
const setup = (insert = (editor: Editor) => editor.insert('Hello world!'), sid?: number) => {
7+
const setup = (
8+
insert = (editor: Editor<string>) => {
9+
editor.insert('Hello world!');
10+
},
11+
sid?: number,
12+
) => {
813
const model = Model.create(void 0, sid);
914
model.api.root({
1015
text: '',

src/json-crdt-peritext-ui/__demos__/components/App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ export const App: React.FC = () => {
3030
return [model, peritext] as const;
3131
});
3232

33+
React.useEffect(() => {
34+
model.api.autoFlush(true);
35+
return () => {
36+
model.api.stopAutoFlush?.();
37+
};
38+
}, [model]);
39+
3340
const plugins = React.useMemo(() => {
3441
const cursorPlugin = new CursorPlugin();
3542
const toolbarPlugin = new ToolbarPlugin();

src/json-crdt-peritext-ui/dom/DomController.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import {printTree, type Printable} from 'tree-dump';
2-
import {InputController} from '../dom/InputController';
3-
import {CursorController} from '../dom/CursorController';
4-
import {RichTextController} from '../dom/RichTextController';
5-
import {KeyController} from '../dom/KeyController';
6-
import {CompositionController} from '../dom/CompositionController';
2+
import {InputController} from './InputController';
3+
import {CursorController} from './CursorController';
4+
import {RichTextController} from './RichTextController';
5+
import {KeyController} from './KeyController';
6+
import {CompositionController} from './CompositionController';
7+
import {AnnalsController} from './annals/AnnalsController';
78
import type {PeritextEventDefaults} from '../events/defaults/PeritextEventDefaults';
89
import type {PeritextEventTarget} from '../events/PeritextEventTarget';
910
import type {PeritextRenderingSurfaceApi, UiLifeCycles} from '../dom/types';
11+
import type {Log} from '../../json-crdt/log/Log';
1012

1113
export interface DomControllerOpts {
1214
source: HTMLElement;
1315
events: PeritextEventDefaults;
16+
log: Log;
1417
}
1518

1619
export class DomController implements UiLifeCycles, Printable, PeritextRenderingSurfaceApi {
@@ -20,16 +23,18 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering
2023
public readonly input: InputController;
2124
public readonly cursor: CursorController;
2225
public readonly richText: RichTextController;
26+
public readonly annals: AnnalsController;
2327

2428
constructor(public readonly opts: DomControllerOpts) {
25-
const {source, events} = opts;
29+
const {source, events, log} = opts;
2630
const {txt} = events;
2731
const et = (this.et = opts.events.et);
2832
const keys = (this.keys = new KeyController({source}));
2933
const comp = (this.comp = new CompositionController({et, source, txt}));
3034
this.input = new InputController({et, source, txt, comp});
3135
this.cursor = new CursorController({et, source, txt, keys});
3236
this.richText = new RichTextController({et, source, txt});
37+
this.annals = new AnnalsController({et, txt, log});
3338
}
3439

3540
/** -------------------------------------------------- {@link UiLifeCycles} */
@@ -40,6 +45,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering
4045
this.input.start();
4146
this.cursor.start();
4247
this.richText.start();
48+
this.annals.start();
4349
}
4450

4551
public stop(): void {
@@ -48,6 +54,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering
4854
this.input.stop();
4955
this.cursor.stop();
5056
this.richText.stop();
57+
this.annals.stop();
5158
}
5259

5360
/** ----------------------------------- {@link PeritextRenderingSurfaceApi} */
@@ -65,6 +72,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering
6572
(tab) => this.cursor.toString(tab),
6673
(tab) => this.keys.toString(tab),
6774
(tab) => this.comp.toString(tab),
75+
(tab) => this.annals.toString(tab),
6876
])
6977
);
7078
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {WebUndo} from './WebUndo';
2+
import {printTree, type Printable} from 'tree-dump';
3+
import type {Patch} from '../../../json-crdt-patch';
4+
import type {Peritext} from '../../../json-crdt-extensions';
5+
import type {UiLifeCycles} from '../types';
6+
import type {RedoCallback, RedoItem, UndoCallback, UndoCollector, UndoItem} from '../../types';
7+
import type {Log} from '../../../json-crdt/log/Log';
8+
import type {PeritextEventTarget} from '../../events/PeritextEventTarget';
9+
10+
export interface UndoRedoControllerOpts {
11+
log: Log;
12+
txt: Peritext;
13+
et: PeritextEventTarget;
14+
}
15+
16+
export class AnnalsController implements UndoCollector, UiLifeCycles, Printable {
17+
protected manager = new WebUndo();
18+
19+
constructor(public readonly opts: UndoRedoControllerOpts) {}
20+
21+
protected captured = new WeakSet<Patch>();
22+
23+
/** ------------------------------------------------- {@link UndoCollector} */
24+
25+
public capture(): void {
26+
const currentPatch = this.opts.txt.model.api.builder.patch;
27+
this.captured.add(currentPatch);
28+
}
29+
30+
public undo(): void {
31+
this.manager.undo();
32+
}
33+
34+
public redo(): void {
35+
this.manager.redo();
36+
}
37+
38+
/** -------------------------------------------------- {@link UiLifeCycles} */
39+
40+
public start(): void {
41+
this.manager.start();
42+
const {opts, captured} = this;
43+
const {txt} = opts;
44+
txt.model.api.onFlush.listen((patch) => {
45+
const isCaptured = captured.has(patch);
46+
if (isCaptured) {
47+
captured.delete(patch);
48+
const item: UndoItem<Patch, Patch> = [patch, this._undo];
49+
this.manager.push(item);
50+
}
51+
});
52+
}
53+
54+
public stop(): void {
55+
this.manager.stop();
56+
}
57+
58+
public readonly _undo: UndoCallback<Patch, Patch> = (doPatch: Patch) => {
59+
const {log, et} = this.opts;
60+
const patch = log.undo(doPatch);
61+
et.dispatch('annals', {
62+
action: 'undo',
63+
batch: [patch],
64+
});
65+
// console.log('doPatch', doPatch + '');
66+
// console.log('undoPatch', patch + '');
67+
return [doPatch, this._redo] as RedoItem<Patch, Patch>;
68+
};
69+
70+
public readonly _redo: RedoCallback<Patch, Patch> = (doPatch: Patch) => {
71+
const {log, et} = this.opts;
72+
const redoPatch = doPatch.rebase(log.end.clock.time);
73+
et.dispatch('annals', {
74+
action: 'redo',
75+
batch: [redoPatch],
76+
});
77+
// console.log('doPatch', doPatch + '');
78+
// console.log('redoPatch', redoPatch + '');
79+
return [redoPatch, this._undo] as RedoItem<Patch, Patch>;
80+
};
81+
82+
/** ----------------------------------------------------- {@link Printable} */
83+
84+
public toString(tab?: string): string {
85+
return (
86+
'annals' +
87+
printTree(tab, [(tab) => 'undo: ' + this.manager.uStack.length, (tab) => 'redo: ' + this.manager.rStack.length])
88+
);
89+
}
90+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type {UndoManager, UndoItem} from '../../types';
2+
import type {UiLifeCycles} from '../types';
3+
4+
/**
5+
* A Memory-based undo manager.
6+
*/
7+
export class MemoryUndo implements UndoManager, UiLifeCycles {
8+
/** Undo stack. */
9+
public uStack: UndoItem[] = [];
10+
/** Redo stack. */
11+
public rStack: UndoItem[] = [];
12+
13+
// /** ------------------------------------------------------ {@link UndoRedo} */
14+
15+
public push<U, R>(undo: UndoItem<U, R>): void {
16+
this.rStack = [];
17+
this.uStack.push(undo as UndoItem);
18+
}
19+
20+
undo(): void {
21+
const undo = this.uStack.pop();
22+
if (undo) {
23+
const redo = undo[1](undo[0]);
24+
this.rStack.push(redo);
25+
}
26+
}
27+
28+
redo(): void {
29+
const redo = this.rStack.pop();
30+
if (redo) {
31+
const undo = redo[1](redo[0]);
32+
this.uStack.push(undo);
33+
}
34+
}
35+
36+
/** -------------------------------------------------- {@link UiLifeCycles} */
37+
38+
public start(): void {}
39+
public stop(): void {}
40+
}

0 commit comments

Comments
 (0)