Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Peritext undo/redo stacks #834

Merged
merged 33 commits into from
Mar 17, 2025
Merged
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ce6cbac
feat(json-crdt-extensions): 🎸 report insertion spans from Editor
streamich Mar 12, 2025
31897c8
chore(json-crdt-peritext-ui): 🤖 setup undo-redo implementation
streamich Mar 12, 2025
b6f1119
feat(json-crdt-peritext-ui): 🎸 update undo setup
streamich Mar 12, 2025
4e7b835
chore(json-crdt-peritext-ui): 🤖 add autoflush to Peritext demo
streamich Mar 12, 2025
6770ffc
feat(json-crdt): 🎸 add ability to replay log until patch non-inclusively
streamich Mar 13, 2025
d34dca8
feat(json-crdt): 🎸 start .undo() implementation
streamich Mar 14, 2025
a7a2e76
feat(json-crdt): 🎸 allow immediate drain on autoflush
streamich Mar 14, 2025
53b50fa
feat(json-crdt-peritext-ui): 🎸 setup new undo controller
streamich Mar 14, 2025
d9a9ab4
refactor(json-crdt-peritext-ui): 💡 cleanup undo/redo controller code
streamich Mar 14, 2025
d494e05
feat(json-crdt-peritext-ui): 🎸 improve undo manager integration
streamich Mar 15, 2025
d95814a
feat(json-crdt-peritext-ui): 🎸 introduce history undo/redo annals event
streamich Mar 15, 2025
29633d4
feat(json-crdt): 🎸 improve Log construction API
streamich Mar 15, 2025
0e9e083
feat(json-crdt-peritext-ui): 🎸 progress on undo manager implementation
streamich Mar 15, 2025
c777adf
feat(json-crdt-peritext-ui): 🎸 implement history redo method
streamich Mar 15, 2025
7844476
perf(json-crdt-peritext-ui): ⚡️ do not track of undo element text length
streamich Mar 15, 2025
893d6d3
feat(json-crdt-peritext-ui): 🎸 cleanup undo history implementations
streamich Mar 15, 2025
91d117a
feat(json-crdt-peritext-ui): 🎸 add undo/redo top toolbar buttons
streamich Mar 15, 2025
073c3e5
refactor(json-crdt-peritext-ui): 💡 update class naming
streamich Mar 15, 2025
45759a1
feat(json-crdt-peritext-ui): 🎸 add history trackign to more events
streamich Mar 15, 2025
3350468
feat(json-crdt-peritext-ui): 🎸 save selection and restore it
streamich Mar 16, 2025
4947cee
test: 💍 fix all tests
streamich Mar 16, 2025
a02f931
style: 💄 fix linter issues
streamich Mar 16, 2025
38978f9
feat(json-crdt-peritext-ui): 🎸 implement in-memory undo manager
streamich Mar 16, 2025
bcaed96
feat(json-crdt-peritext-ui): 🎸 print debug text for annals controller
streamich Mar 16, 2025
c90a0a7
fix(json-crdt): 🐛 correct Log.undo() for LWW nodes
streamich Mar 16, 2025
80603ae
feat(json-crdt): 🎸 add ability to retireve a partial view of as span …
streamich Mar 17, 2025
5905bfd
feat(json-crdt): 🎸 implement .prevId() utility
streamich Mar 17, 2025
348ab2c
feat(json-crdt): 🎸 implement string deletion undo in Log
streamich Mar 17, 2025
c33a0a5
feat(json-crdt): 🎸 implement "bin" node deletion undo
streamich Mar 17, 2025
d6d564d
feat(json-crdt): 🎸 implement "arr" node undo
streamich Mar 17, 2025
f873360
refactor(json-crdt-peritext-ui): 💡 update annals event interface
streamich Mar 17, 2025
a0c565e
feat(json-crdt-peritext-ui): 🎸 adjust cursor on text insert/delte und…
streamich Mar 17, 2025
863893b
style: 💄 fix linter issues
streamich Mar 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ import {render} from './render';

