From ce6cbac42359078fabde30c1d89190ed1737fcea Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 12 Mar 2025 15:41:05 +0100 Subject: [PATCH 01/33] =?UTF-8?q?feat(json-crdt-extensions):=20?= =?UTF-8?q?=F0=9F=8E=B8=20report=20insertion=20spans=20from=20Editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-extensions/peritext/editor/Editor.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/json-crdt-extensions/peritext/editor/Editor.ts b/src/json-crdt-extensions/peritext/editor/Editor.ts index 2278d3fb56..b1ed858861 100644 --- a/src/json-crdt-extensions/peritext/editor/Editor.ts +++ b/src/json-crdt-extensions/peritext/editor/Editor.ts @@ -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 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, text: string): void { + public insert0(cursor: Cursor, 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 | 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 implements Printable { for (const [type, data] of pending) this.toggleRangeExclFmt(range, type, data); } } + return spans; } /** From 31897c8ab9d1c9e1d3fd65ad9f189a474c14c0f6 Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 12 Mar 2025 15:42:20 +0100 Subject: [PATCH 02/33] =?UTF-8?q?chore(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=A4=96=20setup=20undo-redo=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../events/defaults/PeritextEventDefaults.ts | 32 ++++++- src/json-crdt-peritext-ui/events/index.ts | 4 +- .../events/undo/DomUndoRedo.ts | 84 +++++++++++++++++++ .../events/undo/types.ts | 5 ++ 4 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 src/json-crdt-peritext-ui/events/undo/DomUndoRedo.ts create mode 100644 src/json-crdt-peritext-ui/events/undo/types.ts diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index 43f19b9ee5..c9a6ff11fa 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -1,4 +1,5 @@ import {CursorAnchor} from '../../../json-crdt-extensions/peritext/slice/constants'; +import type {Point} from '../../../json-crdt-extensions/peritext/rga/Point'; import type {Range} from '../../../json-crdt-extensions/peritext/rga/Range'; import type {PeritextDataTransfer} from '../../../json-crdt-extensions/peritext/transfer/PeritextDataTransfer'; import type {PeritextEventHandlerMap, PeritextEventTarget} from '../PeritextEventTarget'; @@ -6,12 +7,15 @@ import type {Peritext} from '../../../json-crdt-extensions/peritext'; import type {EditorSlices} from '../../../json-crdt-extensions/peritext/editor/EditorSlices'; import type * as events from '../types'; import type {PeritextClipboard, PeritextClipboardData} from '../clipboard/types'; +import type {ITimespanStruct} from '../../../json-crdt-patch'; +import type {UndoRedo} from '../undo/types'; const toText = (buf: Uint8Array) => new TextDecoder().decode(buf); export interface PeritextEventDefaultsOpts { clipboard?: PeritextClipboard; transfer?: PeritextDataTransfer; + undo?: UndoRedo; } /** @@ -30,9 +34,35 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { public readonly change = (event: CustomEvent) => {}; + private insertUndo = (text: string, after: Point[], insertions: ITimespanStruct[]) => { + const undo = () => { + // Delete `insertions`. + const redo = this.insertRedo(text, after); + return redo; + }; + return undo; + }; + + private insertRedo = (text: string, after: Point[]) => { + const redo = () => { + // Insert text. + const insertions: ITimespanStruct[] = []; + const undo = this.insertUndo(text, after, insertions); + return undo; + }; + return redo; + }; + public readonly insert = (event: CustomEvent) => { const text = event.detail.text; - this.txt.editor.insert(text); + const editor = this.txt.editor; + const insertions: ITimespanStruct[] = editor.insert(text); + const after: Point[] = []; + editor.forCursor(cursor => { + after.push(cursor.start.clone()); + }); + this.opts.undo?.do([123]); + console.log('insertions', insertions, 'after', after); }; public readonly delete = (event: CustomEvent) => { diff --git a/src/json-crdt-peritext-ui/events/index.ts b/src/json-crdt-peritext-ui/events/index.ts index 3cff2b9552..87059d1062 100644 --- a/src/json-crdt-peritext-ui/events/index.ts +++ b/src/json-crdt-peritext-ui/events/index.ts @@ -2,6 +2,7 @@ import {PeritextEventDefaults, type PeritextEventDefaultsOpts} from './defaults/ import {PeritextEventTarget} from './PeritextEventTarget'; import {DomClipboard} from './clipboard/DomClipboard'; import {create as createDataTransfer} from '../../json-crdt-extensions/peritext/transfer/create'; +import {DomUndoRedo} from './undo/DomUndoRedo'; import type {Peritext} from '../../json-crdt-extensions'; export const create = (txt: Peritext) => { @@ -11,7 +12,8 @@ export const create = (txt: Peritext) => { ? new DomClipboard(navigator.clipboard) : undefined; const transfer = createDataTransfer(txt); - const defaults = new PeritextEventDefaults(txt, et, {clipboard, transfer}); + const undo = new DomUndoRedo(); + const defaults = new PeritextEventDefaults(txt, et, {clipboard, transfer, undo}); et.defaults = defaults; return defaults; }; diff --git a/src/json-crdt-peritext-ui/events/undo/DomUndoRedo.ts b/src/json-crdt-peritext-ui/events/undo/DomUndoRedo.ts new file mode 100644 index 0000000000..9a66f412f7 --- /dev/null +++ b/src/json-crdt-peritext-ui/events/undo/DomUndoRedo.ts @@ -0,0 +1,84 @@ +import type {Printable} from 'tree-dump'; +import type {UiLifeCycles} from '../../dom/types'; +import type {UndoRedo} from './types'; + +export class DomUndoRedo implements UndoRedo, UiLifeCycles, Printable { + private _duringUpdate: boolean = false; + private _stack: State[] = []; + private el: HTMLElement; + + constructor ( + public onundo?: (state: State) => void, + public onredo?: (state: State) => void, + ) { + // nb. Previous versions of this used `input` for browsers other than Firefox (as Firefox + // _only_ supports execCommand on contentEditable) + const el = this.el = document.createElement('div'); + el.tabIndex = -1; + el.contentEditable = 'true'; + el.setAttribute('aria-hidden', 'true'); + const style = el.style; + // style.opacity = '0'; + style.position = 'fixed'; + // style.top = '-1000px'; + style.top = '10px'; + style.left = '10px'; + style.pointerEvents = 'none'; + style.fontSize = '2px'; + // style.visibility = 'hidden'; + + document.body.appendChild(el); + + el.addEventListener('focus', () => { + // Timeout, as immediate blur doesn't work in some browsers. + window.setTimeout(() => el.blur(), 0); + }); + el.addEventListener('input', (ev) => { + if (!this._duringUpdate) { + // callback(this.data); + } + + // clear selection, otherwise user copy gesture will copy value + // nb. this _probably_ won't work inside Shadow DOM + // nb. this is mitigated by the fact that we set visibility: 'hidden' + // const s = window.getSelection(); + // if (s.containsNode(this._ctrl, true)) { + // s.removeAllRanges(); + // } + }); + } + + /** ------------------------------------------------------ {@link UndoRedo} */ + + public do(state: State): void { + const activeElement = document.activeElement; + const el = this.el; + const style = el.style; + try { + this._duringUpdate = true; + style.visibility = 'visible'; + el.focus(); + document.execCommand?.('insertText', false, '|'); + } finally { + el.blur(); + this._duringUpdate = false; + // style.visibility = 'hidden'; + } + (activeElement as HTMLElement)?.focus?.(); + } + + /** -------------------------------------------------- {@link UiLifeCycles} */ + + public start(): void { + throw new Error('Method not implemented.'); + } + + public stop(): void { + throw new Error('Method not implemented.'); + } + /** ----------------------------------------------------- {@link Printable} */ + + public toString(tab?: string): string { + throw new Error('Method not implemented.'); + } +} diff --git a/src/json-crdt-peritext-ui/events/undo/types.ts b/src/json-crdt-peritext-ui/events/undo/types.ts new file mode 100644 index 0000000000..df1c33309c --- /dev/null +++ b/src/json-crdt-peritext-ui/events/undo/types.ts @@ -0,0 +1,5 @@ +export interface UndoRedo { + onundo?: (state: State) => void; + onredo?: (state: State) => void; + do(state: State): void; +} From b6f1119008ef583954666c86b795fb095f0c257f Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Wed, 12 Mar 2025 17:27:55 +0100 Subject: [PATCH 03/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20update=20undo=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/DomController.ts | 15 ++- .../UndoRedoController.ts} | 93 +++++++++---------- .../events/defaults/PeritextEventDefaults.ts | 35 +++---- src/json-crdt-peritext-ui/events/index.ts | 4 +- .../events/undo/types.ts | 5 - src/json-crdt-peritext-ui/types.ts | 6 ++ 6 files changed, 78 insertions(+), 80 deletions(-) rename src/json-crdt-peritext-ui/{events/undo/DomUndoRedo.ts => dom/UndoRedoController.ts} (52%) delete mode 100644 src/json-crdt-peritext-ui/events/undo/types.ts create mode 100644 src/json-crdt-peritext-ui/types.ts diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index 5b38c93556..f973ad0880 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -1,9 +1,10 @@ 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 {UndoRedoController} from './UndoRedoController'; import type {PeritextEventDefaults} from '../events/defaults/PeritextEventDefaults'; import type {PeritextEventTarget} from '../events/PeritextEventTarget'; import type {PeritextRenderingSurfaceApi, UiLifeCycles} from '../dom/types'; @@ -20,6 +21,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering public readonly input: InputController; public readonly cursor: CursorController; public readonly richText: RichTextController; + public readonly undo: UndoRedoController; constructor(public readonly opts: DomControllerOpts) { const {source, events} = opts; @@ -30,6 +32,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering this.input = new InputController({et, source, txt, comp}); this.cursor = new CursorController({et, source, txt, keys}); this.richText = new RichTextController({et, source, txt}); + this.undo = new UndoRedoController(); } /** -------------------------------------------------- {@link UiLifeCycles} */ @@ -40,6 +43,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering this.input.start(); this.cursor.start(); this.richText.start(); + this.undo.start(); } public stop(): void { @@ -48,6 +52,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering this.input.stop(); this.cursor.stop(); this.richText.stop(); + this.undo.stop(); } /** ----------------------------------- {@link PeritextRenderingSurfaceApi} */ diff --git a/src/json-crdt-peritext-ui/events/undo/DomUndoRedo.ts b/src/json-crdt-peritext-ui/dom/UndoRedoController.ts similarity index 52% rename from src/json-crdt-peritext-ui/events/undo/DomUndoRedo.ts rename to src/json-crdt-peritext-ui/dom/UndoRedoController.ts index 9a66f412f7..310b26a2a9 100644 --- a/src/json-crdt-peritext-ui/events/undo/DomUndoRedo.ts +++ b/src/json-crdt-peritext-ui/dom/UndoRedoController.ts @@ -1,56 +1,19 @@ import type {Printable} from 'tree-dump'; -import type {UiLifeCycles} from '../../dom/types'; -import type {UndoRedo} from './types'; +import type {UiLifeCycles} from './types'; -export class DomUndoRedo implements UndoRedo, UiLifeCycles, Printable { +export class UndoRedoController implements UiLifeCycles, Printable { private _duringUpdate: boolean = false; - private _stack: State[] = []; - private el: HTMLElement; + private _stack: unknown[] = []; + private el!: HTMLElement; constructor ( - public onundo?: (state: State) => void, - public onredo?: (state: State) => void, - ) { - // nb. Previous versions of this used `input` for browsers other than Firefox (as Firefox - // _only_ supports execCommand on contentEditable) - const el = this.el = document.createElement('div'); - el.tabIndex = -1; - el.contentEditable = 'true'; - el.setAttribute('aria-hidden', 'true'); - const style = el.style; - // style.opacity = '0'; - style.position = 'fixed'; - // style.top = '-1000px'; - style.top = '10px'; - style.left = '10px'; - style.pointerEvents = 'none'; - style.fontSize = '2px'; - // style.visibility = 'hidden'; - - document.body.appendChild(el); - - el.addEventListener('focus', () => { - // Timeout, as immediate blur doesn't work in some browsers. - window.setTimeout(() => el.blur(), 0); - }); - el.addEventListener('input', (ev) => { - if (!this._duringUpdate) { - // callback(this.data); - } - - // clear selection, otherwise user copy gesture will copy value - // nb. this _probably_ won't work inside Shadow DOM - // nb. this is mitigated by the fact that we set visibility: 'hidden' - // const s = window.getSelection(); - // if (s.containsNode(this._ctrl, true)) { - // s.removeAllRanges(); - // } - }); - } + public onundo?: (state: unknown) => void, + public onredo?: (state: unknown) => void, + ) {} /** ------------------------------------------------------ {@link UndoRedo} */ - public do(state: State): void { + public do(state: unknown): void { const activeElement = document.activeElement; const el = this.el; const style = el.style; @@ -70,12 +33,48 @@ export class DomUndoRedo implements UndoRedo, UiLifeCycles, Printa /** -------------------------------------------------- {@link UiLifeCycles} */ public start(): void { - throw new Error('Method not implemented.'); + const el = this.el = document.createElement('div'); + el.tabIndex = -1; + el.contentEditable = 'true'; + el.setAttribute('aria-hidden', 'true'); + const style = el.style; + // style.opacity = '0'; + style.position = 'fixed'; + // style.top = '-1000px'; + style.top = '10px'; + style.left = '10px'; + style.pointerEvents = 'none'; + style.fontSize = '2px'; + // style.visibility = 'hidden'; + document.body.appendChild(el); + el.addEventListener('focus', this.onFocus); + el.addEventListener('input', this.onInput); } public stop(): void { - throw new Error('Method not implemented.'); + const el = this.el; + document.body.removeChild(el); + el.removeEventListener('focus', this.onFocus); + el.removeEventListener('input', this.onInput); } + + public readonly onFocus = () => { + window.setTimeout(() => this.el.blur(), 0); + }; + + public readonly onInput = () => { + if (!this._duringUpdate) { + // callback(this.data); + } + // clear selection, otherwise user copy gesture will copy value + // nb. this _probably_ won't work inside Shadow DOM + // nb. this is mitigated by the fact that we set visibility: 'hidden' + // const s = window.getSelection(); + // if (s.containsNode(this._ctrl, true)) { + // s.removeAllRanges(); + // } + }; + /** ----------------------------------------------------- {@link Printable} */ public toString(tab?: string): string { diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index c9a6ff11fa..52a7f51d47 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -8,14 +8,16 @@ import type {EditorSlices} from '../../../json-crdt-extensions/peritext/editor/E import type * as events from '../types'; import type {PeritextClipboard, PeritextClipboardData} from '../clipboard/types'; import type {ITimespanStruct} from '../../../json-crdt-patch'; -import type {UndoRedo} from '../undo/types'; +import type {Redo, Undo, UndoRedoCollector} from '../../types'; const toText = (buf: Uint8Array) => new TextDecoder().decode(buf); +type InsertUndoState = [text: string, after: Point[], inserts: ITimespanStruct[]]; + export interface PeritextEventDefaultsOpts { clipboard?: PeritextClipboard; transfer?: PeritextDataTransfer; - undo?: UndoRedo; + undo?: UndoRedoCollector; } /** @@ -34,35 +36,28 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { public readonly change = (event: CustomEvent) => {}; - private insertUndo = (text: string, after: Point[], insertions: ITimespanStruct[]) => { - const undo = () => { - // Delete `insertions`. - const redo = this.insertRedo(text, after); - return redo; - }; - return undo; + private insertUndo: Undo = ([text, after, inserts]) => { + // TODO: delete `insertions`. + console.log('delete', inserts); + return [[text, after, inserts], this.insertRedo]; }; - private insertRedo = (text: string, after: Point[]) => { - const redo = () => { - // Insert text. - const insertions: ITimespanStruct[] = []; - const undo = this.insertUndo(text, after, insertions); - return undo; - }; - return redo; + private insertRedo: Redo = ([text, after]) => { + // TODO: insert `text` after `after` locations. + console.log('insert', text, 'after', after); + const inserts: ITimespanStruct[] = []; + return [[text, after, inserts], this.insertUndo]; }; public readonly insert = (event: CustomEvent) => { const text = event.detail.text; const editor = this.txt.editor; - const insertions: ITimespanStruct[] = editor.insert(text); + const inserts: ITimespanStruct[] = editor.insert(text); const after: Point[] = []; editor.forCursor(cursor => { after.push(cursor.start.clone()); }); - this.opts.undo?.do([123]); - console.log('insertions', insertions, 'after', after); + this.opts.undo?.do([text, after, inserts], this.insertUndo); }; public readonly delete = (event: CustomEvent) => { diff --git a/src/json-crdt-peritext-ui/events/index.ts b/src/json-crdt-peritext-ui/events/index.ts index 87059d1062..3cff2b9552 100644 --- a/src/json-crdt-peritext-ui/events/index.ts +++ b/src/json-crdt-peritext-ui/events/index.ts @@ -2,7 +2,6 @@ import {PeritextEventDefaults, type PeritextEventDefaultsOpts} from './defaults/ import {PeritextEventTarget} from './PeritextEventTarget'; import {DomClipboard} from './clipboard/DomClipboard'; import {create as createDataTransfer} from '../../json-crdt-extensions/peritext/transfer/create'; -import {DomUndoRedo} from './undo/DomUndoRedo'; import type {Peritext} from '../../json-crdt-extensions'; export const create = (txt: Peritext) => { @@ -12,8 +11,7 @@ export const create = (txt: Peritext) => { ? new DomClipboard(navigator.clipboard) : undefined; const transfer = createDataTransfer(txt); - const undo = new DomUndoRedo(); - const defaults = new PeritextEventDefaults(txt, et, {clipboard, transfer, undo}); + const defaults = new PeritextEventDefaults(txt, et, {clipboard, transfer}); et.defaults = defaults; return defaults; }; diff --git a/src/json-crdt-peritext-ui/events/undo/types.ts b/src/json-crdt-peritext-ui/events/undo/types.ts deleted file mode 100644 index df1c33309c..0000000000 --- a/src/json-crdt-peritext-ui/events/undo/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface UndoRedo { - onundo?: (state: State) => void; - onredo?: (state: State) => void; - do(state: State): void; -} diff --git a/src/json-crdt-peritext-ui/types.ts b/src/json-crdt-peritext-ui/types.ts new file mode 100644 index 0000000000..f524fef8d0 --- /dev/null +++ b/src/json-crdt-peritext-ui/types.ts @@ -0,0 +1,6 @@ +export interface UndoRedoCollector { + do(state: State, undo: Undo): void; +} + +export type Undo = (state: State) => [state: State, redo: Redo]; +export type Redo = (state: State) => [state: State, undo: Undo]; From 4e7b835c7fe38190c4a507e388fa61c14472604c Mon Sep 17 00:00:00 2001 From: streamich Date: Wed, 12 Mar 2025 19:36:22 +0100 Subject: [PATCH 04/33] =?UTF-8?q?chore(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=A4=96=20add=20autoflush=20to=20Peritext=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-peritext-ui/__demos__/components/App.tsx | 7 +++++++ src/json-crdt-peritext-ui/react/PeritextView.tsx | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/json-crdt-peritext-ui/__demos__/components/App.tsx b/src/json-crdt-peritext-ui/__demos__/components/App.tsx index 39ce171b55..1a7af49962 100644 --- a/src/json-crdt-peritext-ui/__demos__/components/App.tsx +++ b/src/json-crdt-peritext-ui/__demos__/components/App.tsx @@ -30,6 +30,13 @@ export const App: React.FC = () => { return [model, peritext] as const; }); + React.useEffect(() => { + model.api.autoFlush(); + return () => { + model.api.stopAutoFlush?.(); + }; + }, [model]); + const plugins = React.useMemo(() => { const cursorPlugin = new CursorPlugin(); const toolbarPlugin = new ToolbarPlugin(); diff --git a/src/json-crdt-peritext-ui/react/PeritextView.tsx b/src/json-crdt-peritext-ui/react/PeritextView.tsx index 8fccd38f49..134f6b9a69 100644 --- a/src/json-crdt-peritext-ui/react/PeritextView.tsx +++ b/src/json-crdt-peritext-ui/react/PeritextView.tsx @@ -56,6 +56,16 @@ export const PeritextView: React.FC = React.memo((props) => { if (onRender) onRender(); }, [peritext]); + const model = peritext.model; + React.useEffect(() => { + const unsubscribe = peritext.model.api.onFlush.listen((patch) => { + console.log('flush', patch); + }); + return () => { + unsubscribe(); + }; + }, [model]); + const state: PeritextSurfaceState = React.useMemo(() => { const state = new PeritextSurfaceState(peritext, create(peritext), rerender, plugins); onState?.(state); From 6770ffc14d32277e933f9525433774df2497e571 Mon Sep 17 00:00:00 2001 From: streamich Date: Thu, 13 Mar 2025 23:13:21 +0100 Subject: [PATCH 05/33] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20add=20a?= =?UTF-8?q?bility=20to=20replay=20log=20until=20patch=20non-inclusively?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/log/Log.ts | 14 ++- src/json-crdt/log/__tests__/Log.spec.ts | 130 ++++++++++++++---------- 2 files changed, 87 insertions(+), 57 deletions(-) diff --git a/src/json-crdt/log/Log.ts b/src/json-crdt/log/Log.ts index 470fe3fb84..0b186c38b6 100644 --- a/src/json-crdt/log/Log.ts +++ b/src/json-crdt/log/Log.ts @@ -1,9 +1,9 @@ import {AvlMap} from 'sonic-forest/lib/avl/AvlMap'; import {first, next} from 'sonic-forest/lib/util'; -import type {FanOutUnsubscribe} from 'thingies/lib/fanout'; import {printTree} from 'tree-dump/lib/printTree'; -import {type ITimestampStruct, type Patch, compare} from '../../json-crdt-patch'; import {Model} from '../model'; +import {type ITimestampStruct, type Patch, compare} from '../../json-crdt-patch'; +import type {FanOutUnsubscribe} from 'thingies/lib/fanout'; import type {Printable} from 'tree-dump/lib/types'; import type {JsonNode} from '../nodes/types'; @@ -107,12 +107,18 @@ export class Log> implements Printable { * with patches replayed up to the given timestamp. * * @param ts Timestamp ID of the patch to replay to. + * @param inclusive If `true`, the patch at the given timestamp `ts` is included, + * otherwise replays up to the patch before the given timestamp. Default is `true`. * @returns A new model instance with patches replayed up to the given timestamp. */ - public replayTo(ts: ITimestampStruct): Model { + public replayTo(ts: ITimestampStruct, inclusive: boolean = true): Model { + // TODO: PERF: Make `.clone()` implicit in `.start()`. const clone = this.start().clone(); - for (let node = first(this.patches.root); node && compare(ts, node.k) >= 0; node = next(node)) + let cmp: number = 0; + for (let node = first(this.patches.root); node && (cmp = compare(ts, node.k)) >= 0; node = next(node)){ + if (cmp === 0 && !inclusive) break; clone.applyPatch(node.v); + } return clone; } diff --git a/src/json-crdt/log/__tests__/Log.spec.ts b/src/json-crdt/log/__tests__/Log.spec.ts index 2e5f50c32f..8844b8e4a1 100644 --- a/src/json-crdt/log/__tests__/Log.spec.ts +++ b/src/json-crdt/log/__tests__/Log.spec.ts @@ -37,60 +37,84 @@ test('can create a new log from a new model with right starting logical clock', expect(log.end.clock.time > 10).toBe(true); }); -test('can replay to specific patch', () => { - const {log} = setup({foo: 'bar'}); - const model = log.end.clone(); - model.api.obj([]).set({x: 1}); - const patch1 = model.api.flush(); - model.api.obj([]).set({y: 2}); - const patch2 = model.api.flush(); - log.end.applyPatch(patch1); - log.end.applyPatch(patch2); - const model2 = log.replayToEnd(); - const model3 = log.replayTo(patch1.getId()!); - const model4 = log.replayTo(patch2.getId()!); - expect(model.view()).toEqual({foo: 'bar', x: 1, y: 2}); - expect(log.end.view()).toEqual({foo: 'bar', x: 1, y: 2}); - expect(log.start().view()).toEqual(undefined); - expect(model2.view()).toEqual({foo: 'bar', x: 1, y: 2}); - expect(model3.view()).toEqual({foo: 'bar', x: 1}); - expect(model4.view()).toEqual({foo: 'bar', x: 1, y: 2}); -}); +describe('.replayTo()', () => { + test('can replay to specific patch', () => { + const {log} = setup({foo: 'bar'}); + const model = log.end.clone(); + model.api.obj([]).set({x: 1}); + const patch1 = model.api.flush(); + model.api.obj([]).set({y: 2}); + const patch2 = model.api.flush(); + log.end.applyPatch(patch1); + log.end.applyPatch(patch2); + const model2 = log.replayToEnd(); + const model3 = log.replayTo(patch1.getId()!); + const model4 = log.replayTo(patch2.getId()!); + expect(model.view()).toEqual({foo: 'bar', x: 1, y: 2}); + expect(log.end.view()).toEqual({foo: 'bar', x: 1, y: 2}); + expect(log.start().view()).toEqual(undefined); + expect(model2.view()).toEqual({foo: 'bar', x: 1, y: 2}); + expect(model3.view()).toEqual({foo: 'bar', x: 1}); + expect(model4.view()).toEqual({foo: 'bar', x: 1, y: 2}); + }); -test('can advance the log from start', () => { - const {log} = setup({foo: 'bar'}); - log.end.api.obj([]).set({x: 1}); - const patch1 = log.end.api.flush(); - log.end.api.obj([]).set({y: 2}); - const patch2 = log.end.api.flush(); - log.end.api.obj([]).set({foo: 'baz'}); - const patch3 = log.end.api.flush(); - expect(log.end.view()).toEqual({foo: 'baz', x: 1, y: 2}); - expect(log.start().view()).toEqual(undefined); - log.advanceTo(patch1.getId()!); - expect(log.end.view()).toEqual({foo: 'baz', x: 1, y: 2}); - expect(log.start().view()).toEqual({foo: 'bar', x: 1}); - log.advanceTo(patch2.getId()!); - expect(log.end.view()).toEqual({foo: 'baz', x: 1, y: 2}); - expect(log.start().view()).toEqual({foo: 'bar', x: 1, y: 2}); - expect(log.patches.size()).toBe(1); - log.advanceTo(patch3.getId()!); - expect(log.end.view()).toEqual({foo: 'baz', x: 1, y: 2}); - expect(log.start().view()).toEqual({foo: 'baz', x: 1, y: 2}); - expect(log.patches.size()).toBe(0); + test('can replay to just before a specific patch', () => { + const {log} = setup({foo: 'bar'}); + const model = log.end.clone(); + model.api.obj([]).set({x: 1}); + const patch1 = model.api.flush(); + model.api.obj([]).set({y: 2}); + const patch2 = model.api.flush(); + log.end.applyPatch(patch1); + log.end.applyPatch(patch2); + const model2 = log.replayToEnd(); + const model3 = log.replayTo(patch1.getId()!, false); + const model4 = log.replayTo(patch2.getId()!, false); + expect(model.view()).toEqual({foo: 'bar', x: 1, y: 2}); + expect(log.end.view()).toEqual({foo: 'bar', x: 1, y: 2}); + expect(log.start().view()).toEqual(undefined); + expect(model2.view()).toEqual({foo: 'bar', x: 1, y: 2}); + expect(model3.view()).toEqual({foo: 'bar'}); + expect(model4.view()).toEqual({foo: 'bar', x: 1}); + }); }); -test('can advance multiple patches at once', () => { - const {log} = setup({foo: 'bar'}); - log.end.api.obj([]).set({x: 1}); - log.end.api.flush(); - log.end.api.obj([]).set({y: 2}); - const patch2 = log.end.api.flush(); - log.end.api.obj([]).set({foo: 'baz'}); - log.end.api.flush(); - expect(log.end.view()).toEqual({foo: 'baz', x: 1, y: 2}); - expect(log.start().view()).toEqual(undefined); - log.advanceTo(patch2.getId()!); - expect(log.end.view()).toEqual({foo: 'baz', x: 1, y: 2}); - expect(log.start().view()).toEqual({foo: 'bar', x: 1, y: 2}); +describe('.advanceTo()', () => { + test('can advance the log from start', () => { + const {log} = setup({foo: 'bar'}); + log.end.api.obj([]).set({x: 1}); + const patch1 = log.end.api.flush(); + log.end.api.obj([]).set({y: 2}); + const patch2 = log.end.api.flush(); + log.end.api.obj([]).set({foo: 'baz'}); + const patch3 = log.end.api.flush(); + expect(log.end.view()).toEqual({foo: 'baz', x: 1, y: 2}); + expect(log.start().view()).toEqual(undefined); + log.advanceTo(patch1.getId()!); + expect(log.end.view()).toEqual({foo: 'baz', x: 1, y: 2}); + expect(log.start().view()).toEqual({foo: 'bar', x: 1}); + log.advanceTo(patch2.getId()!); + expect(log.end.view()).toEqual({foo: 'baz', x: 1, y: 2}); + expect(log.start().view()).toEqual({foo: 'bar', x: 1, y: 2}); + expect(log.patches.size()).toBe(1); + log.advanceTo(patch3.getId()!); + expect(log.end.view()).toEqual({foo: 'baz', x: 1, y: 2}); + expect(log.start().view()).toEqual({foo: 'baz', x: 1, y: 2}); + expect(log.patches.size()).toBe(0); + }); + + test('can advance multiple patches at once', () => { + const {log} = setup({foo: 'bar'}); + log.end.api.obj([]).set({x: 1}); + log.end.api.flush(); + log.end.api.obj([]).set({y: 2}); + const patch2 = log.end.api.flush(); + log.end.api.obj([]).set({foo: 'baz'}); + log.end.api.flush(); + expect(log.end.view()).toEqual({foo: 'baz', x: 1, y: 2}); + expect(log.start().view()).toEqual(undefined); + log.advanceTo(patch2.getId()!); + expect(log.end.view()).toEqual({foo: 'baz', x: 1, y: 2}); + expect(log.start().view()).toEqual({foo: 'bar', x: 1, y: 2}); + }); }); From d34dca8d85636e6dbb58749cddc6627567a728a9 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 14 Mar 2025 02:10:43 +0100 Subject: [PATCH 06/33] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20start?= =?UTF-8?q?=20.undo()=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/log/Log.ts | 67 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src/json-crdt/log/Log.ts b/src/json-crdt/log/Log.ts index 0b186c38b6..0128c2f454 100644 --- a/src/json-crdt/log/Log.ts +++ b/src/json-crdt/log/Log.ts @@ -2,10 +2,12 @@ import {AvlMap} from 'sonic-forest/lib/avl/AvlMap'; import {first, next} from 'sonic-forest/lib/util'; import {printTree} from 'tree-dump/lib/printTree'; import {Model} from '../model'; -import {type ITimestampStruct, type Patch, compare} from '../../json-crdt-patch'; +import {toSchema} from '../schema/toSchema'; +import {DelOp, type ITimestampStruct, InsArrOp, InsBinOp, InsObjOp, InsStrOp, InsValOp, InsVecOp, type Patch, Timespan, compare} from '../../json-crdt-patch'; import type {FanOutUnsubscribe} from 'thingies/lib/fanout'; import type {Printable} from 'tree-dump/lib/types'; import type {JsonNode} from '../nodes/types'; +import {StrNode} from '../nodes'; /** * The `Log` represents a history of patches applied to a JSON CRDT model. It @@ -143,6 +145,69 @@ export class Log> implements Printable { }; } + /** + * Creates a patch which reverts the given patch. The RGA insertion operations + * are reversed just by deleting the inserted values. All other operations + * require time travel to the state just before the patch was applied, so that + * a copy of a mutated object can be created and inserted back into the model. + * + * @param patch The patch to undo + * @returns A new patch that undoes the given patch + */ + public undo(patch: Patch): Patch { + const ops = patch.ops; + const length = ops.length; + if (!length) throw new Error('EMPTY_PATCH'); + const id = patch.getId(); + let __model: Model | undefined; + const getModel = () => __model || (__model = this.replayTo(id!)); + const builder = this.end.api.builder; + for (let i = length - 1; i >= 0; i--) { + const op = ops[i]; + const opId = op.id; + if (op instanceof InsStrOp || op instanceof InsArrOp || op instanceof InsBinOp) { + builder.del(op.ref, [new Timespan(opId.sid, opId.time, op.span())]); + continue; + } + const model = getModel(); + if (op instanceof InsValOp) { + const obj = model.index.find(op.obj); + if (obj) { + const schema = toSchema(obj.v); + const newId = schema.build(builder); + builder.setVal(op.obj, newId); + } + } else if (op instanceof InsObjOp || op instanceof InsVecOp) { + const data: (typeof op)['data'] = []; + for (const [key, value] of op.data) { + const obj = model.index.find(value); + if (obj) { + const schema = toSchema(obj.v); + const newId = schema.build(builder); + data.push([key, newId] as any); + } else { + data.push([key, builder.const(undefined)] as any); + } + } + if (data.length) { + if (op instanceof InsObjOp) builder.insObj(op.obj, data as InsObjOp['data']); + else if (op instanceof InsVecOp) builder.insVec(op.obj, data as InsVecOp['data']); + } + } else if (op instanceof DelOp) { + const node = model.index.find(op.obj); + if (node) { + const rga = node.v; + if (rga instanceof StrNode) { + for (const span of op.what) { + // TODO: SPAN to view: ... + } + } + } + } + } + return builder.flush(); + } + // ---------------------------------------------------------------- Printable public toString(tab?: string) { From a7a2e7694956fe07b41117cb0eb9d49e5a65ae43 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 14 Mar 2025 17:47:22 +0100 Subject: [PATCH 07/33] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20allow?= =?UTF-8?q?=20immediate=20drain=20on=20autoflush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/log/Log.ts | 2 +- src/json-crdt/model/api/ModelApi.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/json-crdt/log/Log.ts b/src/json-crdt/log/Log.ts index 0128c2f454..1c91048814 100644 --- a/src/json-crdt/log/Log.ts +++ b/src/json-crdt/log/Log.ts @@ -4,10 +4,10 @@ import {printTree} from 'tree-dump/lib/printTree'; import {Model} from '../model'; import {toSchema} from '../schema/toSchema'; import {DelOp, type ITimestampStruct, InsArrOp, InsBinOp, InsObjOp, InsStrOp, InsValOp, InsVecOp, type Patch, Timespan, compare} from '../../json-crdt-patch'; +import {StrNode} from '../nodes'; import type {FanOutUnsubscribe} from 'thingies/lib/fanout'; import type {Printable} from 'tree-dump/lib/types'; import type {JsonNode} from '../nodes/types'; -import {StrNode} from '../nodes'; /** * The `Log` represents a history of patches applied to a JSON CRDT model. It diff --git a/src/json-crdt/model/api/ModelApi.ts b/src/json-crdt/model/api/ModelApi.ts index 8e436a6091..e9b85b8c41 100644 --- a/src/json-crdt/model/api/ModelApi.ts +++ b/src/json-crdt/model/api/ModelApi.ts @@ -303,11 +303,12 @@ export class ModelApi implements SyncStore void { + public autoFlush(drainNow = false): () => void { const drain = () => this.builder.patch.ops.length && this.flush(); const onLocalChangesUnsubscribe = this.onLocalChanges.listen(drain); const onBeforeTransactionUnsubscribe = this.onBeforeTransaction.listen(drain); const onTransactionUnsubscribe = this.onTransaction.listen(drain); + if (drainNow) drain(); return (this.stopAutoFlush = () => { this.stopAutoFlush = undefined; onLocalChangesUnsubscribe(); From 53b50fab237001c96a6fdd7d64eaba914a2710c8 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 14 Mar 2025 17:53:29 +0100 Subject: [PATCH 08/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20setup=20new=20undo=20controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__demos__/components/App.tsx | 2 +- .../dom/DomController.ts | 2 +- .../dom/UndoRedoController.ts | 44 ++++++++++++-- .../events/defaults/PeritextEventDefaults.ts | 59 +++++++++++-------- .../react/PeritextView.tsx | 11 +--- src/json-crdt-peritext-ui/types.ts | 21 ++++++- 6 files changed, 96 insertions(+), 43 deletions(-) diff --git a/src/json-crdt-peritext-ui/__demos__/components/App.tsx b/src/json-crdt-peritext-ui/__demos__/components/App.tsx index 1a7af49962..e7b1872097 100644 --- a/src/json-crdt-peritext-ui/__demos__/components/App.tsx +++ b/src/json-crdt-peritext-ui/__demos__/components/App.tsx @@ -31,7 +31,7 @@ export const App: React.FC = () => { }); React.useEffect(() => { - model.api.autoFlush(); + model.api.autoFlush(true); return () => { model.api.stopAutoFlush?.(); }; diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index f973ad0880..a587c6f126 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -32,7 +32,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering this.input = new InputController({et, source, txt, comp}); this.cursor = new CursorController({et, source, txt, keys}); this.richText = new RichTextController({et, source, txt}); - this.undo = new UndoRedoController(); + this.undo = new UndoRedoController({txt}); } /** -------------------------------------------------- {@link UiLifeCycles} */ diff --git a/src/json-crdt-peritext-ui/dom/UndoRedoController.ts b/src/json-crdt-peritext-ui/dom/UndoRedoController.ts index 310b26a2a9..8a1a06e931 100644 --- a/src/json-crdt-peritext-ui/dom/UndoRedoController.ts +++ b/src/json-crdt-peritext-ui/dom/UndoRedoController.ts @@ -1,16 +1,42 @@ +import {Peritext} from '../../json-crdt-extensions'; import type {Printable} from 'tree-dump'; import type {UiLifeCycles} from './types'; +import type {Patch} from '../../json-crdt-patch'; +import type {UndoRedoCollector} from '../types'; -export class UndoRedoController implements UiLifeCycles, Printable { +export interface UndoRedoControllerOpts { + txt: Peritext; +} + +export class UndoRedoController implements UndoRedoCollector, UiLifeCycles, Printable { private _duringUpdate: boolean = false; private _stack: unknown[] = []; private el!: HTMLElement; constructor ( - public onundo?: (state: unknown) => void, - public onredo?: (state: unknown) => void, + public readonly opts: UndoRedoControllerOpts, + // public onundo?: (state: unknown) => void, + // public onredo?: (state: unknown) => void, ) {} + protected captured = new WeakSet(); + // protected undoable(patch: Patch): void {} + + // public live: boolean = false; + + // public capture(callback: () => void): void { + public capture(): void { + const currentPatch = this.opts.txt.model.api.builder.patch; + this.captured.add(currentPatch); + // this.live = true; + // try { + // callback(); + // } finally { + // this.undoable.add(this.opts.txt.model.api.builder.patch); + // // this.live = false; + // } + } + /** ------------------------------------------------------ {@link UndoRedo} */ public do(state: unknown): void { @@ -44,11 +70,21 @@ export class UndoRedoController implements UiLifeCycles, Printable { style.top = '10px'; style.left = '10px'; style.pointerEvents = 'none'; - style.fontSize = '2px'; + // style.fontSize = '2px'; + style.fontSize = '8px'; // style.visibility = 'hidden'; document.body.appendChild(el); el.addEventListener('focus', this.onFocus); el.addEventListener('input', this.onInput); + const {opts, captured} = this; + const {txt} = opts; + txt.model.api.onFlush.listen((patch) => { + const isCaptured = captured.has(patch); + if (isCaptured) { + captured.delete(patch); + console.log('flush 2', patch + ''); + } + }); } public stop(): void { diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index 52a7f51d47..8c67965fbc 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -1,5 +1,5 @@ import {CursorAnchor} from '../../../json-crdt-extensions/peritext/slice/constants'; -import type {Point} from '../../../json-crdt-extensions/peritext/rga/Point'; +// import type {Point} from '../../../json-crdt-extensions/peritext/rga/Point'; import type {Range} from '../../../json-crdt-extensions/peritext/rga/Range'; import type {PeritextDataTransfer} from '../../../json-crdt-extensions/peritext/transfer/PeritextDataTransfer'; import type {PeritextEventHandlerMap, PeritextEventTarget} from '../PeritextEventTarget'; @@ -7,17 +7,16 @@ import type {Peritext} from '../../../json-crdt-extensions/peritext'; import type {EditorSlices} from '../../../json-crdt-extensions/peritext/editor/EditorSlices'; import type * as events from '../types'; import type {PeritextClipboard, PeritextClipboardData} from '../clipboard/types'; -import type {ITimespanStruct} from '../../../json-crdt-patch'; -import type {Redo, Undo, UndoRedoCollector} from '../../types'; +// import type {ITimespanStruct} from '../../../json-crdt-patch'; +import type {UndoRedoCollector} from '../../types'; const toText = (buf: Uint8Array) => new TextDecoder().decode(buf); -type InsertUndoState = [text: string, after: Point[], inserts: ITimespanStruct[]]; +// type InsertUndoState = [text: string, after: Point[], inserts: ITimespanStruct[]]; export interface PeritextEventDefaultsOpts { clipboard?: PeritextClipboard; transfer?: PeritextDataTransfer; - undo?: UndoRedoCollector; } /** @@ -28,36 +27,48 @@ export interface PeritextEventDefaultsOpts { * will not be executed. */ export class PeritextEventDefaults implements PeritextEventHandlerMap { + public undo?: UndoRedoCollector; + public constructor( public readonly txt: Peritext, public readonly et: PeritextEventTarget, public readonly opts: PeritextEventDefaultsOpts = {}, ) {} + // protected record(callback: () => void): void { + // const {undo} = this; + // undo ? undo.capture(callback) : callback(); + // } + public readonly change = (event: CustomEvent) => {}; - private insertUndo: Undo = ([text, after, inserts]) => { - // TODO: delete `insertions`. - console.log('delete', inserts); - return [[text, after, inserts], this.insertRedo]; - }; + // private insertUndo: Undo = ([text, after, inserts]) => { + // // TODO: delete `insertions`. + // console.log('delete', inserts); + // return [[text, after, inserts], this.insertRedo]; + // }; - private insertRedo: Redo = ([text, after]) => { - // TODO: insert `text` after `after` locations. - console.log('insert', text, 'after', after); - const inserts: ITimespanStruct[] = []; - return [[text, after, inserts], this.insertUndo]; - }; + // private insertRedo: Redo = ([text, after]) => { + // // TODO: insert `text` after `after` locations. + // console.log('insert', text, 'after', after); + // const inserts: ITimespanStruct[] = []; + // return [[text, after, inserts], this.insertUndo]; + // }; public readonly insert = (event: CustomEvent) => { - const text = event.detail.text; - const editor = this.txt.editor; - const inserts: ITimespanStruct[] = editor.insert(text); - const after: Point[] = []; - editor.forCursor(cursor => { - after.push(cursor.start.clone()); - }); - this.opts.undo?.do([text, after, inserts], this.insertUndo); + // this.record(() => { + // console.log('here') + const text = event.detail.text; + const editor = this.txt.editor; + editor.insert(text); + this.undo?.capture(); + // const inserts: ITimespanStruct[] = editor.insert(text); + // const after: Point[] = []; + // editor.forCursor(cursor => { + // after.push(cursor.start.clone()); + // }); + // }); + // this.opts.undo?.do([text, after, inserts], this.insertUndo); }; public readonly delete = (event: CustomEvent) => { diff --git a/src/json-crdt-peritext-ui/react/PeritextView.tsx b/src/json-crdt-peritext-ui/react/PeritextView.tsx index 134f6b9a69..9ebb5606ff 100644 --- a/src/json-crdt-peritext-ui/react/PeritextView.tsx +++ b/src/json-crdt-peritext-ui/react/PeritextView.tsx @@ -56,16 +56,6 @@ export const PeritextView: React.FC = React.memo((props) => { if (onRender) onRender(); }, [peritext]); - const model = peritext.model; - React.useEffect(() => { - const unsubscribe = peritext.model.api.onFlush.listen((patch) => { - console.log('flush', patch); - }); - return () => { - unsubscribe(); - }; - }, [model]); - const state: PeritextSurfaceState = React.useMemo(() => { const state = new PeritextSurfaceState(peritext, create(peritext), rerender, plugins); onState?.(state); @@ -86,6 +76,7 @@ export const PeritextView: React.FC = React.memo((props) => { } if (dom && dom.opts.source === el) return; const ctrl = new DomController({source: el, events: state.events}); + state.events.undo = ctrl.undo; ctrl.start(); state.dom = ctrl; setDom(ctrl); diff --git a/src/json-crdt-peritext-ui/types.ts b/src/json-crdt-peritext-ui/types.ts index f524fef8d0..588c3aaf8f 100644 --- a/src/json-crdt-peritext-ui/types.ts +++ b/src/json-crdt-peritext-ui/types.ts @@ -1,6 +1,21 @@ export interface UndoRedoCollector { - do(state: State, undo: Undo): void; + /** + * Marks whether the collector is currently capturing events for undo/redo. + * Can be mutated to turn on/off capturing. + */ + // live: boolean; + + /** + * Captures events for undo/redo withing the callback. And sets `live` state + * to `false` after the callback is executed. + * + * @param callback The callback to execute while undo/redo events are being + * captured. + */ + // capture(callback: () => void): void; + capture(): void; + // do(state: State, undo: Undo): void; } -export type Undo = (state: State) => [state: State, redo: Redo]; -export type Redo = (state: State) => [state: State, undo: Undo]; +// export type Undo = (state: State) => [state: State, redo: Redo]; +// export type Redo = (state: State) => [state: State, undo: Undo]; From d9a9ab427aa0bcd9729799bd7140562abb978a39 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 14 Mar 2025 19:22:42 +0100 Subject: [PATCH 09/33] =?UTF-8?q?refactor(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=92=A1=20cleanup=20undo/redo=20controller=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/DomController.ts | 2 +- .../dom/UndoRedoController.ts | 119 ------------------ src/json-crdt-peritext-ui/dom/types.ts | 3 + src/json-crdt-peritext-ui/dom/undo/DomUndo.ts | 84 +++++++++++++ .../dom/undo/UndoRedoController.ts | 61 +++++++++ .../events/defaults/PeritextEventDefaults.ts | 4 +- src/json-crdt-peritext-ui/types.ts | 20 ++- 7 files changed, 160 insertions(+), 133 deletions(-) delete mode 100644 src/json-crdt-peritext-ui/dom/UndoRedoController.ts create mode 100644 src/json-crdt-peritext-ui/dom/undo/DomUndo.ts create mode 100644 src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index a587c6f126..2eae018c30 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -4,7 +4,7 @@ import {CursorController} from './CursorController'; import {RichTextController} from './RichTextController'; import {KeyController} from './KeyController'; import {CompositionController} from './CompositionController'; -import {UndoRedoController} from './UndoRedoController'; +import {UndoRedoController} from './undo/UndoRedoController'; import type {PeritextEventDefaults} from '../events/defaults/PeritextEventDefaults'; import type {PeritextEventTarget} from '../events/PeritextEventTarget'; import type {PeritextRenderingSurfaceApi, UiLifeCycles} from '../dom/types'; diff --git a/src/json-crdt-peritext-ui/dom/UndoRedoController.ts b/src/json-crdt-peritext-ui/dom/UndoRedoController.ts deleted file mode 100644 index 8a1a06e931..0000000000 --- a/src/json-crdt-peritext-ui/dom/UndoRedoController.ts +++ /dev/null @@ -1,119 +0,0 @@ -import {Peritext} from '../../json-crdt-extensions'; -import type {Printable} from 'tree-dump'; -import type {UiLifeCycles} from './types'; -import type {Patch} from '../../json-crdt-patch'; -import type {UndoRedoCollector} from '../types'; - -export interface UndoRedoControllerOpts { - txt: Peritext; -} - -export class UndoRedoController implements UndoRedoCollector, UiLifeCycles, Printable { - private _duringUpdate: boolean = false; - private _stack: unknown[] = []; - private el!: HTMLElement; - - constructor ( - public readonly opts: UndoRedoControllerOpts, - // public onundo?: (state: unknown) => void, - // public onredo?: (state: unknown) => void, - ) {} - - protected captured = new WeakSet(); - // protected undoable(patch: Patch): void {} - - // public live: boolean = false; - - // public capture(callback: () => void): void { - public capture(): void { - const currentPatch = this.opts.txt.model.api.builder.patch; - this.captured.add(currentPatch); - // this.live = true; - // try { - // callback(); - // } finally { - // this.undoable.add(this.opts.txt.model.api.builder.patch); - // // this.live = false; - // } - } - - /** ------------------------------------------------------ {@link UndoRedo} */ - - public do(state: unknown): void { - const activeElement = document.activeElement; - const el = this.el; - const style = el.style; - try { - this._duringUpdate = true; - style.visibility = 'visible'; - el.focus(); - document.execCommand?.('insertText', false, '|'); - } finally { - el.blur(); - this._duringUpdate = false; - // style.visibility = 'hidden'; - } - (activeElement as HTMLElement)?.focus?.(); - } - - /** -------------------------------------------------- {@link UiLifeCycles} */ - - public start(): void { - const el = this.el = document.createElement('div'); - el.tabIndex = -1; - el.contentEditable = 'true'; - el.setAttribute('aria-hidden', 'true'); - const style = el.style; - // style.opacity = '0'; - style.position = 'fixed'; - // style.top = '-1000px'; - style.top = '10px'; - style.left = '10px'; - style.pointerEvents = 'none'; - // style.fontSize = '2px'; - style.fontSize = '8px'; - // style.visibility = 'hidden'; - document.body.appendChild(el); - el.addEventListener('focus', this.onFocus); - el.addEventListener('input', this.onInput); - const {opts, captured} = this; - const {txt} = opts; - txt.model.api.onFlush.listen((patch) => { - const isCaptured = captured.has(patch); - if (isCaptured) { - captured.delete(patch); - console.log('flush 2', patch + ''); - } - }); - } - - public stop(): void { - const el = this.el; - document.body.removeChild(el); - el.removeEventListener('focus', this.onFocus); - el.removeEventListener('input', this.onInput); - } - - public readonly onFocus = () => { - window.setTimeout(() => this.el.blur(), 0); - }; - - public readonly onInput = () => { - if (!this._duringUpdate) { - // callback(this.data); - } - // clear selection, otherwise user copy gesture will copy value - // nb. this _probably_ won't work inside Shadow DOM - // nb. this is mitigated by the fact that we set visibility: 'hidden' - // const s = window.getSelection(); - // if (s.containsNode(this._ctrl, true)) { - // s.removeAllRanges(); - // } - }; - - /** ----------------------------------------------------- {@link Printable} */ - - public toString(tab?: string): string { - throw new Error('Method not implemented.'); - } -} diff --git a/src/json-crdt-peritext-ui/dom/types.ts b/src/json-crdt-peritext-ui/dom/types.ts index dcd5ef2503..ffe407946d 100644 --- a/src/json-crdt-peritext-ui/dom/types.ts +++ b/src/json-crdt-peritext-ui/dom/types.ts @@ -1,3 +1,6 @@ +/** + * @todo Unify this with {@link UiLifeCycles}, join interfaces. + */ export interface UiLifeCycles { /** Called when UI component is mounted. */ start(): void; diff --git a/src/json-crdt-peritext-ui/dom/undo/DomUndo.ts b/src/json-crdt-peritext-ui/dom/undo/DomUndo.ts new file mode 100644 index 0000000000..d9f0ca5cf4 --- /dev/null +++ b/src/json-crdt-peritext-ui/dom/undo/DomUndo.ts @@ -0,0 +1,84 @@ +import type {UndoManager, UndoItem} from '../../types'; +import type {UiLifeCycles} from '../types'; + +/** + * A DOM-based undo manager. Integrates with native undo/redo functionality of + * the browser. Supports user Ctrl+Z and Ctrl+Shift+Z shortcuts and application + * context menu undo/redo events. + */ +export class DomUndo implements UndoManager, UiLifeCycles { + /** Whether we are in a process of pushing a new undo item. */ + private _push: boolean = false; + /** The DOM element, which keeps text content for native undo/redo integration. */ + protected el!: HTMLElement; + /** The last known text contents length of the `el`. */ + protected tlen: number = 0; + /** Undo stack. */ + public undo: UndoItem[] = []; + /** Redo stack. */ + public redo: UndoItem[] = []; + + // /** ------------------------------------------------------ {@link UndoRedo} */ + + public push(undo: UndoItem): void { + const el = this.el; + // TODO: restore previous selection (multiple ranges), not just focus + const activeElement = document.activeElement; + try { + this._push = true; + const style = el.style; + this.undo.push(undo as UndoItem); + this.redo = []; + style.visibility = 'visible'; + el.focus(); + document.execCommand?.('insertText', false, '|'); + } finally { + el.blur(); + this._push = false; + // style.visibility = 'hidden'; + (activeElement as HTMLElement)?.focus?.(); + } + } + + /** -------------------------------------------------- {@link UiLifeCycles} */ + + public start(): void { + const el = this.el = document.createElement('div'); + el.tabIndex = -1; + el.contentEditable = 'true'; + el.setAttribute('aria-hidden', 'true'); + const style = el.style; + // style.opacity = '0'; + style.position = 'fixed'; + // style.top = '-1000px'; + style.top = '10px'; + style.left = '10px'; + style.pointerEvents = 'none'; + // style.fontSize = '2px'; + style.fontSize = '8px'; + // style.visibility = 'hidden'; + document.body.appendChild(el); + el.addEventListener('focus', this.onFocus); + el.addEventListener('input', this.onInput); + } + + public stop(): void { + const el = this.el; + document.body.removeChild(el); + el.removeEventListener('focus', this.onFocus); + el.removeEventListener('input', this.onInput); + } + + public readonly onFocus = () => { + setTimeout(() => this.el.blur(), 0); + }; + + public readonly onInput = () => { + if (this._push) { + console.log('onInput PUSH'); + // callback(this.data); + } else { + console.log('onInput HISTORY'); + } + }; +} diff --git a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts b/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts new file mode 100644 index 0000000000..29e6458787 --- /dev/null +++ b/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts @@ -0,0 +1,61 @@ +import {Peritext} from '../../../json-crdt-extensions'; +import {DomUndo} from './DomUndo'; +import type {Printable} from 'tree-dump'; +import type {UiLifeCycles} from '../types'; +import type {Patch} from '../../../json-crdt-patch'; +import type {RedoCallback, RedoItem, UndoCallback, UndoCollector, UndoItem} from '../../types'; + +export interface UndoRedoControllerOpts { + txt: Peritext; +} + +export class UndoRedoController implements UndoCollector, UiLifeCycles, Printable { + protected undo = new DomUndo(); + + constructor ( + public readonly opts: UndoRedoControllerOpts, + ) {} + + protected captured = new WeakSet(); + + /** ------------------------------------------------- {@link UndoCollector} */ + + public capture(): void { + const currentPatch = this.opts.txt.model.api.builder.patch; + this.captured.add(currentPatch); + } + + /** -------------------------------------------------- {@link UiLifeCycles} */ + + public start(): void { + this.undo.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, this._undo]; + this.undo.push(item); + } + }); + } + + public stop(): void { + this.undo.stop(); + } + + public readonly _undo: UndoCallback = (state: Patch) => { + return [state, this._redo] as RedoItem; + }; + + public readonly _redo: RedoCallback = (state: Patch) => { + return [state, this._undo] as RedoItem; + }; + + /** ----------------------------------------------------- {@link Printable} */ + + public toString(tab?: string): string { + throw new Error('Method not implemented.'); + } +} diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index 8c67965fbc..cf43093ebd 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -8,7 +8,7 @@ import type {EditorSlices} from '../../../json-crdt-extensions/peritext/editor/E import type * as events from '../types'; import type {PeritextClipboard, PeritextClipboardData} from '../clipboard/types'; // import type {ITimespanStruct} from '../../../json-crdt-patch'; -import type {UndoRedoCollector} from '../../types'; +import type {UndoCollector} from '../../types'; const toText = (buf: Uint8Array) => new TextDecoder().decode(buf); @@ -27,7 +27,7 @@ export interface PeritextEventDefaultsOpts { * will not be executed. */ export class PeritextEventDefaults implements PeritextEventHandlerMap { - public undo?: UndoRedoCollector; + public undo?: UndoCollector; public constructor( public readonly txt: Peritext, diff --git a/src/json-crdt-peritext-ui/types.ts b/src/json-crdt-peritext-ui/types.ts index 588c3aaf8f..3feccac7ba 100644 --- a/src/json-crdt-peritext-ui/types.ts +++ b/src/json-crdt-peritext-ui/types.ts @@ -1,10 +1,4 @@ -export interface UndoRedoCollector { - /** - * Marks whether the collector is currently capturing events for undo/redo. - * Can be mutated to turn on/off capturing. - */ - // live: boolean; - +export interface UndoCollector { /** * Captures events for undo/redo withing the callback. And sets `live` state * to `false` after the callback is executed. @@ -12,10 +6,14 @@ export interface UndoRedoCollector { * @param callback The callback to execute while undo/redo events are being * captured. */ - // capture(callback: () => void): void; capture(): void; - // do(state: State, undo: Undo): void; } -// export type Undo = (state: State) => [state: State, redo: Redo]; -// export type Redo = (state: State) => [state: State, undo: Undo]; +export interface UndoManager { + push(undo: UndoItem): void; +} + +export type UndoItem = [state: UndoState, undo: UndoCallback]; +export type UndoCallback = (state: UndoState) => RedoItem; +export type RedoItem = [state: RedoState, redo: RedoCallback]; +export type RedoCallback = (state: RedoState) => UndoItem; \ No newline at end of file From d494e0599a629b88cba00d6fa537193444412e19 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 15 Mar 2025 01:25:42 +0100 Subject: [PATCH 10/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20improve=20undo=20manager=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/undo/UndoRedoController.ts | 5 +++-- .../dom/undo/{DomUndo.ts => WebUndo.ts} | 19 +++++++++++++++---- .../events/defaults/PeritextEventDefaults.ts | 18 +++++------------- src/json-crdt-peritext-ui/types.ts | 7 ++----- 4 files changed, 25 insertions(+), 24 deletions(-) rename src/json-crdt-peritext-ui/dom/undo/{DomUndo.ts => WebUndo.ts} (84%) diff --git a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts b/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts index 29e6458787..03fdf1ebd7 100644 --- a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts +++ b/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts @@ -1,5 +1,5 @@ import {Peritext} from '../../../json-crdt-extensions'; -import {DomUndo} from './DomUndo'; +import {WebUndo} from './WebUndo'; import type {Printable} from 'tree-dump'; import type {UiLifeCycles} from '../types'; import type {Patch} from '../../../json-crdt-patch'; @@ -10,7 +10,7 @@ export interface UndoRedoControllerOpts { } export class UndoRedoController implements UndoCollector, UiLifeCycles, Printable { - protected undo = new DomUndo(); + protected undo = new WebUndo(); constructor ( public readonly opts: UndoRedoControllerOpts, @@ -46,6 +46,7 @@ export class UndoRedoController implements UndoCollector, UiLifeCycles, Printabl } public readonly _undo: UndoCallback = (state: Patch) => { + console.log('UNDO'); return [state, this._redo] as RedoItem; }; diff --git a/src/json-crdt-peritext-ui/dom/undo/DomUndo.ts b/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts similarity index 84% rename from src/json-crdt-peritext-ui/dom/undo/DomUndo.ts rename to src/json-crdt-peritext-ui/dom/undo/WebUndo.ts index d9f0ca5cf4..65fd4809a9 100644 --- a/src/json-crdt-peritext-ui/dom/undo/DomUndo.ts +++ b/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts @@ -6,7 +6,7 @@ import type {UiLifeCycles} from '../types'; * the browser. Supports user Ctrl+Z and Ctrl+Shift+Z shortcuts and application * context menu undo/redo events. */ -export class DomUndo implements UndoManager, UiLifeCycles { +export class WebUndo implements UndoManager, UiLifeCycles { /** Whether we are in a process of pushing a new undo item. */ private _push: boolean = false; /** The DOM element, which keeps text content for native undo/redo integration. */ @@ -18,6 +18,14 @@ export class DomUndo implements UndoManager, UiLifeCycles { /** Redo stack. */ public redo: UndoItem[] = []; + protected _undo() { + const undo = this.undo.pop(); + if (undo) { + const redo = undo[1](undo[0]); + this.redo.push(redo); + } + } + // /** ------------------------------------------------------ {@link UndoRedo} */ public push(undo: UndoItem): void { @@ -74,11 +82,14 @@ export class DomUndo implements UndoManager, UiLifeCycles { }; public readonly onInput = () => { + const text = this.el.innerText; if (this._push) { - console.log('onInput PUSH'); - // callback(this.data); + this.tlen = text.length; + console.log(this.tlen, this.undo.length); } else { - console.log('onInput HISTORY'); + while (this.undo.length && this.undo.length > text.length) this._undo(); + // if (text.length < this.tlen) { + // } } }; } diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index cf43093ebd..d7c4e5ce03 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -56,19 +56,10 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { // }; public readonly insert = (event: CustomEvent) => { - // this.record(() => { - // console.log('here') - const text = event.detail.text; - const editor = this.txt.editor; - editor.insert(text); - this.undo?.capture(); - // const inserts: ITimespanStruct[] = editor.insert(text); - // const after: Point[] = []; - // editor.forCursor(cursor => { - // after.push(cursor.start.clone()); - // }); - // }); - // this.opts.undo?.do([text, after, inserts], this.insertUndo); + const text = event.detail.text; + const editor = this.txt.editor; + editor.insert(text); + this.undo?.capture(); }; public readonly delete = (event: CustomEvent) => { @@ -79,6 +70,7 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { editor.cursor.set(point); } editor.delete(len, unit); + this.undo?.capture(); }; public readonly cursor = (event: CustomEvent) => { diff --git a/src/json-crdt-peritext-ui/types.ts b/src/json-crdt-peritext-ui/types.ts index 3feccac7ba..378c9dea13 100644 --- a/src/json-crdt-peritext-ui/types.ts +++ b/src/json-crdt-peritext-ui/types.ts @@ -1,10 +1,7 @@ export interface UndoCollector { /** - * Captures events for undo/redo withing the callback. And sets `live` state - * to `false` after the callback is executed. - * - * @param callback The callback to execute while undo/redo events are being - * captured. + * Mark the currently minted change {@link Patch} in {@link Builder} for undo. + * It will be picked up during the next flush. */ capture(): void; } From d95814a8002c6fc40ea1f29f8be4317eddf7498a Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 15 Mar 2025 03:00:46 +0100 Subject: [PATCH 11/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20introduce=20history=20undo/redo=20annals=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../events/defaults/PeritextEventDefaults.ts | 28 ++++--------------- src/json-crdt-peritext-ui/events/types.ts | 27 ++++++++++++++++-- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index d7c4e5ce03..7de8856042 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -1,5 +1,4 @@ import {CursorAnchor} from '../../../json-crdt-extensions/peritext/slice/constants'; -// import type {Point} from '../../../json-crdt-extensions/peritext/rga/Point'; import type {Range} from '../../../json-crdt-extensions/peritext/rga/Range'; import type {PeritextDataTransfer} from '../../../json-crdt-extensions/peritext/transfer/PeritextDataTransfer'; import type {PeritextEventHandlerMap, PeritextEventTarget} from '../PeritextEventTarget'; @@ -7,13 +6,10 @@ import type {Peritext} from '../../../json-crdt-extensions/peritext'; import type {EditorSlices} from '../../../json-crdt-extensions/peritext/editor/EditorSlices'; import type * as events from '../types'; import type {PeritextClipboard, PeritextClipboardData} from '../clipboard/types'; -// import type {ITimespanStruct} from '../../../json-crdt-patch'; import type {UndoCollector} from '../../types'; const toText = (buf: Uint8Array) => new TextDecoder().decode(buf); -// type InsertUndoState = [text: string, after: Point[], inserts: ITimespanStruct[]]; - export interface PeritextEventDefaultsOpts { clipboard?: PeritextClipboard; transfer?: PeritextDataTransfer; @@ -35,26 +31,8 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { public readonly opts: PeritextEventDefaultsOpts = {}, ) {} - // protected record(callback: () => void): void { - // const {undo} = this; - // undo ? undo.capture(callback) : callback(); - // } - public readonly change = (event: CustomEvent) => {}; - // private insertUndo: Undo = ([text, after, inserts]) => { - // // TODO: delete `insertions`. - // console.log('delete', inserts); - // return [[text, after, inserts], this.insertRedo]; - // }; - - // private insertRedo: Redo = ([text, after]) => { - // // TODO: insert `text` after `after` locations. - // console.log('insert', text, 'after', after); - // const inserts: ITimespanStruct[] = []; - // return [[text, after, inserts], this.insertUndo]; - // }; - public readonly insert = (event: CustomEvent) => { const text = event.detail.text; const editor = this.txt.editor; @@ -392,4 +370,10 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { } } }; + + public readonly annals = (event: CustomEvent) => { + const {action, patch} = event.detail; + this.txt.model.applyPatch(patch); + console.log('annals', action, patch); + }; } diff --git a/src/json-crdt-peritext-ui/events/types.ts b/src/json-crdt-peritext-ui/events/types.ts index 133de4dc89..0aa82b4312 100644 --- a/src/json-crdt-peritext-ui/events/types.ts +++ b/src/json-crdt-peritext-ui/events/types.ts @@ -1,6 +1,7 @@ import type {Point} from '../../json-crdt-extensions/peritext/rga/Point'; import type {Position as EditorPosition} from '../../json-crdt-extensions/peritext/editor/types'; import type {SliceType} from '../../json-crdt-extensions/peritext/slice/types'; +import type {Patch} from '../../json-crdt-patch'; /** * Dispatched every time any other event is dispatched. @@ -282,8 +283,8 @@ export interface BufferDetail { * * - `auto`: Automatically determine the format based on the data in the * clipboard. - * - `json`: Specifies the default Peritext {@link Editor} export/import format - * in JSON POJO format. + * - `json`: Specifies the default Peritext {@link Editor} export/import + * format in JSON POJO format. * - `jsonml`: HTML markup in JSONML format. * - `hast`: HTML markup in HAST format. * - `text`: Plain text format. Copy and paste text only. @@ -316,6 +317,27 @@ export interface BufferDetail { }; } +/** + * The "annals" event manages undo and redo actions, typically triggered by + * common keyboard shortcuts like `Ctrl+Z` and `Ctrl+Shift+Z`. + */ +export interface AnnalsDetail { + /** The action to perform. */ + action: 'undo' | 'redo'; + + /** + * The original {@link Patch} from which the undo/redo action was originally + * created. + */ + source: Patch; + + /** + * The {@link Patch} that will be applied to the document to undo or redo the + * action, unless the action is cancelled. + */ + patch: Patch; +} + /** * Position represents a caret position in the document. The position can either * be an instance of {@link Point} or a numeric position in the document, which @@ -339,4 +361,5 @@ export type PeritextEventDetailMap = { format: FormatDetail; marker: MarkerDetail; buffer: BufferDetail; + annals: AnnalsDetail; }; From 29633d41b339513cf670771993df0cf1444aeb50 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 15 Mar 2025 03:01:16 +0100 Subject: [PATCH 12/33] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20improve?= =?UTF-8?q?=20Log=20construction=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/log/Log.ts | 56 +++++++++++++++++++++--------------- src/json-crdt/model/Model.ts | 4 +-- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/src/json-crdt/log/Log.ts b/src/json-crdt/log/Log.ts index 1c91048814..e9b45c061d 100644 --- a/src/json-crdt/log/Log.ts +++ b/src/json-crdt/log/Log.ts @@ -18,6 +18,8 @@ import type {JsonNode} from '../nodes/types'; * The log can be used to replay the history of patches to any point in time, * from the "start" to the "end" of the log, and return the resulting {@link Model} * state. + * + * @todo Make this implement UILifecycle (start, stop) interface. */ export class Log> implements Printable { /** @@ -31,29 +33,17 @@ export class Log> implements Printable { */ public static fromNewModel>(model: Model): Log { const sid = model.clock.sid; - const log = new Log(() => Model.create(undefined, sid) as Model); + const log = new Log(() => Model.create(undefined, sid) as Model); /** @todo Maybe provide second arg to `new Log(...)` */ const api = model.api; if (api.builder.patch.ops.length) log.end.applyPatch(api.flush()); return log; } - /** - * Model factory function that creates a new JSON CRDT model instance, which - * is used as the starting point of the log. It is called every time a new - * model is needed to replay the log. - * - * @readonly Internally this function may be updated, but externally it is - * read-only. - */ - public start: () => Model; - - /** - * The end of the log, the current state of the document. It is the model - * instance that is used to apply new patches to the log. - * - * @readonly - */ - public readonly end: Model; + public static from>(model: Model): Log { + const frozen = model.toBinary(); + const beginning = () => Model.fromBinary(frozen); + return new Log(beginning, model); + } /** * The collection of patches which are applied to the `start()` model to reach @@ -68,9 +58,28 @@ export class Log> implements Printable { private __onPatch: FanOutUnsubscribe; private __onFlush: FanOutUnsubscribe; - constructor(start: () => Model) { - this.start = start; - const end = (this.end = start()); + constructor( + /** + * Model factory function that creates a new JSON CRDT model instance, which + * is used as the starting point of the log. It is called every time a new + * model is needed to replay the log. + * + * @readonly Internally this function may be updated, but externally it is + * read-only. + * + * @todo Rename to something else to give way to a `start()` in UILifecycle. + * Call "snapshot". Maybe introduce `type Snapshot = () => Model;`. + */ + public start: () => Model, + + /** + * The end of the log, the current state of the document. It is the model + * instance that is used to apply new patches to the log. + * + * @readonly + */ + public readonly end: Model = start(), + ) { const onPatch = (patch: Patch) => { const id = patch.getId(); if (!id) return; @@ -82,7 +91,7 @@ export class Log> implements Printable { } /** - * Call this method to destroy the `PatchLog` instance. It unsubscribes patch + * Call this method to destroy the {@link Log} instance. It unsubscribes patch * and flush listeners from the `end` model and clears the patch log. */ public destroy() { @@ -141,6 +150,7 @@ export class Log> implements Printable { this.start = (): Model => { const model = oldStart(); for (const patch of newStartPatches) model.applyPatch(patch); + /** @todo Freeze the old model here, by `model.toBinary()`, it needs to be cloned on .start() anyways. */ return model; }; } @@ -166,7 +176,7 @@ export class Log> implements Printable { const op = ops[i]; const opId = op.id; if (op instanceof InsStrOp || op instanceof InsArrOp || op instanceof InsBinOp) { - builder.del(op.ref, [new Timespan(opId.sid, opId.time, op.span())]); + builder.del(op.obj, [new Timespan(opId.sid, opId.time, op.span())]); continue; } const model = getModel(); diff --git a/src/json-crdt/model/Model.ts b/src/json-crdt/model/Model.ts index 079a0e8740..cb1270fbf0 100644 --- a/src/json-crdt/model/Model.ts +++ b/src/json-crdt/model/Model.ts @@ -179,8 +179,8 @@ export class Model> implements Printable { * encoding. * @returns An instance of a model. */ - public static readonly fromBinary = (data: Uint8Array): Model => { - return decoder.decode(data); + public static readonly fromBinary = >(data: Uint8Array): Model => { + return decoder.decode(data) as unknown as Model; }; /** From 0e9e083d4f42f516801473a6ab26e8a26596e1f9 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 15 Mar 2025 03:01:58 +0100 Subject: [PATCH 13/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20progress=20on=20undo=20manager=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/DomController.ts | 6 ++++-- src/json-crdt-peritext-ui/dom/types.ts | 2 ++ .../dom/undo/UndoRedoController.ts | 19 ++++++++++++++++--- src/json-crdt-peritext-ui/dom/undo/WebUndo.ts | 2 ++ .../react/PeritextView.tsx | 4 +++- src/json-crdt-peritext-ui/react/state.ts | 16 ++++++++++++++-- 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index 2eae018c30..13027c68df 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -8,10 +8,12 @@ import {UndoRedoController} from './undo/UndoRedoController'; 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 { @@ -24,7 +26,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering public readonly undo: UndoRedoController; 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})); @@ -32,7 +34,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering this.input = new InputController({et, source, txt, comp}); this.cursor = new CursorController({et, source, txt, keys}); this.richText = new RichTextController({et, source, txt}); - this.undo = new UndoRedoController({txt}); + this.undo = new UndoRedoController({et, txt, log}); } /** -------------------------------------------------- {@link UiLifeCycles} */ diff --git a/src/json-crdt-peritext-ui/dom/types.ts b/src/json-crdt-peritext-ui/dom/types.ts index ffe407946d..d4cbb76fc0 100644 --- a/src/json-crdt-peritext-ui/dom/types.ts +++ b/src/json-crdt-peritext-ui/dom/types.ts @@ -1,5 +1,7 @@ /** * @todo Unify this with {@link UiLifeCycles}, join interfaces. + * @todo Rename this to something like "disposable", as it does not have to be + * a UI component. */ export interface UiLifeCycles { /** Called when UI component is mounted. */ diff --git a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts b/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts index 03fdf1ebd7..b9ba35e772 100644 --- a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts +++ b/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts @@ -4,9 +4,13 @@ import type {Printable} from 'tree-dump'; import type {UiLifeCycles} from '../types'; import type {Patch} from '../../../json-crdt-patch'; 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 UndoRedoController implements UndoCollector, UiLifeCycles, Printable { @@ -45,9 +49,18 @@ export class UndoRedoController implements UndoCollector, UiLifeCycles, Printabl this.undo.stop(); } - public readonly _undo: UndoCallback = (state: Patch) => { - console.log('UNDO'); - return [state, this._redo] as RedoItem; + public readonly _undo: UndoCallback = (source: Patch) => { + const {log} = this.opts; + const patch = log.undo(source); + this.opts.et.dispatch('annals', { + action: 'undo', + source, + patch, + }); + // log.end.applyPatch(undoPatch); + console.log('doPatch', source + ''); + console.log('undoPatch', patch + ''); + return [patch, this._redo] as RedoItem; }; public readonly _redo: RedoCallback = (state: Patch) => { diff --git a/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts b/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts index 65fd4809a9..c8d59b8cf1 100644 --- a/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts +++ b/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts @@ -37,6 +37,7 @@ export class WebUndo implements UndoManager, UiLifeCycles { const style = el.style; this.undo.push(undo as UndoItem); this.redo = []; + el.setAttribute('aria-hidden', 'false'); style.visibility = 'visible'; el.focus(); document.execCommand?.('insertText', false, '|'); @@ -44,6 +45,7 @@ export class WebUndo implements UndoManager, UiLifeCycles { el.blur(); this._push = false; // style.visibility = 'hidden'; + el.setAttribute('aria-hidden', 'true'); (activeElement as HTMLElement)?.focus?.(); } } diff --git a/src/json-crdt-peritext-ui/react/PeritextView.tsx b/src/json-crdt-peritext-ui/react/PeritextView.tsx index 9ebb5606ff..ffca05a0cc 100644 --- a/src/json-crdt-peritext-ui/react/PeritextView.tsx +++ b/src/json-crdt-peritext-ui/react/PeritextView.tsx @@ -62,6 +62,8 @@ export const PeritextView: React.FC = React.memo((props) => { return state; }, [peritext, plugins, rerender, onState]); + React.useEffect(() => state.start(), [state]); + // biome-ignore lint: lint/correctness/useExhaustiveDependencies const ref = React.useCallback( (el: null | HTMLDivElement) => { @@ -75,7 +77,7 @@ export const PeritextView: React.FC = React.memo((props) => { return; } if (dom && dom.opts.source === el) return; - const ctrl = new DomController({source: el, events: state.events}); + const ctrl = new DomController({source: el, events: state.events, log: state.log}); state.events.undo = ctrl.undo; ctrl.start(); state.dom = ctrl; diff --git a/src/json-crdt-peritext-ui/react/state.ts b/src/json-crdt-peritext-ui/react/state.ts index 571761ab12..f297ce3d91 100644 --- a/src/json-crdt-peritext-ui/react/state.ts +++ b/src/json-crdt-peritext-ui/react/state.ts @@ -1,15 +1,27 @@ +import {Log} from '../../json-crdt/log/Log'; +import {Model} from '../../json-crdt/model'; import type {PeritextPlugin} from './types'; import type {Peritext} from '../../json-crdt-extensions/peritext/Peritext'; import type {DomController} from '../dom/DomController'; import type {PeritextEventDefaults} from '../events/defaults/PeritextEventDefaults'; +import type {UiLifeCyclesRender} from '../dom/types'; -export class PeritextSurfaceState { +export class PeritextSurfaceState implements UiLifeCyclesRender { public dom?: DomController = void 0; + public log: Log; constructor( public readonly peritext: Peritext, public readonly events: PeritextEventDefaults, public readonly rerender: () => void, public readonly plugins: PeritextPlugin[], - ) {} + ) { + this.log = Log.from(peritext.model); + } + + public start() { + return () => { + this.log.destroy(); + }; + } } From c777adf1c9f160d6762002f569d5b1b7a21a52ea Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 15 Mar 2025 15:24:08 +0100 Subject: [PATCH 14/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20implement=20history=20redo=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/undo/UndoRedoController.ts | 28 ++++++++++++------- src/json-crdt-peritext-ui/dom/undo/WebUndo.ts | 11 ++++++-- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts b/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts index b9ba35e772..2252455f46 100644 --- a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts +++ b/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts @@ -49,22 +49,30 @@ export class UndoRedoController implements UndoCollector, UiLifeCycles, Printabl this.undo.stop(); } - public readonly _undo: UndoCallback = (source: Patch) => { - const {log} = this.opts; - const patch = log.undo(source); - this.opts.et.dispatch('annals', { + public readonly _undo: UndoCallback = (doPatch: Patch) => { + const {log, et} = this.opts; + const patch = log.undo(doPatch); + et.dispatch('annals', { action: 'undo', - source, + source: doPatch, patch, }); - // log.end.applyPatch(undoPatch); - console.log('doPatch', source + ''); + console.log('doPatch', doPatch + ''); console.log('undoPatch', patch + ''); - return [patch, this._redo] as RedoItem; + return [doPatch, this._redo] as RedoItem; }; - public readonly _redo: RedoCallback = (state: Patch) => { - return [state, this._undo] as RedoItem; + public readonly _redo: RedoCallback = (doPatch: Patch) => { + const {log, et} = this.opts; + const redoPatch = doPatch.rebase(log.end.clock.time); + et.dispatch('annals', { + action: 'redo', + source: doPatch, + patch: redoPatch, + }); + console.log('doPatch', doPatch + ''); + console.log('redoPatch', redoPatch + ''); + return [redoPatch, this._undo] as RedoItem; }; /** ----------------------------------------------------- {@link Printable} */ diff --git a/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts b/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts index c8d59b8cf1..1492aa2ab6 100644 --- a/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts +++ b/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts @@ -26,6 +26,14 @@ export class WebUndo implements UndoManager, UiLifeCycles { } } + protected _redo() { + const redo = this.redo.pop(); + if (redo) { + const undo = redo[1](redo[0]); + this.undo.push(undo); + } + } + // /** ------------------------------------------------------ {@link UndoRedo} */ public push(undo: UndoItem): void { @@ -90,8 +98,7 @@ export class WebUndo implements UndoManager, UiLifeCycles { console.log(this.tlen, this.undo.length); } else { while (this.undo.length && this.undo.length > text.length) this._undo(); - // if (text.length < this.tlen) { - // } + while (this.redo.length && this.undo.length < text.length) this._redo(); } }; } From 784447643f0b613e64e7c237c60c8a81810e8480 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 15 Mar 2025 15:27:59 +0100 Subject: [PATCH 15/33] =?UTF-8?q?perf(json-crdt-peritext-ui):=20=E2=9A=A1?= =?UTF-8?q?=EF=B8=8F=20do=20not=20track=20of=20undo=20element=20text=20len?= =?UTF-8?q?gth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-peritext-ui/dom/undo/WebUndo.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts b/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts index 1492aa2ab6..28063ef5c7 100644 --- a/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts +++ b/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts @@ -11,8 +11,6 @@ export class WebUndo implements UndoManager, UiLifeCycles { private _push: boolean = false; /** The DOM element, which keeps text content for native undo/redo integration. */ protected el!: HTMLElement; - /** The last known text contents length of the `el`. */ - protected tlen: number = 0; /** Undo stack. */ public undo: UndoItem[] = []; /** Redo stack. */ @@ -92,13 +90,10 @@ export class WebUndo implements UndoManager, UiLifeCycles { }; public readonly onInput = () => { - const text = this.el.innerText; - if (this._push) { - this.tlen = text.length; - console.log(this.tlen, this.undo.length); - } else { - while (this.undo.length && this.undo.length > text.length) this._undo(); - while (this.redo.length && this.undo.length < text.length) this._redo(); + const tlen = this.el.innerText.length; + if (!this._push) { + while (this.undo.length && this.undo.length > tlen) this._undo(); + while (this.redo.length && this.undo.length < tlen) this._redo(); } }; } From 893d6d3d753912208699b8c4a0f746dd71376118 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 15 Mar 2025 17:05:24 +0100 Subject: [PATCH 16/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20cleanup=20undo=20history=20implementations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/undo/UndoRedoController.ts | 8 +++--- src/json-crdt-peritext-ui/dom/undo/WebUndo.ts | 28 ++++++++----------- .../events/defaults/PeritextEventDefaults.ts | 3 +- 3 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts b/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts index 2252455f46..db98564a18 100644 --- a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts +++ b/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts @@ -57,8 +57,8 @@ export class UndoRedoController implements UndoCollector, UiLifeCycles, Printabl source: doPatch, patch, }); - console.log('doPatch', doPatch + ''); - console.log('undoPatch', patch + ''); + // console.log('doPatch', doPatch + ''); + // console.log('undoPatch', patch + ''); return [doPatch, this._redo] as RedoItem; }; @@ -70,8 +70,8 @@ export class UndoRedoController implements UndoCollector, UiLifeCycles, Printabl source: doPatch, patch: redoPatch, }); - console.log('doPatch', doPatch + ''); - console.log('redoPatch', redoPatch + ''); + // console.log('doPatch', doPatch + ''); + // console.log('redoPatch', redoPatch + ''); return [redoPatch, this._undo] as RedoItem; }; diff --git a/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts b/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts index 28063ef5c7..ae3388410e 100644 --- a/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts +++ b/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts @@ -40,17 +40,15 @@ export class WebUndo implements UndoManager, UiLifeCycles { const activeElement = document.activeElement; try { this._push = true; - const style = el.style; - this.undo.push(undo as UndoItem); this.redo = []; el.setAttribute('aria-hidden', 'false'); - style.visibility = 'visible'; el.focus(); - document.execCommand?.('insertText', false, '|'); + document.execCommand?.('insertText', false, '.'); + const tlen = this.el.innerText.length; + if (tlen - 1 === this.undo.length) this.undo.push(undo as UndoItem); } finally { el.blur(); this._push = false; - // style.visibility = 'hidden'; el.setAttribute('aria-hidden', 'true'); (activeElement as HTMLElement)?.focus?.(); } @@ -64,15 +62,11 @@ export class WebUndo implements UndoManager, UiLifeCycles { el.contentEditable = 'true'; el.setAttribute('aria-hidden', 'true'); const style = el.style; - // style.opacity = '0'; - style.position = 'fixed'; - // style.top = '-1000px'; - style.top = '10px'; - style.left = '10px'; style.pointerEvents = 'none'; - // style.fontSize = '2px'; - style.fontSize = '8px'; - // style.visibility = 'hidden'; + style.position = 'fixed'; + style.fontSize = '1px'; + style.top = '-1000px'; + style.opacity = '0'; document.body.appendChild(el); el.addEventListener('focus', this.onFocus); el.addEventListener('input', this.onInput); @@ -86,14 +80,16 @@ export class WebUndo implements UndoManager, UiLifeCycles { } public readonly onFocus = () => { - setTimeout(() => this.el.blur(), 0); + const el = this.el; + setTimeout(() => el.blur(), 0); }; public readonly onInput = () => { const tlen = this.el.innerText.length; if (!this._push) { - while (this.undo.length && this.undo.length > tlen) this._undo(); - while (this.redo.length && this.undo.length < tlen) this._redo(); + const {undo, redo} = this; + while (undo.length && undo.length > tlen) this._undo(); + while (redo.length && undo.length < tlen) this._redo(); } }; } diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index 7de8856042..f43973df07 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -372,8 +372,7 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { }; public readonly annals = (event: CustomEvent) => { - const {action, patch} = event.detail; + const {patch} = event.detail; this.txt.model.applyPatch(patch); - console.log('annals', action, patch); }; } From 91d117a3be6966d76c28c7c8a553f1d6ebb4ed33 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 15 Mar 2025 23:01:07 +0100 Subject: [PATCH 17/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20undo/redo=20top=20toolbar=20buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/undo/UndoRedoController.ts | 16 +++++++++--- src/json-crdt-peritext-ui/dom/undo/WebUndo.ts | 26 ++++++++++++------- .../plugins/toolbar/TopToolbar/index.tsx | 7 +++++ src/json-crdt-peritext-ui/types.ts | 2 ++ 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts b/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts index db98564a18..a0f883c77e 100644 --- a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts +++ b/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts @@ -14,7 +14,7 @@ export interface UndoRedoControllerOpts { } export class UndoRedoController implements UndoCollector, UiLifeCycles, Printable { - protected undo = new WebUndo(); + protected manager = new WebUndo(); constructor ( public readonly opts: UndoRedoControllerOpts, @@ -29,10 +29,18 @@ export class UndoRedoController implements UndoCollector, UiLifeCycles, Printabl this.captured.add(currentPatch); } + public undo(): void { + this.manager.undo(); + } + + redo(): void { + this.manager.redo(); + } + /** -------------------------------------------------- {@link UiLifeCycles} */ public start(): void { - this.undo.start(); + this.manager.start(); const {opts, captured} = this; const {txt} = opts; txt.model.api.onFlush.listen((patch) => { @@ -40,13 +48,13 @@ export class UndoRedoController implements UndoCollector, UiLifeCycles, Printabl if (isCaptured) { captured.delete(patch); const item: UndoItem = [patch, this._undo]; - this.undo.push(item); + this.manager.push(item); } }); } public stop(): void { - this.undo.stop(); + this.manager.stop(); } public readonly _undo: UndoCallback = (doPatch: Patch) => { diff --git a/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts b/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts index ae3388410e..341bfba598 100644 --- a/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts +++ b/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts @@ -12,23 +12,23 @@ export class WebUndo implements UndoManager, UiLifeCycles { /** The DOM element, which keeps text content for native undo/redo integration. */ protected el!: HTMLElement; /** Undo stack. */ - public undo: UndoItem[] = []; + public uStack: UndoItem[] = []; /** Redo stack. */ - public redo: UndoItem[] = []; + public rStack: UndoItem[] = []; protected _undo() { - const undo = this.undo.pop(); + const undo = this.uStack.pop(); if (undo) { const redo = undo[1](undo[0]); - this.redo.push(redo); + this.rStack.push(redo); } } protected _redo() { - const redo = this.redo.pop(); + const redo = this.rStack.pop(); if (redo) { const undo = redo[1](redo[0]); - this.undo.push(undo); + this.uStack.push(undo); } } @@ -40,12 +40,12 @@ export class WebUndo implements UndoManager, UiLifeCycles { const activeElement = document.activeElement; try { this._push = true; - this.redo = []; + this.rStack = []; el.setAttribute('aria-hidden', 'false'); el.focus(); document.execCommand?.('insertText', false, '.'); const tlen = this.el.innerText.length; - if (tlen - 1 === this.undo.length) this.undo.push(undo as UndoItem); + if (tlen - 1 === this.uStack.length) this.uStack.push(undo as UndoItem); } finally { el.blur(); this._push = false; @@ -54,6 +54,14 @@ export class WebUndo implements UndoManager, UiLifeCycles { } } + undo(): void { + document?.execCommand?.('undo'); + } + + redo(): void { + document?.execCommand?.('redo'); + } + /** -------------------------------------------------- {@link UiLifeCycles} */ public start(): void { @@ -87,7 +95,7 @@ export class WebUndo implements UndoManager, UiLifeCycles { public readonly onInput = () => { const tlen = this.el.innerText.length; if (!this._push) { - const {undo, redo} = this; + const {uStack: undo, rStack: redo} = this; while (undo.length && undo.length > tlen) this._undo(); while (redo.length && undo.length < tlen) this._redo(); } diff --git a/src/json-crdt-peritext-ui/plugins/toolbar/TopToolbar/index.tsx b/src/json-crdt-peritext-ui/plugins/toolbar/TopToolbar/index.tsx index d810e95324..49c9a06e1f 100644 --- a/src/json-crdt-peritext-ui/plugins/toolbar/TopToolbar/index.tsx +++ b/src/json-crdt-peritext-ui/plugins/toolbar/TopToolbar/index.tsx @@ -78,6 +78,13 @@ export const TopToolbar: React.FC = ({ctx}) => { {blockGroupButton(CommonSliceType.p, 'Paragraph')} {blockGroupButton(CommonSliceType.blockquote, 'Blockquote')} {blockGroupButton(CommonSliceType.codeblock, 'Code Block')} + + {button('Undo', () => { + ctx.dom?.undo.undo(); + })} + {button('Redo', () => { + ctx.dom?.undo.redo(); + })} ); }; diff --git a/src/json-crdt-peritext-ui/types.ts b/src/json-crdt-peritext-ui/types.ts index 378c9dea13..4fac73eea5 100644 --- a/src/json-crdt-peritext-ui/types.ts +++ b/src/json-crdt-peritext-ui/types.ts @@ -8,6 +8,8 @@ export interface UndoCollector { export interface UndoManager { push(undo: UndoItem): void; + undo(): void; + redo(): void; } export type UndoItem = [state: UndoState, undo: UndoCallback]; From 073c3e59694c2c2d23c204fa03c67d5e0c947ab7 Mon Sep 17 00:00:00 2001 From: streamich Date: Sat, 15 Mar 2025 23:08:11 +0100 Subject: [PATCH 18/33] =?UTF-8?q?refactor(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=92=A1=20update=20class=20naming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-peritext-ui/dom/DomController.ts | 10 +++++----- .../AnnalsController.ts} | 4 ++-- .../dom/{undo => annals}/WebUndo.ts | 0 .../plugins/toolbar/TopToolbar/index.tsx | 4 ++-- src/json-crdt-peritext-ui/react/PeritextView.tsx | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) rename src/json-crdt-peritext-ui/dom/{undo/UndoRedoController.ts => annals/AnnalsController.ts} (95%) rename src/json-crdt-peritext-ui/dom/{undo => annals}/WebUndo.ts (100%) diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index 13027c68df..76693aaf86 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -4,7 +4,7 @@ import {CursorController} from './CursorController'; import {RichTextController} from './RichTextController'; import {KeyController} from './KeyController'; import {CompositionController} from './CompositionController'; -import {UndoRedoController} from './undo/UndoRedoController'; +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'; @@ -23,7 +23,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering public readonly input: InputController; public readonly cursor: CursorController; public readonly richText: RichTextController; - public readonly undo: UndoRedoController; + public readonly annals: AnnalsController; constructor(public readonly opts: DomControllerOpts) { const {source, events, log} = opts; @@ -34,7 +34,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering this.input = new InputController({et, source, txt, comp}); this.cursor = new CursorController({et, source, txt, keys}); this.richText = new RichTextController({et, source, txt}); - this.undo = new UndoRedoController({et, txt, log}); + this.annals = new AnnalsController({et, txt, log}); } /** -------------------------------------------------- {@link UiLifeCycles} */ @@ -45,7 +45,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering this.input.start(); this.cursor.start(); this.richText.start(); - this.undo.start(); + this.annals.start(); } public stop(): void { @@ -54,7 +54,7 @@ export class DomController implements UiLifeCycles, Printable, PeritextRendering this.input.stop(); this.cursor.stop(); this.richText.stop(); - this.undo.stop(); + this.annals.stop(); } /** ----------------------------------- {@link PeritextRenderingSurfaceApi} */ diff --git a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts b/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts similarity index 95% rename from src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts rename to src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts index a0f883c77e..50bc01b8e5 100644 --- a/src/json-crdt-peritext-ui/dom/undo/UndoRedoController.ts +++ b/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts @@ -13,7 +13,7 @@ export interface UndoRedoControllerOpts { et: PeritextEventTarget; } -export class UndoRedoController implements UndoCollector, UiLifeCycles, Printable { +export class AnnalsController implements UndoCollector, UiLifeCycles, Printable { protected manager = new WebUndo(); constructor ( @@ -33,7 +33,7 @@ export class UndoRedoController implements UndoCollector, UiLifeCycles, Printabl this.manager.undo(); } - redo(): void { + public redo(): void { this.manager.redo(); } diff --git a/src/json-crdt-peritext-ui/dom/undo/WebUndo.ts b/src/json-crdt-peritext-ui/dom/annals/WebUndo.ts similarity index 100% rename from src/json-crdt-peritext-ui/dom/undo/WebUndo.ts rename to src/json-crdt-peritext-ui/dom/annals/WebUndo.ts diff --git a/src/json-crdt-peritext-ui/plugins/toolbar/TopToolbar/index.tsx b/src/json-crdt-peritext-ui/plugins/toolbar/TopToolbar/index.tsx index 49c9a06e1f..c44dc8774b 100644 --- a/src/json-crdt-peritext-ui/plugins/toolbar/TopToolbar/index.tsx +++ b/src/json-crdt-peritext-ui/plugins/toolbar/TopToolbar/index.tsx @@ -80,10 +80,10 @@ export const TopToolbar: React.FC = ({ctx}) => { {blockGroupButton(CommonSliceType.codeblock, 'Code Block')} {button('Undo', () => { - ctx.dom?.undo.undo(); + ctx.dom?.annals.undo(); })} {button('Redo', () => { - ctx.dom?.undo.redo(); + ctx.dom?.annals.redo(); })} ); diff --git a/src/json-crdt-peritext-ui/react/PeritextView.tsx b/src/json-crdt-peritext-ui/react/PeritextView.tsx index ffca05a0cc..d2a23f701c 100644 --- a/src/json-crdt-peritext-ui/react/PeritextView.tsx +++ b/src/json-crdt-peritext-ui/react/PeritextView.tsx @@ -78,7 +78,7 @@ export const PeritextView: React.FC = React.memo((props) => { } if (dom && dom.opts.source === el) return; const ctrl = new DomController({source: el, events: state.events, log: state.log}); - state.events.undo = ctrl.undo; + state.events.undo = ctrl.annals; ctrl.start(); state.dom = ctrl; setDom(ctrl); From 45759a1ed0f08ae87bc6e82a7171355ab6b037d4 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 16 Mar 2025 00:56:43 +0100 Subject: [PATCH 19/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20add=20history=20trackign=20to=20more=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../events/defaults/PeritextEventDefaults.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index f43973df07..2464aa78bc 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -146,6 +146,7 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { break; } } + this.undo?.capture(); }; public readonly marker = (event: CustomEvent) => { @@ -164,6 +165,7 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { break; } } + this.undo?.capture(); }; public readonly buffer = async (event: CustomEvent) => { @@ -369,6 +371,7 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { break; } } + this.undo?.capture(); }; public readonly annals = (event: CustomEvent) => { From 33504680e422e6d4732acec1407d586e72e3b6fb Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 16 Mar 2025 01:45:20 +0100 Subject: [PATCH 20/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20save=20selection=20and=20restore=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-peritext-ui/dom/annals/WebUndo.ts | 6 +++--- src/json-crdt-peritext-ui/dom/util.ts | 17 +++++++++++++++++ .../events/clipboard/DomClipboard.ts | 7 +++---- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/json-crdt-peritext-ui/dom/annals/WebUndo.ts b/src/json-crdt-peritext-ui/dom/annals/WebUndo.ts index 341bfba598..4ed562aab7 100644 --- a/src/json-crdt-peritext-ui/dom/annals/WebUndo.ts +++ b/src/json-crdt-peritext-ui/dom/annals/WebUndo.ts @@ -1,3 +1,4 @@ +import {saveSelection} from '../util'; import type {UndoManager, UndoItem} from '../../types'; import type {UiLifeCycles} from '../types'; @@ -36,8 +37,7 @@ export class WebUndo implements UndoManager, UiLifeCycles { public push(undo: UndoItem): void { const el = this.el; - // TODO: restore previous selection (multiple ranges), not just focus - const activeElement = document.activeElement; + const restoreSelection = saveSelection(); try { this._push = true; this.rStack = []; @@ -50,7 +50,7 @@ export class WebUndo implements UndoManager, UiLifeCycles { el.blur(); this._push = false; el.setAttribute('aria-hidden', 'true'); - (activeElement as HTMLElement)?.focus?.(); + restoreSelection?.(); } } diff --git a/src/json-crdt-peritext-ui/dom/util.ts b/src/json-crdt-peritext-ui/dom/util.ts index c9662b1577..36919db623 100644 --- a/src/json-crdt-peritext-ui/dom/util.ts +++ b/src/json-crdt-peritext-ui/dom/util.ts @@ -12,3 +12,20 @@ export const getCursorPosition: GetCursorPosition = (document).caretPositio export const unit = (event: KeyboardEvent): '' | 'word' | 'line' => event.metaKey ? 'line' : event.altKey || event.ctrlKey ? 'word' : ''; + +/** + * Save the current browser selection, so that it can be restored later. Returns + * a callback to restore the selection. + * + * @returns Callback to restore the selection. + */ +export const saveSelection = (): (() => void) | undefined => { + const selection = window?.getSelection(); + if (!selection) return; + const ranges: Range[] = []; + for (let i = 0; i < selection.rangeCount; i++) ranges.push(selection.getRangeAt(i)); + return () => { + selection.removeAllRanges(); + for (const range of ranges) selection.addRange(range); + }; +}; diff --git a/src/json-crdt-peritext-ui/events/clipboard/DomClipboard.ts b/src/json-crdt-peritext-ui/events/clipboard/DomClipboard.ts index 79f38fe543..359ad7a411 100644 --- a/src/json-crdt-peritext-ui/events/clipboard/DomClipboard.ts +++ b/src/json-crdt-peritext-ui/events/clipboard/DomClipboard.ts @@ -1,3 +1,4 @@ +import {saveSelection} from '../../dom/util'; import type {PeritextClipboard, PeritextClipboardData} from './types'; const toText = (buf: Uint8Array) => new TextDecoder().decode(buf); @@ -11,8 +12,7 @@ const writeSync = (data: PeritextClipboardData): boolean => { const copySupported = queryCommandSupported?.('copy') ?? true; const cutSupported = queryCommandSupported?.('cut') ?? true; if (!copySupported && !cutSupported) return false; - const ranges = []; - for (let i = 0; i < selection.rangeCount; i++) ranges.push(selection.getRangeAt(i)); + const restoreSelection = saveSelection(); const value = data['text/plain'] ?? ''; const text = typeof value === 'string' ? value : ''; const span = document.createElement('span'); @@ -63,8 +63,7 @@ const writeSync = (data: PeritextClipboardData): boolean => { // span.removeEventListener('copy', listener); // span.removeEventListener('cut', listener); document.body.removeChild(span); - selection.removeAllRanges(); - for (const range of ranges) selection.addRange(range); + restoreSelection?.(); } catch {} } } catch { From 4947cee1177aec67ee959d95bac4a1f483194a99 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 16 Mar 2025 02:05:56 +0100 Subject: [PATCH 21/33] =?UTF-8?q?test:=20=F0=9F=92=8D=20fix=20all=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../peritext/__tests__/Peritext.render-block.spec.ts | 2 +- .../peritext/editor/__tests__/Editor-movement.spec.ts | 2 +- .../peritext/editor/__tests__/Editor-selection.spec.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts b/src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts index f47715508b..f54208a2de 100644 --- a/src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts +++ b/src/json-crdt-extensions/peritext/__tests__/Peritext.render-block.spec.ts @@ -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(); diff --git a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-movement.spec.ts b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-movement.spec.ts index 024ad7d1af..c6454991ab 100644 --- a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-movement.spec.ts +++ b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-movement.spec.ts @@ -320,7 +320,7 @@ 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: '', diff --git a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-selection.spec.ts b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-selection.spec.ts index d7d086a7ad..277df55580 100644 --- a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-selection.spec.ts +++ b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-selection.spec.ts @@ -4,7 +4,7 @@ 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) => {editor.insert('Hello world!')}, sid?: number) => { const model = Model.create(void 0, sid); model.api.root({ text: '', From a02f931945df7eb75ccafce022e14758281f9d48 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 16 Mar 2025 02:09:03 +0100 Subject: [PATCH 22/33] =?UTF-8?q?style:=20=F0=9F=92=84=20fix=20linter=20is?= =?UTF-8?q?sues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../editor/__tests__/Editor-movement.spec.ts | 7 ++++- .../editor/__tests__/Editor-selection.spec.ts | 7 ++++- .../dom/annals/AnnalsController.ts | 6 ++--- .../dom/annals/WebUndo.ts | 2 +- src/json-crdt-peritext-ui/types.ts | 18 ++++++++++--- src/json-crdt/log/Log.ts | 26 ++++++++++++++----- src/json-crdt/log/__tests__/Log.spec.ts | 2 +- 7 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-movement.spec.ts b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-movement.spec.ts index c6454991ab..b7e47e5723 100644 --- a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-movement.spec.ts +++ b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-movement.spec.ts @@ -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: '', diff --git a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-selection.spec.ts b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-selection.spec.ts index 277df55580..b579dd9349 100644 --- a/src/json-crdt-extensions/peritext/editor/__tests__/Editor-selection.spec.ts +++ b/src/json-crdt-extensions/peritext/editor/__tests__/Editor-selection.spec.ts @@ -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) => { + editor.insert('Hello world!'); + }, + sid?: number, +) => { const model = Model.create(void 0, sid); model.api.root({ text: '', diff --git a/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts b/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts index 50bc01b8e5..ac1fbb7a92 100644 --- a/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts +++ b/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts @@ -1,4 +1,4 @@ -import {Peritext} from '../../../json-crdt-extensions'; +import type {Peritext} from '../../../json-crdt-extensions'; import {WebUndo} from './WebUndo'; import type {Printable} from 'tree-dump'; import type {UiLifeCycles} from '../types'; @@ -16,9 +16,7 @@ export interface UndoRedoControllerOpts { export class AnnalsController implements UndoCollector, UiLifeCycles, Printable { protected manager = new WebUndo(); - constructor ( - public readonly opts: UndoRedoControllerOpts, - ) {} + constructor(public readonly opts: UndoRedoControllerOpts) {} protected captured = new WeakSet(); diff --git a/src/json-crdt-peritext-ui/dom/annals/WebUndo.ts b/src/json-crdt-peritext-ui/dom/annals/WebUndo.ts index 4ed562aab7..7bdeb72e01 100644 --- a/src/json-crdt-peritext-ui/dom/annals/WebUndo.ts +++ b/src/json-crdt-peritext-ui/dom/annals/WebUndo.ts @@ -65,7 +65,7 @@ export class WebUndo implements UndoManager, UiLifeCycles { /** -------------------------------------------------- {@link UiLifeCycles} */ public start(): void { - const el = this.el = document.createElement('div'); + const el = (this.el = document.createElement('div')); el.tabIndex = -1; el.contentEditable = 'true'; el.setAttribute('aria-hidden', 'true'); diff --git a/src/json-crdt-peritext-ui/types.ts b/src/json-crdt-peritext-ui/types.ts index 4fac73eea5..2b85b948cf 100644 --- a/src/json-crdt-peritext-ui/types.ts +++ b/src/json-crdt-peritext-ui/types.ts @@ -12,7 +12,17 @@ export interface UndoManager { redo(): void; } -export type UndoItem = [state: UndoState, undo: UndoCallback]; -export type UndoCallback = (state: UndoState) => RedoItem; -export type RedoItem = [state: RedoState, redo: RedoCallback]; -export type RedoCallback = (state: RedoState) => UndoItem; \ No newline at end of file +export type UndoItem = [ + state: UndoState, + undo: UndoCallback, +]; +export type UndoCallback = ( + state: UndoState, +) => RedoItem; +export type RedoItem = [ + state: RedoState, + redo: RedoCallback, +]; +export type RedoCallback = ( + state: RedoState, +) => UndoItem; diff --git a/src/json-crdt/log/Log.ts b/src/json-crdt/log/Log.ts index e9b45c061d..9245274bed 100644 --- a/src/json-crdt/log/Log.ts +++ b/src/json-crdt/log/Log.ts @@ -3,7 +3,19 @@ import {first, next} from 'sonic-forest/lib/util'; import {printTree} from 'tree-dump/lib/printTree'; import {Model} from '../model'; import {toSchema} from '../schema/toSchema'; -import {DelOp, type ITimestampStruct, InsArrOp, InsBinOp, InsObjOp, InsStrOp, InsValOp, InsVecOp, type Patch, Timespan, compare} from '../../json-crdt-patch'; +import { + DelOp, + type ITimestampStruct, + InsArrOp, + InsBinOp, + InsObjOp, + InsStrOp, + InsValOp, + InsVecOp, + type Patch, + Timespan, + compare, +} from '../../json-crdt-patch'; import {StrNode} from '../nodes'; import type {FanOutUnsubscribe} from 'thingies/lib/fanout'; import type {Printable} from 'tree-dump/lib/types'; @@ -18,7 +30,7 @@ import type {JsonNode} from '../nodes/types'; * The log can be used to replay the history of patches to any point in time, * from the "start" to the "end" of the log, and return the resulting {@link Model} * state. - * + * * @todo Make this implement UILifecycle (start, stop) interface. */ export class Log> implements Printable { @@ -33,7 +45,9 @@ export class Log> implements Printable { */ public static fromNewModel>(model: Model): Log { const sid = model.clock.sid; - const log = new Log(() => Model.create(undefined, sid) as Model); /** @todo Maybe provide second arg to `new Log(...)` */ + const log = new Log( + () => Model.create(undefined, sid) as Model, + ); /** @todo Maybe provide second arg to `new Log(...)` */ const api = model.api; if (api.builder.patch.ops.length) log.end.applyPatch(api.flush()); return log; @@ -66,7 +80,7 @@ export class Log> implements Printable { * * @readonly Internally this function may be updated, but externally it is * read-only. - * + * * @todo Rename to something else to give way to a `start()` in UILifecycle. * Call "snapshot". Maybe introduce `type Snapshot = () => Model;`. */ @@ -126,7 +140,7 @@ export class Log> implements Printable { // TODO: PERF: Make `.clone()` implicit in `.start()`. const clone = this.start().clone(); let cmp: number = 0; - for (let node = first(this.patches.root); node && (cmp = compare(ts, node.k)) >= 0; node = next(node)){ + for (let node = first(this.patches.root); node && (cmp = compare(ts, node.k)) >= 0; node = next(node)) { if (cmp === 0 && !inclusive) break; clone.applyPatch(node.v); } @@ -160,7 +174,7 @@ export class Log> implements Printable { * are reversed just by deleting the inserted values. All other operations * require time travel to the state just before the patch was applied, so that * a copy of a mutated object can be created and inserted back into the model. - * + * * @param patch The patch to undo * @returns A new patch that undoes the given patch */ diff --git a/src/json-crdt/log/__tests__/Log.spec.ts b/src/json-crdt/log/__tests__/Log.spec.ts index 8844b8e4a1..8d99a84144 100644 --- a/src/json-crdt/log/__tests__/Log.spec.ts +++ b/src/json-crdt/log/__tests__/Log.spec.ts @@ -102,7 +102,7 @@ describe('.advanceTo()', () => { expect(log.start().view()).toEqual({foo: 'baz', x: 1, y: 2}); expect(log.patches.size()).toBe(0); }); - + test('can advance multiple patches at once', () => { const {log} = setup({foo: 'bar'}); log.end.api.obj([]).set({x: 1}); From 38978f9db01f33be5bc2cd17f4c730d8b06da9b2 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 16 Mar 2025 19:01:39 +0100 Subject: [PATCH 23/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20implement=20in-memory=20undo=20manager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/annals/MemoryUndo.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/json-crdt-peritext-ui/dom/annals/MemoryUndo.ts diff --git a/src/json-crdt-peritext-ui/dom/annals/MemoryUndo.ts b/src/json-crdt-peritext-ui/dom/annals/MemoryUndo.ts new file mode 100644 index 0000000000..283442af83 --- /dev/null +++ b/src/json-crdt-peritext-ui/dom/annals/MemoryUndo.ts @@ -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(undo: UndoItem): 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 {} +} From bcaed9681c22a3a9c4f4073a223e1b625ccc0924 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 16 Mar 2025 19:04:52 +0100 Subject: [PATCH 24/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20print=20debug=20text=20for=20annals=20controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt-peritext-ui/dom/DomController.ts | 1 + .../dom/annals/AnnalsController.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/json-crdt-peritext-ui/dom/DomController.ts b/src/json-crdt-peritext-ui/dom/DomController.ts index 76693aaf86..588372080f 100644 --- a/src/json-crdt-peritext-ui/dom/DomController.ts +++ b/src/json-crdt-peritext-ui/dom/DomController.ts @@ -72,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), ]) ); } diff --git a/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts b/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts index ac1fbb7a92..ab2d6b0e9d 100644 --- a/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts +++ b/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts @@ -1,6 +1,6 @@ -import type {Peritext} from '../../../json-crdt-extensions'; import {WebUndo} from './WebUndo'; -import type {Printable} from 'tree-dump'; +import {printTree, type Printable} from 'tree-dump'; +import type {Peritext} from '../../../json-crdt-extensions'; import type {UiLifeCycles} from '../types'; import type {Patch} from '../../../json-crdt-patch'; import type {RedoCallback, RedoItem, UndoCallback, UndoCollector, UndoItem} from '../../types'; @@ -84,6 +84,12 @@ export class AnnalsController implements UndoCollector, UiLifeCycles, Printable /** ----------------------------------------------------- {@link Printable} */ public toString(tab?: string): string { - throw new Error('Method not implemented.'); + return ( + 'annals' + + printTree(tab, [ + (tab) => 'undo: ' + this.manager.uStack.length, + (tab) => 'redo: ' + this.manager.rStack.length, + ]) + ); } } From c90a0a7276be919b01ec742ad8b1d368cf406102 Mon Sep 17 00:00:00 2001 From: streamich Date: Sun, 16 Mar 2025 20:31:34 +0100 Subject: [PATCH 25/33] =?UTF-8?q?fix(json-crdt):=20=F0=9F=90=9B=20correct?= =?UTF-8?q?=20Log.undo()=20for=20LWW=20nodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/log/Log.ts | 22 ++-- src/json-crdt/log/__tests__/Log.spec.ts | 149 +++++++++++++++++++++++- 2 files changed, 161 insertions(+), 10 deletions(-) diff --git a/src/json-crdt/log/Log.ts b/src/json-crdt/log/Log.ts index 9245274bed..a329e5682b 100644 --- a/src/json-crdt/log/Log.ts +++ b/src/json-crdt/log/Log.ts @@ -16,7 +16,7 @@ import { Timespan, compare, } from '../../json-crdt-patch'; -import {StrNode} from '../nodes'; +import {ObjNode, StrNode, ValNode, VecNode} from '../nodes'; import type {FanOutUnsubscribe} from 'thingies/lib/fanout'; import type {Printable} from 'tree-dump/lib/types'; import type {JsonNode} from '../nodes/types'; @@ -184,7 +184,7 @@ export class Log> implements Printable { if (!length) throw new Error('EMPTY_PATCH'); const id = patch.getId(); let __model: Model | undefined; - const getModel = () => __model || (__model = this.replayTo(id!)); + const getModel = () => __model || (__model = this.replayTo(id!, false)); const builder = this.end.api.builder; for (let i = length - 1; i >= 0; i--) { const op = ops[i]; @@ -194,19 +194,23 @@ export class Log> implements Printable { continue; } const model = getModel(); + // TODO: Do not overwrite already deleted values? Or needed for concurrency? if (op instanceof InsValOp) { - const obj = model.index.find(op.obj); - if (obj) { - const schema = toSchema(obj.v); + const val = model.index.get(op.obj); + if (val instanceof ValNode) { + const schema = toSchema(val.node()); const newId = schema.build(builder); builder.setVal(op.obj, newId); } } else if (op instanceof InsObjOp || op instanceof InsVecOp) { const data: (typeof op)['data'] = []; - for (const [key, value] of op.data) { - const obj = model.index.find(value); - if (obj) { - const schema = toSchema(obj.v); + const container = model.index.get(op.obj); + for (const [key] of op.data) { + let value: JsonNode | undefined; + if (container instanceof ObjNode) value = container.get(key + ''); + else if (container instanceof VecNode) value = container.get(+key); + if (value) { + const schema = toSchema(value); const newId = schema.build(builder); data.push([key, newId] as any); } else { diff --git a/src/json-crdt/log/__tests__/Log.spec.ts b/src/json-crdt/log/__tests__/Log.spec.ts index 8d99a84144..b5a6b543c4 100644 --- a/src/json-crdt/log/__tests__/Log.spec.ts +++ b/src/json-crdt/log/__tests__/Log.spec.ts @@ -1,4 +1,4 @@ -import {s} from '../../../json-crdt-patch'; +import {DelOp, s} from '../../../json-crdt-patch'; import {Model} from '../../model'; import {Log} from '../Log'; @@ -118,3 +118,150 @@ describe('.advanceTo()', () => { expect(log.start().view()).toEqual({foo: 'bar', x: 1, y: 2}); }); }); + +describe('.undo()', () => { + describe('RGA', () => { + test('can undo string insert', () => { + const {log} = setup({str: ''}); + log.end.api.flush(); + log.end.api.str(['str']).ins(0, 'a'); + const patch = log.end.api.flush(); + expect(patch.ops.length).toBe(1); + expect(patch.ops[0].name()).toBe('ins_str'); + const undo = log.undo(patch); + expect(undo.ops.length).toBe(1); + expect(undo.ops[0].name()).toBe('del'); + const del = undo.ops[0] as DelOp; + expect(del.what.length).toBe(1); + expect(del.what[0].sid).toBe(patch.ops[0].id.sid); + expect(del.what[0].time).toBe(patch.ops[0].id.time); + expect(del.what[0].span).toBe(1); + expect(log.end.view()).toEqual({str: 'a'}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({str: ''}); + }); + + test('can undo blob insert', () => { + const {log} = setup({bin: new Uint8Array()}); + log.end.api.flush(); + log.end.api.bin(['bin']).ins(0, new Uint8Array([1, 2, 3])); + const patch = log.end.api.flush(); + expect(patch.ops.length).toBe(1); + expect(patch.ops[0].name()).toBe('ins_bin'); + const undo = log.undo(patch); + expect(undo.ops.length).toBe(1); + expect(undo.ops[0].name()).toBe('del'); + const del = undo.ops[0] as DelOp; + expect(del.what.length).toBe(1); + expect(del.what[0].sid).toBe(patch.ops[0].id.sid); + expect(del.what[0].time).toBe(patch.ops[0].id.time); + expect(del.what[0].span).toBe(3); + expect(log.end.view()).toEqual({bin: new Uint8Array([1, 2, 3])}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({bin: new Uint8Array([])}); + }); + + test('can undo array insert', () => { + const {log} = setup({arr: []}); + log.end.api.flush(); + log.end.api.arr(['arr']).ins(0, [s.con(1)]); + const patch = log.end.api.flush(); + expect(patch.ops.length).toBe(2); + const insOp = patch.ops.find(op => op.name() === 'ins_arr')!; + expect(log.end.view()).toEqual({arr: [1]}); + const undo = log.undo(patch); + expect(undo.ops.length).toBe(1); + expect(undo.ops[0].name()).toBe('del'); + const del = undo.ops[0] as DelOp; + expect(del.what.length).toBe(1); + expect(del.what[0].sid).toBe(insOp.id.sid); + expect(del.what[0].time).toBe(insOp.id.time); + expect(del.what[0].span).toBe(1); + expect(log.end.view()).toEqual({arr: [1]}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({arr: []}); + }); + }); + + describe('LWW', () => { + test('can undo object key write', () => { + const {log} = setup({obj: {foo: s.con('bar')}}); + log.end.api.flush(); + expect(log.end.view()).toEqual({obj: {foo: 'bar'}}); + log.end.api.obj(['obj']).set({foo: s.con('baz')}); + expect(log.end.view()).toEqual({obj: {foo: 'baz'}}); + const patch = log.end.api.flush(); + expect(patch.ops.length).toBe(2); + const insOp = patch.ops.find(op => op.name() === 'ins_obj')!; + const undo = log.undo(patch); + expect(undo.ops.length).toBe(2); + expect(log.end.view()).toEqual({obj: {foo: 'baz'}}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({obj: {foo: 'bar'}}); + }); + + test('can undo object key delete', () => { + const {log} = setup({obj: {foo: s.con('bar')}}); + log.end.api.flush(); + expect(log.end.view()).toEqual({obj: {foo: 'bar'}}); + log.end.api.obj(['obj']).del(['foo']); + expect(log.end.view()).toEqual({obj: {}}); + const patch = log.end.api.flush(); + expect(patch.ops.length).toBe(2); + const insOp = patch.ops.find(op => op.name() === 'ins_obj')!; + const undo = log.undo(patch); + expect(undo.ops.length).toBe(2); + expect(log.end.view()).toEqual({obj: {}}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({obj: {foo: 'bar'}}); + }); + + test('can undo vector element write', () => { + const {log} = setup({vec: s.vec(s.con('bar'))}); + log.end.api.flush(); + expect(log.end.view()).toEqual({vec: ['bar']}); + log.end.api.vec(['vec']).set([[0, s.con('baz')]]); + expect(log.end.view()).toEqual({vec: ['baz']}); + const patch = log.end.api.flush(); + expect(patch.ops.length).toBe(2); + const insOp = patch.ops.find(op => op.name() === 'ins_vec')!; + const undo = log.undo(patch); + expect(undo.ops.length).toBe(2); + expect(log.end.view()).toEqual({vec: ['baz']}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({vec: ['bar']}); + }); + + test('can undo vector element "delete"', () => { + const {log} = setup({vec: s.vec(s.con('bar'))}); + log.end.api.flush(); + expect(log.end.view()).toEqual({vec: ['bar']}); + log.end.api.vec(['vec']).set([[0, s.con(undefined)]]); + expect(log.end.view()).toEqual({vec: [undefined]}); + const patch = log.end.api.flush(); + expect(patch.ops.length).toBe(2); + const insOp = patch.ops.find(op => op.name() === 'ins_vec')!; + const undo = log.undo(patch); + expect(undo.ops.length).toBe(2); + expect(log.end.view()).toEqual({vec: [undefined]}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({vec: ['bar']}); + }); + + test('can undo register write', () => { + const {log} = setup({arr: [1]}); + log.end.api.flush(); + expect(log.end.view()).toEqual({arr: [1]}); + log.end.api.val(['arr', 0]).set(2); + expect(log.end.view()).toEqual({arr: [2]}); + const patch = log.end.api.flush(); + expect(patch.ops.length).toBe(2); + const insOp = patch.ops.find(op => op.name() === 'ins_val')!; + const undo = log.undo(patch); + expect(undo.ops.length).toBe(2); + expect(log.end.view()).toEqual({arr: [2]}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({arr: [1]}); + }); + }); +}); From 80603ae59e57ded3743802d88965da5e6da8267e Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 17 Mar 2025 01:41:14 +0100 Subject: [PATCH 26/33] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20add=20a?= =?UTF-8?q?bility=20to=20retireve=20a=20partial=20view=20of=20as=20span=20?= =?UTF-8?q?in=20RGA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/nodes/rga/AbstractRga.ts | 36 ++++++++ .../nodes/str/__tests__/StrNode.spec.ts | 83 +++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/json-crdt/nodes/rga/AbstractRga.ts b/src/json-crdt/nodes/rga/AbstractRga.ts index af92eed76c..cff5345c7b 100644 --- a/src/json-crdt/nodes/rga/AbstractRga.ts +++ b/src/json-crdt/nodes/rga/AbstractRga.ts @@ -793,6 +793,42 @@ export abstract class AbstractRga { return chunk; } + public spanView(span: ITimespanStruct): T[] { + const view: T[] = []; + let remaining = span.span; + let time = span.time; + let chunk = this.findById(span); + if (!chunk) return view; + if (!chunk.del) { + if (chunk.span >= remaining + time - chunk.id.time) { + const offset = time - chunk.id.time; + const end = offset + remaining; + const viewChunk = chunk.view().slice(offset, end); + view.push(viewChunk); + return view; + } else { + const offset = time - chunk.id.time; + const viewChunk = chunk.view().slice(offset, span.span); + remaining -= chunk.span - offset; + view.push(viewChunk); + } + } + while (chunk = chunk.s) { + const chunkSpan = chunk.span; + if (!chunk.del) { + if (chunkSpan > remaining) { + const viewChunk = chunk.view().slice(0, remaining); + view.push(viewChunk); + break; + } + view.push(chunk.data!); + } + remaining -= chunkSpan; + if (remaining <= 0) break; + } + return view; + } + // ---------------------------------------------------------- Splay balancing public splay(chunk: Chunk): void { diff --git a/src/json-crdt/nodes/str/__tests__/StrNode.spec.ts b/src/json-crdt/nodes/str/__tests__/StrNode.spec.ts index 79c0c5703e..fd0a79eaca 100644 --- a/src/json-crdt/nodes/str/__tests__/StrNode.spec.ts +++ b/src/json-crdt/nodes/str/__tests__/StrNode.spec.ts @@ -1271,6 +1271,89 @@ describe('StrNode', () => { }); }); + describe('.spanView()', () => { + test('can get view from beginning of chunk', () => { + const type = new StrNode(ts(1, 1)); + type.ins(ts(1, 1), ts(1, 2), '123456'); + const view = type.spanView(tss(1, 2, 3)); + expect(view).toEqual(['123']); + }); + + test('can get empty view', () => { + const type = new StrNode(ts(1, 1)); + type.ins(ts(1, 1), ts(1, 2), '123456'); + const view = type.spanView(tss(1, 2, 0)); + expect(view.join('')).toBe(''); + }); + + test('can get view from beginning to end of single chunk', () => { + const type = new StrNode(ts(1, 1)); + type.ins(ts(1, 1), ts(1, 2), '123456'); + const view = type.spanView(tss(1, 2, 6)); + expect(view).toEqual(['123456']); + }); + + test('can get view starting from middle', () => { + const type = new StrNode(ts(1, 1)); + type.ins(ts(1, 1), ts(1, 2), '123456'); + const view = type.spanView(tss(1, 4, 2)); + expect(view).toEqual(['34']); + }); + + test('can get view starting from middle to end', () => { + const type = new StrNode(ts(1, 1)); + type.ins(ts(1, 1), ts(1, 2), '123456'); + const view = type.spanView(tss(1, 4, 4)); + expect(view).toEqual(['3456']); + }); + + test('can get view across two chunks', () => { + const type = new StrNode(ts(1, 1)); + type.ins(ts(1, 1), ts(1, 2), '123'); + type.ins(ts(1, 4), ts(1, 5), '456'); + type.ins(ts(1, 4), ts(2, 5), 'xxx'); + const view = type.spanView(tss(1, 2, 4)); + expect(view).toEqual(['123', '4']); + }); + + test('can get view across two chunks starting from middle', () => { + const type = new StrNode(ts(1, 1)); + type.ins(ts(1, 1), ts(1, 2), '123'); + type.ins(ts(1, 4), ts(1, 5), '456'); + type.ins(ts(1, 4), ts(2, 5), 'xxx'); + const view = type.spanView(tss(1, 3, 4)); + expect(view).toEqual(['23', '45']); + }); + + test('can get view across two chunks starting from middle - 2', () => { + const type = new StrNode(ts(1, 1)); + type.ins(ts(1, 1), ts(1, 2), '123'); + type.ins(ts(1, 4), ts(1, 5), '456'); + type.ins(ts(1, 4), ts(2, 5), 'xxx'); + const view = type.spanView(tss(1, 4, 4)); + expect(view).toEqual(['3', '456']); + }); + + test('can get view across three chunks', () => { + const type = new StrNode(ts(1, 1)); + type.ins(ts(1, 1), ts(1, 2), '123456789'); + type.ins(ts(1, 4), ts(2, 5), 'xxx'); + type.ins(ts(1, 7), ts(3, 5), 'yyy'); + const view = type.spanView(tss(1, 3, 7)); + expect(view).toEqual(['23', '456', '78']); + }); + + test('can get view across three chunks and ignore deleted chunks', () => { + const type = new StrNode(ts(1, 1)); + type.ins(ts(1, 1), ts(1, 2), '123456789'); + type.ins(ts(1, 4), ts(2, 5), 'xxx'); + type.ins(ts(1, 7), ts(3, 5), 'yyy'); + type.delete([tss(1, 4, 4)]); + const view = type.spanView(tss(1, 3, 7)); + expect(view).toEqual(['2', '78']); + }); + }); + describe('export / import', () => { type Entry = [ITimestampStruct, number, string]; const exp = (type: StrNode) => { From 5905bfdbd776366bdfccdb417adbe09dc62729e5 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 17 Mar 2025 01:58:12 +0100 Subject: [PATCH 27/33] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20impleme?= =?UTF-8?q?nt=20.prevId()=20utility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/nodes/rga/AbstractRga.ts | 14 ++++++- .../nodes/str/__tests__/StrNode.spec.ts | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/json-crdt/nodes/rga/AbstractRga.ts b/src/json-crdt/nodes/rga/AbstractRga.ts index cff5345c7b..8e67f5491c 100644 --- a/src/json-crdt/nodes/rga/AbstractRga.ts +++ b/src/json-crdt/nodes/rga/AbstractRga.ts @@ -755,7 +755,7 @@ export abstract class AbstractRga { this.count--; } - public findById(after: ITimestampStruct): undefined | Chunk { + public findById(after: ITimestampStruct): Chunk | undefined { const afterSid = after.sid; const afterTime = after.time; let curr: Chunk | undefined = this.ids; @@ -793,6 +793,18 @@ export abstract class AbstractRga { return chunk; } + public prevId(id: ITimestampStruct): ITimestampStruct | undefined { + let chunk = this.findById(id); + if (!chunk) return; + const time = id.time; + if (chunk.id.time < time) return new Timestamp(id.sid, time - 1); + chunk = prev(chunk); + if (!chunk) return; + const prevId = chunk.id; + const span = chunk.span; + return span > 1 ? new Timestamp(prevId.sid, prevId.time + chunk.span - 1) : prevId; + } + public spanView(span: ITimespanStruct): T[] { const view: T[] = []; let remaining = span.span; diff --git a/src/json-crdt/nodes/str/__tests__/StrNode.spec.ts b/src/json-crdt/nodes/str/__tests__/StrNode.spec.ts index fd0a79eaca..936eb6348a 100644 --- a/src/json-crdt/nodes/str/__tests__/StrNode.spec.ts +++ b/src/json-crdt/nodes/str/__tests__/StrNode.spec.ts @@ -1354,6 +1354,46 @@ describe('StrNode', () => { }); }); + describe('.prevId()', () => { + test('can iterate through IDs in reverse', () => { + const type = new StrNode(ts(1, 1)); + type.ins(ts(1, 1), ts(1, 2), '123456789'); + type.ins(ts(1, 4), ts(2, 5), 'xxx'); + type.ins(ts(1, 7), ts(3, 5), 'yyy'); + let id = ts(1, 10); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(1, 9)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(1, 8)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(3, 7)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(3, 6)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(3, 5)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(1, 7)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(1, 6)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(1, 5)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(2, 7)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(2, 6)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(2, 5)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(1, 4)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(1, 3)); + id = type.prevId(id)!; + expect(id).toStrictEqual(ts(1, 2)); + id = type.prevId(id)!; + expect(id).toBe(undefined); + }); + }); + describe('export / import', () => { type Entry = [ITimestampStruct, number, string]; const exp = (type: StrNode) => { From 348ab2c920e33cbf1b6ba8f350ff56ef89823e44 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 17 Mar 2025 01:59:22 +0100 Subject: [PATCH 28/33] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20impleme?= =?UTF-8?q?nt=20string=20deletion=20undo=20in=20Log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/log/Log.ts | 10 ++++++++-- src/json-crdt/nodes/rga/AbstractRga.ts | 4 ++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/json-crdt/log/Log.ts b/src/json-crdt/log/Log.ts index a329e5682b..934899e9e1 100644 --- a/src/json-crdt/log/Log.ts +++ b/src/json-crdt/log/Log.ts @@ -226,9 +226,15 @@ export class Log> implements Printable { if (node) { const rga = node.v; if (rga instanceof StrNode) { - for (const span of op.what) { - // TODO: SPAN to view: ... + let str = ''; + for (const span of op.what) str += rga.spanView(span).join(''); + let after = op.obj; + const firstDelSpan = op.what[0]; + if (firstDelSpan) { + const after2 = rga.prevId(firstDelSpan); + if (after2) after = after2; } + builder.insStr(op.obj, after, str); } } } diff --git a/src/json-crdt/nodes/rga/AbstractRga.ts b/src/json-crdt/nodes/rga/AbstractRga.ts index 8e67f5491c..8ad1978ed8 100644 --- a/src/json-crdt/nodes/rga/AbstractRga.ts +++ b/src/json-crdt/nodes/rga/AbstractRga.ts @@ -793,6 +793,10 @@ export abstract class AbstractRga { return chunk; } + /** + * @param id ID of character to start the search from. + * @returns Previous ID in the RGA sequence. + */ public prevId(id: ITimestampStruct): ITimestampStruct | undefined { let chunk = this.findById(id); if (!chunk) return; From c33a0a5d868a483f1ac399f3fec596a855fce8cd Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 17 Mar 2025 11:06:09 +0100 Subject: [PATCH 29/33] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20impleme?= =?UTF-8?q?nt=20"bin"=20node=20deletion=20undo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/log/Log.ts | 14 +- src/json-crdt/log/__tests__/Log.spec.ts | 166 ++++++++++++++++-------- 2 files changed, 123 insertions(+), 57 deletions(-) diff --git a/src/json-crdt/log/Log.ts b/src/json-crdt/log/Log.ts index 934899e9e1..6d70b8d097 100644 --- a/src/json-crdt/log/Log.ts +++ b/src/json-crdt/log/Log.ts @@ -1,6 +1,7 @@ import {AvlMap} from 'sonic-forest/lib/avl/AvlMap'; import {first, next} from 'sonic-forest/lib/util'; import {printTree} from 'tree-dump/lib/printTree'; +import {listToUint8} from '@jsonjoy.com/util/lib/buffers/concat'; import {Model} from '../model'; import {toSchema} from '../schema/toSchema'; import { @@ -16,7 +17,7 @@ import { Timespan, compare, } from '../../json-crdt-patch'; -import {ObjNode, StrNode, ValNode, VecNode} from '../nodes'; +import {BinNode, ObjNode, StrNode, ValNode, VecNode} from '../nodes'; import type {FanOutUnsubscribe} from 'thingies/lib/fanout'; import type {Printable} from 'tree-dump/lib/types'; import type {JsonNode} from '../nodes/types'; @@ -235,6 +236,17 @@ export class Log> implements Printable { if (after2) after = after2; } builder.insStr(op.obj, after, str); + } else if (rga instanceof BinNode) { + const buffers: Uint8Array[] = []; + for (const span of op.what) buffers.push(...rga.spanView(span)); + let after = op.obj; + const firstDelSpan = op.what[0]; + if (firstDelSpan) { + const after2 = rga.prevId(firstDelSpan); + if (after2) after = after2; + } + const blob = listToUint8(buffers); + builder.insBin(op.obj, after, blob); } } } diff --git a/src/json-crdt/log/__tests__/Log.spec.ts b/src/json-crdt/log/__tests__/Log.spec.ts index b5a6b543c4..33ca20e597 100644 --- a/src/json-crdt/log/__tests__/Log.spec.ts +++ b/src/json-crdt/log/__tests__/Log.spec.ts @@ -1,4 +1,4 @@ -import {DelOp, s} from '../../../json-crdt-patch'; +import {DelOp, InsStrOp, s} from '../../../json-crdt-patch'; import {Model} from '../../model'; import {Log} from '../Log'; @@ -121,65 +121,119 @@ describe('.advanceTo()', () => { describe('.undo()', () => { describe('RGA', () => { - test('can undo string insert', () => { - const {log} = setup({str: ''}); - log.end.api.flush(); - log.end.api.str(['str']).ins(0, 'a'); - const patch = log.end.api.flush(); - expect(patch.ops.length).toBe(1); - expect(patch.ops[0].name()).toBe('ins_str'); - const undo = log.undo(patch); - expect(undo.ops.length).toBe(1); - expect(undo.ops[0].name()).toBe('del'); - const del = undo.ops[0] as DelOp; - expect(del.what.length).toBe(1); - expect(del.what[0].sid).toBe(patch.ops[0].id.sid); - expect(del.what[0].time).toBe(patch.ops[0].id.time); - expect(del.what[0].span).toBe(1); - expect(log.end.view()).toEqual({str: 'a'}); - log.end.applyPatch(undo); - expect(log.end.view()).toEqual({str: ''}); + describe('str', () => { + test('can undo string insert', () => { + const {log} = setup({str: ''}); + log.end.api.flush(); + log.end.api.str(['str']).ins(0, 'a'); + const patch = log.end.api.flush(); + expect(patch.ops.length).toBe(1); + expect(patch.ops[0].name()).toBe('ins_str'); + const undo = log.undo(patch); + expect(undo.ops.length).toBe(1); + expect(undo.ops[0].name()).toBe('del'); + const del = undo.ops[0] as DelOp; + expect(del.what.length).toBe(1); + expect(del.what[0].sid).toBe(patch.ops[0].id.sid); + expect(del.what[0].time).toBe(patch.ops[0].id.time); + expect(del.what[0].span).toBe(1); + expect(log.end.view()).toEqual({str: 'a'}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({str: ''}); + }); + + test('can undo string delete', () => { + const {log} = setup({str: 'a'}); + log.end.api.flush(); + log.end.api.str(['str']).del(0, 1); + const patch = log.end.api.flush(); + expect(patch.ops.length).toBe(1); + expect(patch.ops[0].name()).toBe('del'); + const undo = log.undo(patch); + expect(undo.ops.length).toBe(1); + expect(undo.ops[0].name()).toBe('ins_str'); + const op = undo.ops[0] as InsStrOp; + expect(op.data).toBe('a'); + expect(op.obj.time).toBe(log.end.api.str(['str']).node.id.time); + expect(op.obj.sid).toBe(log.end.api.str(['str']).node.id.sid); + expect(op.ref.time).toBe(log.end.api.str(['str']).node.id.time); + expect(op.ref.sid).toBe(log.end.api.str(['str']).node.id.sid); + expect(log.end.view()).toEqual({str: ''}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({str: 'a'}); + }); + + test('can undo string delete - 2', () => { + const {log} = setup({str: '12345'}); + log.end.api.flush(); + log.end.api.str(['str']).del(1, 1); + const patch1 = log.end.api.flush(); + log.end.api.str(['str']).del(1, 2); + const patch2 = log.end.api.flush(); + const undo2 = log.undo(patch2); + const undo1 = log.undo(patch1); + expect(log.end.view()).toEqual({str: '15'}); + log.end.applyPatch(undo2); + expect(log.end.view()).toEqual({str: '1345'}); + log.end.applyPatch(undo1); + expect(log.end.view()).toEqual({str: '12345'}); + }); }); - test('can undo blob insert', () => { - const {log} = setup({bin: new Uint8Array()}); - log.end.api.flush(); - log.end.api.bin(['bin']).ins(0, new Uint8Array([1, 2, 3])); - const patch = log.end.api.flush(); - expect(patch.ops.length).toBe(1); - expect(patch.ops[0].name()).toBe('ins_bin'); - const undo = log.undo(patch); - expect(undo.ops.length).toBe(1); - expect(undo.ops[0].name()).toBe('del'); - const del = undo.ops[0] as DelOp; - expect(del.what.length).toBe(1); - expect(del.what[0].sid).toBe(patch.ops[0].id.sid); - expect(del.what[0].time).toBe(patch.ops[0].id.time); - expect(del.what[0].span).toBe(3); - expect(log.end.view()).toEqual({bin: new Uint8Array([1, 2, 3])}); - log.end.applyPatch(undo); - expect(log.end.view()).toEqual({bin: new Uint8Array([])}); + describe('bin', () => { + test('can undo blob insert', () => { + const {log} = setup({bin: new Uint8Array()}); + log.end.api.flush(); + log.end.api.bin(['bin']).ins(0, new Uint8Array([1, 2, 3])); + const patch = log.end.api.flush(); + expect(patch.ops.length).toBe(1); + expect(patch.ops[0].name()).toBe('ins_bin'); + const undo = log.undo(patch); + expect(undo.ops.length).toBe(1); + expect(undo.ops[0].name()).toBe('del'); + const del = undo.ops[0] as DelOp; + expect(del.what.length).toBe(1); + expect(del.what[0].sid).toBe(patch.ops[0].id.sid); + expect(del.what[0].time).toBe(patch.ops[0].id.time); + expect(del.what[0].span).toBe(3); + expect(log.end.view()).toEqual({bin: new Uint8Array([1, 2, 3])}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({bin: new Uint8Array([])}); + }); }); - test('can undo array insert', () => { - const {log} = setup({arr: []}); - log.end.api.flush(); - log.end.api.arr(['arr']).ins(0, [s.con(1)]); - const patch = log.end.api.flush(); - expect(patch.ops.length).toBe(2); - const insOp = patch.ops.find(op => op.name() === 'ins_arr')!; - expect(log.end.view()).toEqual({arr: [1]}); - const undo = log.undo(patch); - expect(undo.ops.length).toBe(1); - expect(undo.ops[0].name()).toBe('del'); - const del = undo.ops[0] as DelOp; - expect(del.what.length).toBe(1); - expect(del.what[0].sid).toBe(insOp.id.sid); - expect(del.what[0].time).toBe(insOp.id.time); - expect(del.what[0].span).toBe(1); - expect(log.end.view()).toEqual({arr: [1]}); - log.end.applyPatch(undo); - expect(log.end.view()).toEqual({arr: []}); + describe('arr', () => { + test('can undo array insert', () => { + const {log} = setup({arr: []}); + log.end.api.flush(); + log.end.api.arr(['arr']).ins(0, [s.con(1)]); + const patch = log.end.api.flush(); + expect(patch.ops.length).toBe(2); + const insOp = patch.ops.find(op => op.name() === 'ins_arr')!; + expect(log.end.view()).toEqual({arr: [1]}); + const undo = log.undo(patch); + expect(undo.ops.length).toBe(1); + expect(undo.ops[0].name()).toBe('del'); + const del = undo.ops[0] as DelOp; + expect(del.what.length).toBe(1); + expect(del.what[0].sid).toBe(insOp.id.sid); + expect(del.what[0].time).toBe(insOp.id.time); + expect(del.what[0].span).toBe(1); + expect(log.end.view()).toEqual({arr: [1]}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({arr: []}); + }); + + test('can undo blob delete', () => { + const {log} = setup({bin: new Uint8Array([1, 2, 3])}); + log.end.api.flush(); + log.end.api.bin(['bin']).del(1, 1); + const patch = log.end.api.flush(); + const undo = log.undo(patch); + expect(log.end.view()).toEqual({bin: new Uint8Array([1, 3])}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({bin: new Uint8Array([1, 2, 3])}); + }); }); }); From d6d564d67bd7e4e9662a0e50020b506524400220 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 17 Mar 2025 11:18:48 +0100 Subject: [PATCH 30/33] =?UTF-8?q?feat(json-crdt):=20=F0=9F=8E=B8=20impleme?= =?UTF-8?q?nt=20"arr"=20node=20undo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/json-crdt/log/Log.ts | 26 +++++++++++++++++++++++-- src/json-crdt/log/__tests__/Log.spec.ts | 21 +++++++++++++++----- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/json-crdt/log/Log.ts b/src/json-crdt/log/Log.ts index 6d70b8d097..2e581d2307 100644 --- a/src/json-crdt/log/Log.ts +++ b/src/json-crdt/log/Log.ts @@ -17,7 +17,7 @@ import { Timespan, compare, } from '../../json-crdt-patch'; -import {BinNode, ObjNode, StrNode, ValNode, VecNode} from '../nodes'; +import {ArrNode, BinNode, ObjNode, StrNode, ValNode, VecNode} from '../nodes'; import type {FanOutUnsubscribe} from 'thingies/lib/fanout'; import type {Printable} from 'tree-dump/lib/types'; import type {JsonNode} from '../nodes/types'; @@ -195,7 +195,7 @@ export class Log> implements Printable { continue; } const model = getModel(); - // TODO: Do not overwrite already deleted values? Or needed for concurrency? + // TODO: Do not overwrite already deleted values? Or needed for concurrency? Orphaned nodes. if (op instanceof InsValOp) { const val = model.index.get(op.obj); if (val instanceof ValNode) { @@ -247,6 +247,28 @@ export class Log> implements Printable { } const blob = listToUint8(buffers); builder.insBin(op.obj, after, blob); + } else if (rga instanceof ArrNode) { + const copies: ITimestampStruct[] = []; + for (const span of op.what) { + const ids2 = rga.spanView(span); + for (const ids of ids2) { + for (const id of ids) { + const node = model.index.get(id); + if (node) { + const schema = toSchema(node); + const newId = schema.build(builder); + copies.push(newId); + } + } + } + } + let after = op.obj; + const firstDelSpan = op.what[0]; + if (firstDelSpan) { + const after2 = rga.prevId(firstDelSpan); + if (after2) after = after2; + } + builder.insArr(op.obj, after, copies); } } } diff --git a/src/json-crdt/log/__tests__/Log.spec.ts b/src/json-crdt/log/__tests__/Log.spec.ts index 33ca20e597..a75297b121 100644 --- a/src/json-crdt/log/__tests__/Log.spec.ts +++ b/src/json-crdt/log/__tests__/Log.spec.ts @@ -200,6 +200,17 @@ describe('.undo()', () => { log.end.applyPatch(undo); expect(log.end.view()).toEqual({bin: new Uint8Array([])}); }); + + test('can undo blob delete', () => { + const {log} = setup({bin: new Uint8Array([1, 2, 3])}); + log.end.api.flush(); + log.end.api.bin(['bin']).del(1, 1); + const patch = log.end.api.flush(); + const undo = log.undo(patch); + expect(log.end.view()).toEqual({bin: new Uint8Array([1, 3])}); + log.end.applyPatch(undo); + expect(log.end.view()).toEqual({bin: new Uint8Array([1, 2, 3])}); + }); }); describe('arr', () => { @@ -224,15 +235,15 @@ describe('.undo()', () => { expect(log.end.view()).toEqual({arr: []}); }); - test('can undo blob delete', () => { - const {log} = setup({bin: new Uint8Array([1, 2, 3])}); + test('can undo "arr" delete', () => { + const {log} = setup({arr: [{a: 1}, {a: 2}, {a: 3}]}); log.end.api.flush(); - log.end.api.bin(['bin']).del(1, 1); + log.end.api.arr(['arr']).del(1, 1); const patch = log.end.api.flush(); const undo = log.undo(patch); - expect(log.end.view()).toEqual({bin: new Uint8Array([1, 3])}); + expect(log.end.view()).toEqual({arr: [{a: 1}, {a: 3}]}); log.end.applyPatch(undo); - expect(log.end.view()).toEqual({bin: new Uint8Array([1, 2, 3])}); + expect(log.end.view()).toEqual({arr: [{a: 1}, {a: 2}, {a: 3}]}); }); }); }); From f8733600e9131cba4189dcaec1130c3104d65565 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 17 Mar 2025 14:15:17 +0100 Subject: [PATCH 31/33] =?UTF-8?q?refactor(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=92=A1=20update=20annals=20event=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/annals/AnnalsController.ts | 6 ++---- .../events/defaults/PeritextEventDefaults.ts | 4 ++-- src/json-crdt-peritext-ui/events/types.ts | 12 +++--------- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts b/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts index ab2d6b0e9d..05d406af59 100644 --- a/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts +++ b/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts @@ -60,8 +60,7 @@ export class AnnalsController implements UndoCollector, UiLifeCycles, Printable const patch = log.undo(doPatch); et.dispatch('annals', { action: 'undo', - source: doPatch, - patch, + batch: [patch], }); // console.log('doPatch', doPatch + ''); // console.log('undoPatch', patch + ''); @@ -73,8 +72,7 @@ export class AnnalsController implements UndoCollector, UiLifeCycles, Printable const redoPatch = doPatch.rebase(log.end.clock.time); et.dispatch('annals', { action: 'redo', - source: doPatch, - patch: redoPatch, + batch: [redoPatch], }); // console.log('doPatch', doPatch + ''); // console.log('redoPatch', redoPatch + ''); diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index 2464aa78bc..67ac262729 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -375,7 +375,7 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { }; public readonly annals = (event: CustomEvent) => { - const {patch} = event.detail; - this.txt.model.applyPatch(patch); + const {batch} = event.detail; + this.txt.model.applyBatch(batch); }; } diff --git a/src/json-crdt-peritext-ui/events/types.ts b/src/json-crdt-peritext-ui/events/types.ts index 0aa82b4312..42ea04d2f9 100644 --- a/src/json-crdt-peritext-ui/events/types.ts +++ b/src/json-crdt-peritext-ui/events/types.ts @@ -326,16 +326,10 @@ export interface AnnalsDetail { action: 'undo' | 'redo'; /** - * The original {@link Patch} from which the undo/redo action was originally - * created. + * The list of {@link Patch} that will be applied to the document to undo or + * redo the action, unless the action is cancelled. */ - source: Patch; - - /** - * The {@link Patch} that will be applied to the document to undo or redo the - * action, unless the action is cancelled. - */ - patch: Patch; + batch: [Patch]; } /** From a0c565e0ebd4349ba36e7b4f02c0c0f570338b3b Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 17 Mar 2025 15:22:29 +0100 Subject: [PATCH 32/33] =?UTF-8?q?feat(json-crdt-peritext-ui):=20?= =?UTF-8?q?=F0=9F=8E=B8=20adjust=20cursor=20on=20text=20insert/delte=20und?= =?UTF-8?q?o/redo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/annals/AnnalsController.ts | 2 +- .../events/defaults/PeritextEventDefaults.ts | 4 ++ .../events/defaults/annals.ts | 40 +++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/json-crdt-peritext-ui/events/defaults/annals.ts diff --git a/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts b/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts index 05d406af59..1d12e83c72 100644 --- a/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts +++ b/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts @@ -1,8 +1,8 @@ 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 {Patch} from '../../../json-crdt-patch'; import type {RedoCallback, RedoItem, UndoCallback, UndoCollector, UndoItem} from '../../types'; import type {Log} from '../../../json-crdt/log/Log'; import type {PeritextEventTarget} from '../../events/PeritextEventTarget'; diff --git a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts index 67ac262729..dab2f26fc4 100644 --- a/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts +++ b/src/json-crdt-peritext-ui/events/defaults/PeritextEventDefaults.ts @@ -1,4 +1,5 @@ import {CursorAnchor} from '../../../json-crdt-extensions/peritext/slice/constants'; +import {placeCursor} from './annals'; import type {Range} from '../../../json-crdt-extensions/peritext/rga/Range'; import type {PeritextDataTransfer} from '../../../json-crdt-extensions/peritext/transfer/PeritextDataTransfer'; import type {PeritextEventHandlerMap, PeritextEventTarget} from '../PeritextEventTarget'; @@ -377,5 +378,8 @@ export class PeritextEventDefaults implements PeritextEventHandlerMap { public readonly annals = (event: CustomEvent) => { const {batch} = event.detail; this.txt.model.applyBatch(batch); + const txt = this.txt; + const cursor = placeCursor(txt, batch); + if (cursor) txt.editor.cursor.setRange(cursor); }; } diff --git a/src/json-crdt-peritext-ui/events/defaults/annals.ts b/src/json-crdt-peritext-ui/events/defaults/annals.ts new file mode 100644 index 0000000000..af03e352fc --- /dev/null +++ b/src/json-crdt-peritext-ui/events/defaults/annals.ts @@ -0,0 +1,40 @@ +import {Peritext} from '../../../json-crdt-extensions'; +import {Anchor} from '../../../json-crdt-extensions/peritext/rga/constants'; +import {DelOp, equal, InsArrOp, InsBinOp, InsStrOp, Patch, Timestamp} from '../../../json-crdt-patch'; +import type {Range} from '../../../json-crdt-extensions/peritext/rga/Range'; + +/** + * Given an undo/redo patch/batch, calculates a good cursor position to place + * the cursor after the patch is applied, so that the user can continue typing + * from the same logical position. + * + * @param patch Undo/Redo patch + * @returns Range + */ +export const placeCursor = (txt: Peritext, batch: Patch[]): Range | undefined => { + const batchLength = batch.length; + for (let j = batchLength - 1; j >= 0; j--) { + const patch = batch[j]; + const ops = patch.ops; + const length = ops.length; + for (let i = length - 1; i >= 0; i--) { + const op = ops[i]; + if (op instanceof InsStrOp || op instanceof InsBinOp || op instanceof InsArrOp) { + const opId = op.id; + const lastCharId = new Timestamp(opId.sid, opId.time + op.span() - 1); + const point = txt.point(lastCharId, Anchor.After); + const cursor = txt.range(point); + return cursor; + } else if (op instanceof DelOp && equal(op.obj, txt.str.id)) { + const lastSpan = op.what[op.what.length - 1]; + if (lastSpan) { + const point = txt.point(lastSpan, Anchor.Before); + point.halfstep(-1); + const cursor = txt.range(point); + return cursor; + } + } + } + } + return; +}; From 863893b7bf6b1f54678cbc775f7500af5b473213 Mon Sep 17 00:00:00 2001 From: streamich Date: Mon, 17 Mar 2025 15:30:26 +0100 Subject: [PATCH 33/33] =?UTF-8?q?style:=20=F0=9F=92=84=20fix=20linter=20is?= =?UTF-8?q?sues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dom/annals/AnnalsController.ts | 5 +---- .../events/defaults/annals.ts | 4 ++-- src/json-crdt/log/__tests__/Log.spec.ts | 14 +++++++------- src/json-crdt/nodes/rga/AbstractRga.ts | 4 ++-- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts b/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts index 1d12e83c72..f343a0322f 100644 --- a/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts +++ b/src/json-crdt-peritext-ui/dom/annals/AnnalsController.ts @@ -84,10 +84,7 @@ export class AnnalsController implements UndoCollector, UiLifeCycles, Printable public toString(tab?: string): string { return ( 'annals' + - printTree(tab, [ - (tab) => 'undo: ' + this.manager.uStack.length, - (tab) => 'redo: ' + this.manager.rStack.length, - ]) + printTree(tab, [(tab) => 'undo: ' + this.manager.uStack.length, (tab) => 'redo: ' + this.manager.rStack.length]) ); } } diff --git a/src/json-crdt-peritext-ui/events/defaults/annals.ts b/src/json-crdt-peritext-ui/events/defaults/annals.ts index af03e352fc..623df30b0a 100644 --- a/src/json-crdt-peritext-ui/events/defaults/annals.ts +++ b/src/json-crdt-peritext-ui/events/defaults/annals.ts @@ -1,6 +1,6 @@ -import {Peritext} from '../../../json-crdt-extensions'; +import type {Peritext} from '../../../json-crdt-extensions'; import {Anchor} from '../../../json-crdt-extensions/peritext/rga/constants'; -import {DelOp, equal, InsArrOp, InsBinOp, InsStrOp, Patch, Timestamp} from '../../../json-crdt-patch'; +import {DelOp, equal, InsArrOp, InsBinOp, InsStrOp, type Patch, Timestamp} from '../../../json-crdt-patch'; import type {Range} from '../../../json-crdt-extensions/peritext/rga/Range'; /** diff --git a/src/json-crdt/log/__tests__/Log.spec.ts b/src/json-crdt/log/__tests__/Log.spec.ts index a75297b121..ca4641bb08 100644 --- a/src/json-crdt/log/__tests__/Log.spec.ts +++ b/src/json-crdt/log/__tests__/Log.spec.ts @@ -1,4 +1,4 @@ -import {DelOp, InsStrOp, s} from '../../../json-crdt-patch'; +import {type DelOp, type InsStrOp, s} from '../../../json-crdt-patch'; import {Model} from '../../model'; import {Log} from '../Log'; @@ -220,7 +220,7 @@ describe('.undo()', () => { log.end.api.arr(['arr']).ins(0, [s.con(1)]); const patch = log.end.api.flush(); expect(patch.ops.length).toBe(2); - const insOp = patch.ops.find(op => op.name() === 'ins_arr')!; + const insOp = patch.ops.find((op) => op.name() === 'ins_arr')!; expect(log.end.view()).toEqual({arr: [1]}); const undo = log.undo(patch); expect(undo.ops.length).toBe(1); @@ -257,7 +257,7 @@ describe('.undo()', () => { expect(log.end.view()).toEqual({obj: {foo: 'baz'}}); const patch = log.end.api.flush(); expect(patch.ops.length).toBe(2); - const insOp = patch.ops.find(op => op.name() === 'ins_obj')!; + const insOp = patch.ops.find((op) => op.name() === 'ins_obj')!; const undo = log.undo(patch); expect(undo.ops.length).toBe(2); expect(log.end.view()).toEqual({obj: {foo: 'baz'}}); @@ -273,7 +273,7 @@ describe('.undo()', () => { expect(log.end.view()).toEqual({obj: {}}); const patch = log.end.api.flush(); expect(patch.ops.length).toBe(2); - const insOp = patch.ops.find(op => op.name() === 'ins_obj')!; + const insOp = patch.ops.find((op) => op.name() === 'ins_obj')!; const undo = log.undo(patch); expect(undo.ops.length).toBe(2); expect(log.end.view()).toEqual({obj: {}}); @@ -289,7 +289,7 @@ describe('.undo()', () => { expect(log.end.view()).toEqual({vec: ['baz']}); const patch = log.end.api.flush(); expect(patch.ops.length).toBe(2); - const insOp = patch.ops.find(op => op.name() === 'ins_vec')!; + const insOp = patch.ops.find((op) => op.name() === 'ins_vec')!; const undo = log.undo(patch); expect(undo.ops.length).toBe(2); expect(log.end.view()).toEqual({vec: ['baz']}); @@ -305,7 +305,7 @@ describe('.undo()', () => { expect(log.end.view()).toEqual({vec: [undefined]}); const patch = log.end.api.flush(); expect(patch.ops.length).toBe(2); - const insOp = patch.ops.find(op => op.name() === 'ins_vec')!; + const insOp = patch.ops.find((op) => op.name() === 'ins_vec')!; const undo = log.undo(patch); expect(undo.ops.length).toBe(2); expect(log.end.view()).toEqual({vec: [undefined]}); @@ -321,7 +321,7 @@ describe('.undo()', () => { expect(log.end.view()).toEqual({arr: [2]}); const patch = log.end.api.flush(); expect(patch.ops.length).toBe(2); - const insOp = patch.ops.find(op => op.name() === 'ins_val')!; + const insOp = patch.ops.find((op) => op.name() === 'ins_val')!; const undo = log.undo(patch); expect(undo.ops.length).toBe(2); expect(log.end.view()).toEqual({arr: [2]}); diff --git a/src/json-crdt/nodes/rga/AbstractRga.ts b/src/json-crdt/nodes/rga/AbstractRga.ts index 8ad1978ed8..b2b70957ee 100644 --- a/src/json-crdt/nodes/rga/AbstractRga.ts +++ b/src/json-crdt/nodes/rga/AbstractRga.ts @@ -812,7 +812,7 @@ export abstract class AbstractRga { public spanView(span: ITimespanStruct): T[] { const view: T[] = []; let remaining = span.span; - let time = span.time; + const time = span.time; let chunk = this.findById(span); if (!chunk) return view; if (!chunk.del) { @@ -829,7 +829,7 @@ export abstract class AbstractRga { view.push(viewChunk); } } - while (chunk = chunk.s) { + while ((chunk = chunk.s)) { const chunkSpan = chunk.span; if (!chunk.del) { if (chunkSpan > remaining) {