From b786f15efe94b98e86fad86944c38360579b9b8e Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 23 May 2024 11:12:03 +0800 Subject: [PATCH] feat: restore selections when undo/redo --- examples/stories/package.json | 2 +- examples/stories/pnpm-lock.yaml | 14 +- .../stories/src/stories/Editor.stories.tsx | 13 +- package.json | 2 +- pnpm-lock.yaml | 14 +- src/cursor-plugin.ts | 37 ++-- src/sync-plugin.ts | 4 +- src/undo-plugin.ts | 177 ++++++++++++++++-- 8 files changed, 215 insertions(+), 48 deletions(-) diff --git a/examples/stories/package.json b/examples/stories/package.json index 794e8c7..4a88fbd 100644 --- a/examples/stories/package.json +++ b/examples/stories/package.json @@ -12,7 +12,7 @@ "build-storybook": "storybook build" }, "dependencies": { - "loro-crdt": "^0.16.1", + "loro-crdt": "^0.16.2", "loro-prosemirror": "link:../..", "prosemirror-commands": "^1.5.2", "prosemirror-example-setup": "^1.2.2", diff --git a/examples/stories/pnpm-lock.yaml b/examples/stories/pnpm-lock.yaml index 26766bf..0b61a66 100644 --- a/examples/stories/pnpm-lock.yaml +++ b/examples/stories/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: loro-crdt: - specifier: ^0.16.1 - version: 0.16.1 + specifier: ^0.16.2 + version: 0.16.2 loro-prosemirror: specifier: link:../.. version: link:../.. @@ -5689,14 +5689,14 @@ packages: dependencies: js-tokens: 4.0.0 - /loro-crdt@0.16.1: - resolution: {integrity: sha512-miNru12ZUIznAJyzIT7LPxlM11b2BQkTC5/9/YWiaYskLZTL2ANzxdN0g36Peh/O6L0QFW+8I/c1LutW1kxFpg==} + /loro-crdt@0.16.2: + resolution: {integrity: sha512-+iS6GdKk+ethW98S2jX0Sp475cXKiSG+c6zXvjaGfycSHnQSGiof/gM1NOQ96QyNHXoA0DyCukhpLSNbRe9fLA==} dependencies: - loro-wasm: 0.16.1 + loro-wasm: 0.16.2 dev: false - /loro-wasm@0.16.1: - resolution: {integrity: sha512-JkbQk51zZvZbE5ENTLKVjcDZ4a4TmbwTWjIDvDMX66Kbj/K63arBsZKKldDEvnY8XNRVBuUi2zSh07tJAV3c1w==} + /loro-wasm@0.16.2: + resolution: {integrity: sha512-8SJuGCGXl69/1TDF6cjERBHsh+NzAATVRFklMGN6M5bq45MH8iO1Npy9KsgarRfOugvlnYNiVhQEYunJenLGxQ==} dev: false /loupe@2.3.7: diff --git a/examples/stories/src/stories/Editor.stories.tsx b/examples/stories/src/stories/Editor.stories.tsx index c557554..4c54ddd 100644 --- a/examples/stories/src/stories/Editor.stories.tsx +++ b/examples/stories/src/stories/Editor.stories.tsx @@ -1,5 +1,5 @@ -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta } from '@storybook/react'; import { Editor } from './Editor'; import { Loro } from 'loro-crdt'; @@ -16,9 +16,16 @@ const meta = { } satisfies Meta; export default meta; -type Story = StoryObj; -export const Basic: Story = {}; +export const Basic = () => { + const loroARef = useRef(createLoro()); + const idA = loroARef.current.peerIdStr; + const awarenessA = useRef(new CursorAwareness(idA)); + return
+ +
+ +}; function createLoro() { const doc = new Loro(); diff --git a/package.json b/package.json index 5c73207..baacc4b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "license": "ISC", "dependencies": { "lib0": "^0.2.42", - "loro-crdt": "^0.16.1" + "loro-crdt": "^0.16.2" }, "peerDependencies": { "prosemirror-model": "^1.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac8adfc..223333d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ dependencies: specifier: ^0.2.42 version: 0.2.93 loro-crdt: - specifier: ^0.16.1 - version: 0.16.1 + specifier: ^0.16.2 + version: 0.16.2 devDependencies: '@typescript-eslint/parser': @@ -1331,14 +1331,14 @@ packages: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true - /loro-crdt@0.16.1: - resolution: {integrity: sha512-miNru12ZUIznAJyzIT7LPxlM11b2BQkTC5/9/YWiaYskLZTL2ANzxdN0g36Peh/O6L0QFW+8I/c1LutW1kxFpg==} + /loro-crdt@0.16.2: + resolution: {integrity: sha512-+iS6GdKk+ethW98S2jX0Sp475cXKiSG+c6zXvjaGfycSHnQSGiof/gM1NOQ96QyNHXoA0DyCukhpLSNbRe9fLA==} dependencies: - loro-wasm: 0.16.1 + loro-wasm: 0.16.2 dev: false - /loro-wasm@0.16.1: - resolution: {integrity: sha512-JkbQk51zZvZbE5ENTLKVjcDZ4a4TmbwTWjIDvDMX66Kbj/K63arBsZKKldDEvnY8XNRVBuUi2zSh07tJAV3c1w==} + /loro-wasm@0.16.2: + resolution: {integrity: sha512-8SJuGCGXl69/1TDF6cjERBHsh+NzAATVRFklMGN6M5bq45MH8iO1Npy9KsgarRfOugvlnYNiVhQEYunJenLGxQ==} dev: false /loupe@2.3.7: diff --git a/src/cursor-plugin.ts b/src/cursor-plugin.ts index cf08bba..d0470c5 100644 --- a/src/cursor-plugin.ts +++ b/src/cursor-plugin.ts @@ -1,7 +1,7 @@ import { Awareness, Container, ContainerID, Cursor, Loro, LoroList, LoroText, PeerID } from "loro-crdt"; import { EditorState, Plugin, PluginKey, Selection } from "prosemirror-state"; import { Decoration, DecorationAttrs, DecorationSet } from "prosemirror-view"; -import { loroSyncPluginKey } from "./sync-plugin"; +import { LoroSyncPluginState, loroSyncPluginKey } from "./sync-plugin"; import { Node } from "prosemirror-model"; import { CHILDREN_KEY, LoroDocType, LoroNode, LoroNodeMapping, WEAK_NODE_TO_LORO_CONTAINER_MAPPING } from "./lib"; import { CursorAwareness, cursorEq } from "./awareness"; @@ -37,10 +37,10 @@ function createDecorations( continue; } - const [focus, focusCursorUpdate] = cursorToAbsolutePosition(state.doc, cursor.focus, doc as LoroDocType, loroState.mapping); + const [focus, focusCursorUpdate] = cursorToAbsolutePosition(cursor.focus, doc as LoroDocType, loroState.mapping); d.push(Decoration.widget(focus, createCursor(peer as PeerID))); if (!cursorEq(cursor.anchor, cursor.focus)) { - const [anchor, anchorCursorUpdate] = cursorToAbsolutePosition(state.doc, cursor.anchor, doc as LoroDocType, loroState.mapping); + const [anchor, anchorCursorUpdate] = cursorToAbsolutePosition(cursor.anchor, doc as LoroDocType, loroState.mapping); d.push(Decoration.inline(Math.min(anchor, focus), Math.max(anchor, focus), createSelection(peer as PeerID))); if (focusCursorUpdate || anchorCursorUpdate) { awareness.setLocal({ @@ -135,18 +135,7 @@ export const LoroCursorPlugin = ( const pmRootNode = view.state.doc; if (view.hasFocus()) { const selection = getSelection(view.state); - const anchor = absolutePositionToCursor( - pmRootNode, - selection.anchor, - loroState.doc as LoroDocType, - loroState.mapping, - ); - const focus = selection.head == selection.anchor ? anchor : absolutePositionToCursor( - pmRootNode, - selection.head, - loroState.doc as LoroDocType, - loroState.mapping, - ); + const { anchor, focus } = convertPmSelectionToCursors(pmRootNode, selection, loroState); if ( current == null || !cursorEq(current.anchor, anchor) || @@ -184,6 +173,22 @@ export const LoroCursorPlugin = ( }; +export function convertPmSelectionToCursors(pmRootNode: Node, selection: Selection, loroState: LoroSyncPluginState) { + const anchor = absolutePositionToCursor( + pmRootNode, + selection.anchor, + loroState.doc as LoroDocType, + loroState.mapping + ); + const focus = selection.head == selection.anchor ? anchor : absolutePositionToCursor( + pmRootNode, + selection.head, + loroState.doc as LoroDocType, + loroState.mapping + ); + return { anchor, focus }; +} + function absolutePositionToCursor(pmRootNode: Node, anchor: number, doc: LoroDocType, mapping: LoroNodeMapping): Cursor | undefined { const pos = pmRootNode.resolve(anchor); const nodeParent = pos.node(pos.depth); @@ -230,7 +235,7 @@ function absolutePositionToCursor(pmRootNode: Node, anchor: number, doc: LoroDoc } -function cursorToAbsolutePosition(_pmRootNode: Node, cursor: Cursor, doc: LoroDocType, mapping: LoroNodeMapping): [number, Cursor | undefined] { +export function cursorToAbsolutePosition(cursor: Cursor, doc: LoroDocType, mapping: LoroNodeMapping): [number, Cursor | undefined] { const containerId = cursor.containerId() let index = -1; let targetChildId: ContainerID; diff --git a/src/sync-plugin.ts b/src/sync-plugin.ts index 811c388..db74493 100644 --- a/src/sync-plugin.ts +++ b/src/sync-plugin.ts @@ -34,11 +34,11 @@ type PluginTransactionType = }; export interface LoroSyncPluginProps { - doc: Loro; + doc: LoroDocType; mapping?: LoroNodeMapping; } -interface LoroSyncPluginState extends LoroSyncPluginProps { +export interface LoroSyncPluginState extends LoroSyncPluginProps { changedBy: "local" | "import" | "checkout"; mapping: LoroNodeMapping; snapshot?: Loro | null; diff --git a/src/undo-plugin.ts b/src/undo-plugin.ts index d539ffc..8ddd95b 100644 --- a/src/undo-plugin.ts +++ b/src/undo-plugin.ts @@ -1,5 +1,8 @@ -import { Loro, UndoManager } from "loro-crdt"; +import { Cursor, Loro, UndoManager } from "loro-crdt"; import { EditorState, Plugin, PluginKey, StateField, TextSelection, Transaction } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { convertPmSelectionToCursors, cursorToAbsolutePosition } from "./cursor-plugin"; +import { loroSyncPluginKey } from "./sync-plugin"; export interface LoroUndoPluginProps { doc: Loro; @@ -14,12 +17,17 @@ interface LoroUndoPluginState { canRedo: boolean } +type Cursors = { anchor: Cursor | null, focus: Cursor | null }; export const LoroUndoPlugin = (props: LoroUndoPluginProps): Plugin => { + const undoManager = props.undoManager || new UndoManager(props.doc, {}); + let lastSelection: Cursors = { + anchor: null, + focus: null + } return new Plugin({ key: loroUndoPluginKey, state: { init: (config, editorState): LoroUndoPluginState => { - const undoManager = props.undoManager || new UndoManager(props.doc, {}); undoManager.addExcludeOriginPrefix("sys:init") return { undoManager, @@ -28,24 +36,171 @@ export const LoroUndoPlugin = (props: LoroUndoPluginProps): Plugin => { } }, apply: (tr, state, oldEditorState, newEditorState) => { - const undoState = loroUndoPluginKey.getState(newEditorState); - if (!undoState) { + const undoState = loroUndoPluginKey.getState(oldEditorState); + const loroState = loroSyncPluginKey.getState(oldEditorState); + if (!undoState || !loroState) { return state; } const canUndo = undoState.undoManager.canUndo(); const canRedo = undoState.undoManager.canRedo(); - if (canUndo !== state.canUndo || canRedo !== state.canRedo) { + const { anchor, focus } = convertPmSelectionToCursors(oldEditorState.doc, oldEditorState.selection, loroState); + lastSelection = { + anchor: anchor ?? null, + focus: focus ?? null + } + return { + ...state, + canUndo, + canRedo, + } + }, + } as StateField, + + view: (view: EditorView) => { + // When in the undo/redo loop, the new undo/redo stack item should restore the selection + // to the state it was in before the item that was popped two steps ago from the stack. + // + // ┌────────────┐ + // │Selection 1 │ + // └─────┬──────┘ + // │ Some + // ▼ ops + // ┌────────────┐ + // │Selection 2 │ + // └─────┬──────┘ + // │ Some + // ▼ ops + // ┌────────────┐ + // │Selection 3 │◁ ─ ─ ─ ─ ─ ─ ─ Restore ─ ─ ─ + // └─────┬──────┘ │ + // │ + // │ │ + // │ ┌ ─ ─ ─ ─ ─ ─ ─ + // Enter the │ Undo ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶ Push Redo │ + // undo/redo ─ ─ ─ ▶ ▼ └ ─ ─ ─ ─ ─ ─ ─ + // loop ┌────────────┐ │ + // │Selection 2 │◁─ ─ ─ Restore ─ + // └─────┬──────┘ │ │ + // │ + // │ │ │ + // │ ┌ ─ ─ ─ ─ ─ ─ ─ + // │ Undo ─ ─ ─ ─ ▶ Push Redo │ │ + // ▼ └ ─ ─ ─ ─ ─ ─ ─ + // ┌────────────┐ │ │ + // │Selection 1 │ + // └─────┬──────┘ │ │ + // │ Redo ◀ ─ ─ ─ ─ ─ ─ ─ ─ + // ▼ │ + // ┌────────────┐ + // ┌ Restore ─ ▷│Selection 2 │ │ + // └─────┬──────┘ + // │ │ │ + // ┌ ─ ─ ─ ─ ─ ─ ─ │ + // Push Undo │◀─ ─ ─ ─ ─ ─ ─ │ Redo ◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ + // └ ─ ─ ─ ─ ─ ─ ─ ▼ + // │ ┌────────────┐ + // │Selection 3 │ + // │ └─────┬──────┘ + // ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ▶ │ Undo + // ▼ + // ┌────────────┐ + // │Selection 2 │ + // └────────────┘ + // + // Because users may change the selections during the undo/redo loop, it's + // more stable to keep the selection stored in the last stack item + // rather than using the current selection directly. + + let lastUndoRedoLoopSelection: Cursors | null = null; + let justPopped = false; + props.doc.subscribe(event => { + if (event.by === "import") { + lastUndoRedoLoopSelection = null; + } + }); + + undoManager.setOnPush((isUndo, _counterRange) => { + if (!justPopped) { + // A new op is being pushed to the undo stack, so it breaks the + // undo/redo loop. + console.assert(isUndo); + lastUndoRedoLoopSelection = null; + } + + const loroState = loroSyncPluginKey.getState(view.state); + if (loroState?.doc == null) { return { - ...state, - canUndo, - canRedo + value: null, + cursors: [] + }; + } + + const cursors: Cursor[] = []; + if (lastSelection.anchor) { + cursors.push(lastSelection.anchor); + } + if (lastSelection.focus) { + cursors.push(lastSelection.focus); + } + + return { + value: null, + // The undo manager will internally transform the cursors. + // Undo/redo operations may recreate deleted content, so we need to remap + // the cursors to their new positions. Additionally, if containers are deleted + // and recreated, they also need remapping. Remote changes to the document + // should be considered in these transformations. + cursors + } + }) + undoManager.setOnPop((isUndo, meta, counterRange) => { + // After this call, the `onPush` will be called immediately. + // The immediate `onPush` will contain the inverse operations that undone the effect caused by the current `onPop` + const loroState = loroSyncPluginKey.getState(view.state); + if (loroState?.doc == null) { + return; + } + + const anchor = meta.cursors[0] ?? null; + const focus = meta.cursors[1] ?? null + if (anchor == null) { + return; + } + + if (lastUndoRedoLoopSelection) { + // We overwrite the lastSelection so that the corresponding `onPush` + // will restore the selection to the state it was in before the + // item that was popped two steps ago from the stack. + lastSelection = lastUndoRedoLoopSelection; + } + + lastUndoRedoLoopSelection = { + anchor, + focus + }; + + justPopped = true; + setTimeout(() => { + try { + justPopped = false; + const anchorPos = cursorToAbsolutePosition(anchor, loroState.doc, loroState.mapping)[0]; + const focusPos = focus && cursorToAbsolutePosition(focus, loroState.doc, loroState.mapping)[0]; + const selection = TextSelection.create(view.state.doc, anchorPos, focusPos ?? undefined) + view.dispatch(view.state.tr.setSelection(selection)); + } catch (e) { + console.error(e); } + }, 0) + }); + return { + destroy: () => { + undoManager.setOnPop(); + undoManager.setOnPush(); } + } - return state; - }, - } as StateField, + } }); };