const runInlineSlicesTests = (
desc: string,
insertNumbers = (editor: Editor) => editor.insert('abcdefghijklmnopqrstuvwxyz'),
insertNumbers = (editor: Editor) => void editor.insert('abcdefghijklmnopqrstuvwxyz'),
) => {
const setup = () => {
const model = Model.withLogicalClock();
13 changes: 9 additions & 4 deletions src/json-crdt-extensions/peritext/editor/Editor.ts
Original file line number Diff line number Diff line change
@@ -12,7 +12,7 @@ import {PersistedSlice} from '../slice/PersistedSlice';
import {ValueSyncStore} from '../../../util/events/sync-store';
import {formatType} from '../slice/util';
import {CommonSliceType, type SliceType} from '../slice';
import {tick} from '../../../json-crdt-patch';
import {tick, Timespan, type ITimespanStruct} from '../../../json-crdt-patch';
import type {ChunkSlice} from '../util/ChunkSlice';
import type {Peritext} from '../Peritext';
import type {Point} from '../rga/Point';
@@ -182,26 +182,30 @@ export class Editor<T = string> implements Printable {
* Insert inline text at current cursor position. If cursor selects a range,
* the range is removed and the text is inserted at the start of the range.
*/
public insert0(cursor: Cursor<T>, text: string): void {
public insert0(cursor: Cursor<T>, text: string): ITimespanStruct | undefined {
if (!text) return;
if (!cursor.isCollapsed()) this.delRange(cursor);
const after = cursor.start.clone();
after.refAfter();
const txt = this.txt;
const textId = txt.ins(after.id, text);
const span = new Timespan(textId.sid, textId.time, text.length);
const shift = text.length - 1;
const point = txt.point(shift ? tick(textId, shift) : textId, Anchor.After);
cursor.set(point, point, CursorAnchor.Start);
return span;
}

/**
* Inserts text at the cursor positions and collapses cursors, if necessary.
* The applies any pending inline formatting to the inserted text.
*/
public insert(text: string): void {
public insert(text: string): ITimespanStruct[] {
const spans: ITimespanStruct[] = [];
if (!this.hasCursor()) this.addCursor();
for (let cursor: Cursor<T> | undefined, i = this.cursors0(); (cursor = i()); ) {
this.insert0(cursor, text);
const span = this.insert0(cursor, text);
if (span) spans.push(span);
const pending = this.pending.value;
if (pending.size) {
this.pending.next(new Map());
@@ -211,6 +215,7 @@ export class Editor<T = string> implements Printable {
for (const [type, data] of pending) this.toggleRangeExclFmt(range, type, data);
}
}
return spans;
}

/**
Original file line number Diff line number Diff line change
@@ -320,7 +320,12 @@ const runTestsWithAlphabetKit = (setup: () => Kit) => {

runAlphabetKitTestSuite(runTestsWithAlphabetKit);

const setup = (insert = (editor: Editor) => editor.insert('Hello world!'), sid?: number) => {
const setup = (
insert = (editor: Editor) => {
editor.insert('Hello world!');
},
sid?: number,
) => {
const model = Model.create(void 0, sid);
model.api.root({
text: '',
Original file line number Diff line number Diff line change
@@ -4,7 +4,12 @@ import {Anchor} from '../../rga/constants';
import {CursorAnchor} from '../../slice/constants';
import type {Editor} from '../Editor';

const setup = (insert = (editor: Editor) => editor.insert('Hello world!'), sid?: number) => {
const setup = (
insert = (editor: Editor<string>) => {
editor.insert('Hello world!');
},
sid?: number,
) => {
const model = Model.create(void 0, sid);
model.api.root({
text: '',
7 changes: 7 additions & 0 deletions src/json-crdt-peritext-ui/__demos__/components/App.tsx
Original file line number Diff line number Diff line change
@@ -30,6 +30,13 @@ export const App: React.FC = () => {
return [model, peritext] as const;
});

React.useEffect(() => {
model.api.autoFlush(true);
return () => {
model.api.stopAutoFlush?.();
};
}, [model]);

const plugins = React.useMemo(() => {
const cursorPlugin = new CursorPlugin();
const toolbarPlugin = new ToolbarPlugin();
20 changes: 14 additions & 6 deletions src/json-crdt-peritext-ui/dom/DomController.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import {printTree, type Printable} from 'tree-dump';
import {InputController} from '../dom/InputController';
import {CursorController} from '../dom/CursorController';
import {RichTextController} from '../dom/RichTextController';
import {KeyController} from '../dom/KeyController';
import {CompositionController} from '../dom/CompositionController';
import {InputController} from './InputController';
import {CursorController} from './CursorController';
import {RichTextController} from './RichTextController';
import {KeyController} from './KeyController';
import {CompositionController} from './CompositionController';
import {AnnalsController} from './annals/AnnalsController';
import type {PeritextEventDefaults} from '../events/defaults/PeritextEventDefaults';
import type {PeritextEventTarget} from '../events/PeritextEventTarget';
import type {PeritextRenderingSurfaceApi, UiLifeCycles} from '../dom/types';
import type {Log} from '../../json-crdt/log/Log';

export interface DomControllerOpts {
source: HTMLElement;
events: PeritextEventDefaults;
log: Log;
}

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

constructor(public readonly opts: DomControllerOpts) {
const {source, events} = opts;
const {source, events, log} = opts;
const {txt} = events;
const et = (this.et = opts.events.et);
const keys = (this.keys = new KeyController({source}));
const comp = (this.comp = new CompositionController({et, source, txt}));
this.input = new InputController({et, source, txt, comp});
this.cursor = new CursorController({et, source, txt, keys});
this.richText = new RichTextController({et, source, txt});
this.annals = new AnnalsController({et, txt, log});
}

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

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

/** ----------------------------------- {@link PeritextRenderingSurfaceApi} */
@@ -65,6 +72,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering
(tab) => this.cursor.toString(tab),
(tab) => this.keys.toString(tab),
(tab) => this.comp.toString(tab),
(tab) => this.annals.toString(tab),
])
);
}
90 changes: 90 additions & 0 deletions src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import {WebUndo} from './WebUndo';
import {printTree, type Printable} from 'tree-dump';
import type {Patch} from '../../../json-crdt-patch';
import type {Peritext} from '../../../json-crdt-extensions';
import type {UiLifeCycles} from '../types';
import type {RedoCallback, RedoItem, UndoCallback, UndoCollector, UndoItem} from '../../types';
import type {Log} from '../../../json-crdt/log/Log';
import type {PeritextEventTarget} from '../../events/PeritextEventTarget';

export interface UndoRedoControllerOpts {
log: Log;
txt: Peritext;
et: PeritextEventTarget;
}

export class AnnalsController implements UndoCollector, UiLifeCycles, Printable {
protected manager = new WebUndo();

constructor(public readonly opts: UndoRedoControllerOpts) {}

protected captured = new WeakSet<Patch>();

/** ------------------------------------------------- {@link UndoCollector} */

public capture(): void {
const currentPatch = this.opts.txt.model.api.builder.patch;
this.captured.add(currentPatch);
}

public undo(): void {
this.manager.undo();
}

public redo(): void {
this.manager.redo();
}

/** -------------------------------------------------- {@link UiLifeCycles} */

public start(): void {
this.manager.start();
const {opts, captured} = this;
const {txt} = opts;
txt.model.api.onFlush.listen((patch) => {
const isCaptured = captured.has(patch);
if (isCaptured) {
captured.delete(patch);
const item: UndoItem<Patch, Patch> = [patch, this._undo];
this.manager.push(item);
}
});
}

public stop(): void {
this.manager.stop();
}

public readonly _undo: UndoCallback<Patch, Patch> = (doPatch: Patch) => {
const {log, et} = this.opts;
const patch = log.undo(doPatch);
et.dispatch('annals', {
action: 'undo',
batch: [patch],
});
// console.log('doPatch', doPatch + '');
// console.log('undoPatch', patch + '');
return [doPatch, this._redo] as RedoItem<Patch, Patch>;
};

public readonly _redo: RedoCallback<Patch, Patch> = (doPatch: Patch) => {
const {log, et} = this.opts;
const redoPatch = doPatch.rebase(log.end.clock.time);
et.dispatch('annals', {
action: 'redo',
batch: [redoPatch],
});
// console.log('doPatch', doPatch + '');
// console.log('redoPatch', redoPatch + '');
return [redoPatch, this._undo] as RedoItem<Patch, Patch>;
};

/** ----------------------------------------------------- {@link Printable} */

public toString(tab?: string): string {
return (
'annals' +
printTree(tab, [(tab) => 'undo: ' + this.manager.uStack.length, (tab) => 'redo: ' + this.manager.rStack.length])
);
}
}
40 changes: 40 additions & 0 deletions src/json-crdt-peritext-ui/dom/annals/MemoryUndo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type {UndoManager, UndoItem} from '../../types';
import type {UiLifeCycles} from '../types';

/**
* A Memory-based undo manager.
*/
export class MemoryUndo implements UndoManager, UiLifeCycles {
/** Undo stack. */
public uStack: UndoItem[] = [];
/** Redo stack. */
public rStack: UndoItem[] = [];

// /** ------------------------------------------------------ {@link UndoRedo} */

public push<U, R>(undo: UndoItem<U, R>): void {
this.rStack = [];
this.uStack.push(undo as UndoItem);
}

undo(): void {
const undo = this.uStack.pop();
if (undo) {
const redo = undo[1](undo[0]);
this.rStack.push(redo);
}
}

redo(): void {
const redo = this.rStack.pop();
if (redo) {
const undo = redo[1](redo[0]);
this.uStack.push(undo);
}
}

/** -------------------------------------------------- {@link UiLifeCycles} */

public start(): void {}
public stop(): void {}
}
Loading