From 6d40a2b5749eefef36790bf8aff009a3cb44f0ab Mon Sep 17 00:00:00 2001 From: Hakan Shehu Date: Mon, 2 Feb 2026 09:38:00 +0100 Subject: [PATCH] Fix undo & redo for documents (#314) --- packages/crdt/src/index.ts | 2 +- .../components/documents/document-editor.tsx | 173 +++++++++++------- 2 files changed, 105 insertions(+), 70 deletions(-) diff --git a/packages/crdt/src/index.ts b/packages/crdt/src/index.ts index f73fe746..57ebda6e 100644 --- a/packages/crdt/src/index.ts +++ b/packages/crdt/src/index.ts @@ -27,7 +27,7 @@ export class YDoc { constructor(state?: Uint8Array | string | Uint8Array[] | string[]) { this.doc = new Y.Doc(); - this.undoManager = new Y.UndoManager(this.doc, { + this.undoManager = new Y.UndoManager(this.doc.getMap('object'), { trackedOrigins: new Set([ORIGIN]), }); diff --git a/packages/ui/src/components/documents/document-editor.tsx b/packages/ui/src/components/documents/document-editor.tsx index 94f68941..d6dbf6cb 100644 --- a/packages/ui/src/components/documents/document-editor.tsx +++ b/packages/ui/src/components/documents/document-editor.tsx @@ -7,7 +7,7 @@ import { useEditor, } from '@tiptap/react'; import { debounce, isEqual } from 'lodash-es'; -import { Fragment, useCallback, useEffect, useMemo, useRef } from 'react'; +import { Fragment, useEffect, useMemo, useRef } from 'react'; import { toast } from 'sonner'; import { @@ -107,6 +107,84 @@ const buildYDoc = ( return ydoc; }; +interface UndoRedoParams { + editor: ReturnType; + ydoc: YDoc; + nodeId: string; + userId: string; +} + +const performUndo = async ({ + editor, + ydoc, + nodeId, + userId, +}: UndoRedoParams) => { + const beforeContent = ydoc.getObject(); + const update = ydoc.undo(); + + if (!update) { + return; + } + + const afterContent = ydoc.getObject(); + + if (isEqual(beforeContent, afterContent)) { + return; + } + + const editorContent = buildEditorContent(nodeId, afterContent); + editor.chain().setContent(editorContent).run(); + + const result = await window.colanode.executeMutation({ + type: 'document.update', + userId, + documentId: nodeId, + update: encodeState(update), + }); + + if (!result.success) { + toast.error(result.error.message); + } +}; + +const performRedo = async ({ + editor, + ydoc, + nodeId, + userId, +}: UndoRedoParams) => { + const beforeContent = ydoc.getObject(); + console.log('beforeContent', beforeContent); + const update = ydoc.redo(); + console.log('afterContent', ydoc.getObject()); + console.log('update', update); + + if (!update) { + return; + } + + const afterContent = ydoc.getObject(); + + if (isEqual(beforeContent, afterContent)) { + return; + } + + const editorContent = buildEditorContent(nodeId, afterContent); + editor.chain().setContent(editorContent).run(); + + const result = await window.colanode.executeMutation({ + type: 'document.update', + userId, + documentId: nodeId, + update: encodeState(update), + }); + + if (!result.success) { + toast.error(result.error.message); + } +}; + export const DocumentEditor = ({ node, state, @@ -119,6 +197,7 @@ export const DocumentEditor = ({ const hasPendingChanges = useRef(false); const revisionRef = useRef(state?.revision ?? 0); const ydocRef = useRef(buildYDoc(state, updates)); + const editorRef = useRef>(null); const debouncedSave = useMemo( () => @@ -257,19 +336,38 @@ export const DocumentEditor = ({ spellCheck: 'false', }, handleKeyDown: (_, event) => { + if (!editorRef.current) { + return false; + } + if (event.key === 'z' && event.metaKey && !event.shiftKey) { event.preventDefault(); - undo(); + performUndo({ + editor: editorRef.current, + ydoc: ydocRef.current, + nodeId: node.id, + userId: workspace.userId, + }); return true; } if (event.key === 'z' && event.metaKey && event.shiftKey) { event.preventDefault(); - redo(); + performRedo({ + editor: editorRef.current, + ydoc: ydocRef.current, + nodeId: node.id, + userId: workspace.userId, + }); return true; } if (event.key === 'y' && event.metaKey) { event.preventDefault(); - redo(); + performRedo({ + editor: editorRef.current, + ydoc: ydocRef.current, + nodeId: node.id, + userId: workspace.userId, + }); return true; } }, @@ -332,71 +430,8 @@ export const DocumentEditor = ({ } }, [state, updates, editor]); - const undo = useCallback(async () => { - if (!editor) { - return; - } - - const beforeContent = ydocRef.current.getObject(); - const update = ydocRef.current.undo(); - - if (!update) { - return; - } - - const afterContent = ydocRef.current.getObject(); - - if (isEqual(beforeContent, afterContent)) { - return; - } - - const editorContent = buildEditorContent(node.id, afterContent); - editor.chain().setContent(editorContent).run(); - - const result = await window.colanode.executeMutation({ - type: 'document.update', - userId: workspace.userId, - documentId: node.id, - update: encodeState(update), - }); - - if (!result.success) { - toast.error(result.error.message); - } - }, [node.id, editor]); - - const redo = useCallback(async () => { - if (!editor) { - return; - } - - const beforeContent = ydocRef.current.getObject(); - const update = ydocRef.current.redo(); - - if (!update) { - return; - } - - const afterContent = ydocRef.current.getObject(); - - if (isEqual(beforeContent, afterContent)) { - return; - } - - const editorContent = buildEditorContent(node.id, afterContent); - editor.chain().setContent(editorContent).run(); - - const result = await window.colanode.executeMutation({ - type: 'document.update', - userId: workspace.userId, - documentId: node.id, - update: encodeState(update), - }); - - if (!result.success) { - toast.error(result.error.message); - } - }, [node.id, editor]); + // Keep editorRef updated so handleKeyDown can access the current editor + editorRef.current = editor; return ( <>