From 294fc41aeced8f37c27c2f418a671fcc54887f49 Mon Sep 17 00:00:00 2001 From: hjlarry Date: Tue, 9 Sep 2025 09:58:55 +0800 Subject: [PATCH] add redo undo manager of CRDT --- .../core/collaboration-manager.ts | 220 +++++++++++++++++- .../components/workflow/header/undo-redo.tsx | 32 ++- .../workflow/hooks/use-nodes-interactions.ts | 45 ++-- 3 files changed, 257 insertions(+), 40 deletions(-) diff --git a/web/app/components/workflow/collaboration/core/collaboration-manager.ts b/web/app/components/workflow/collaboration/core/collaboration-manager.ts index ba9a2db07a..38e47bba97 100644 --- a/web/app/components/workflow/collaboration/core/collaboration-manager.ts +++ b/web/app/components/workflow/collaboration/core/collaboration-manager.ts @@ -1,4 +1,4 @@ -import { LoroDoc } from 'loro-crdt' +import { LoroDoc, UndoManager } from 'loro-crdt' import { isEqual } from 'lodash-es' import { webSocketClient } from './websocket-manager' import { CRDTProvider } from './crdt-provider' @@ -8,6 +8,7 @@ import type { CollaborationState, CursorPosition, OnlineUser } from '../types/co export class CollaborationManager { private doc: LoroDoc | null = null + private undoManager: UndoManager | null = null private provider: CRDTProvider | null = null private nodesMap: any = null private edgesMap: any = null @@ -18,6 +19,7 @@ export class CollaborationManager { private isLeader = false private leaderId: string | null = null private activeConnections = new Set() + private isUndoRedoInProgress = false init = (appId: string, reactFlowStore: any): void => { if (!reactFlowStore) { @@ -28,15 +30,31 @@ export class CollaborationManager { } setNodes = (oldNodes: Node[], newNodes: Node[]): void => { + if (!this.doc) return + + // Don't track operations during undo/redo to prevent loops + if (this.isUndoRedoInProgress) { + console.log('Skipping setNodes during undo/redo') + return + } + + console.log('Setting nodes with tracking') this.syncNodes(oldNodes, newNodes) - if (this.doc) - this.doc.commit() + this.doc.commit() } setEdges = (oldEdges: Edge[], newEdges: Edge[]): void => { + if (!this.doc) return + + // Don't track operations during undo/redo to prevent loops + if (this.isUndoRedoInProgress) { + console.log('Skipping setEdges during undo/redo') + return + } + + console.log('Setting edges with tracking') this.syncEdges(oldEdges, newEdges) - if (this.doc) - this.doc.commit() + this.doc.commit() } destroy = (): void => { @@ -73,6 +91,54 @@ export class CollaborationManager { this.doc = new LoroDoc() this.nodesMap = this.doc.getMap('nodes') this.edgesMap = this.doc.getMap('edges') + + // Initialize UndoManager for collaborative undo/redo + this.undoManager = new UndoManager(this.doc, { + maxUndoSteps: 100, + mergeInterval: 500, // Merge operations within 500ms + excludeOriginPrefixes: [], // Don't exclude anything - let UndoManager track all local operations + onPush: (isUndo, range, event) => { + console.log('UndoManager onPush:', { isUndo, range, event }) + // Store current selection state when an operation is pushed + const selectedNode = this.reactFlowStore?.getState().getNodes().find((n: Node) => n.data.selected) + + // Emit event to update UI button states when new operation is pushed + setTimeout(() => { + this.eventEmitter.emit('undoRedoStateChange', { + canUndo: this.undoManager?.canUndo() || false, + canRedo: this.undoManager?.canRedo() || false, + }) + }, 0) + + return { + value: { + selectedNodeId: selectedNode?.id || null, + timestamp: Date.now(), + }, + cursors: [], + } + }, + onPop: (isUndo, value, counterRange) => { + console.log('UndoManager onPop:', { isUndo, value, counterRange }) + // Restore selection state when undoing/redoing + if (value?.value && typeof value.value === 'object' && 'selectedNodeId' in value.value && this.reactFlowStore) { + const selectedNodeId = (value.value as any).selectedNodeId + if (selectedNodeId) { + const { setNodes } = this.reactFlowStore.getState() + const nodes = this.reactFlowStore.getState().getNodes() + const newNodes = nodes.map((n: Node) => ({ + ...n, + data: { + ...n.data, + selected: n.id === selectedNodeId, + }, + })) + setNodes(newNodes) + } + } + }, + }) + this.provider = new CRDTProvider(socket, this.doc) this.setupSubscriptions() @@ -98,6 +164,7 @@ export class CollaborationManager { webSocketClient.disconnect(this.currentAppId) this.provider?.destroy() + this.undoManager = null this.doc = null this.provider = null this.nodesMap = null @@ -105,6 +172,7 @@ export class CollaborationManager { this.currentAppId = null this.reactFlowStore = null this.cursors = {} + this.isUndoRedoInProgress = false // Only reset leader status when actually disconnecting const wasLeader = this.isLeader @@ -182,6 +250,10 @@ export class CollaborationManager { return this.eventEmitter.on('leaderChange', callback) } + onUndoRedoStateChange(callback: (state: { canUndo: boolean; canRedo: boolean }) => void): () => void { + return this.eventEmitter.on('undoRedoStateChange', callback) + } + getLeaderId(): string | null { return this.leaderId } @@ -190,6 +262,114 @@ export class CollaborationManager { return this.isLeader } + // Collaborative undo/redo methods + undo(): boolean { + if (!this.undoManager) { + console.log('UndoManager not initialized') + return false + } + + const canUndo = this.undoManager.canUndo() + console.log('Can undo:', canUndo) + + if (canUndo) { + this.isUndoRedoInProgress = true + const result = this.undoManager.undo() + + // After undo, manually update React state from CRDT without triggering collaboration + if (result && this.reactFlowStore) { + requestAnimationFrame(() => { + // Get ReactFlow's native setters, not the collaborative ones + const state = this.reactFlowStore.getState() + const updatedNodes = Array.from(this.nodesMap.values()) + const updatedEdges = Array.from(this.edgesMap.values()) + console.log('Manually updating React state after undo') + + // Call ReactFlow's native setters directly to avoid triggering collaboration + state.setNodes(updatedNodes) + state.setEdges(updatedEdges) + + this.isUndoRedoInProgress = false + + // Emit event to update UI button states + this.eventEmitter.emit('undoRedoStateChange', { + canUndo: this.undoManager?.canUndo() || false, + canRedo: this.undoManager?.canRedo() || false, + }) + }) + } + else { + this.isUndoRedoInProgress = false + } + + console.log('Undo result:', result) + return result + } + + return false + } + + redo(): boolean { + if (!this.undoManager) { + console.log('RedoManager not initialized') + return false + } + + const canRedo = this.undoManager.canRedo() + console.log('Can redo:', canRedo) + + if (canRedo) { + this.isUndoRedoInProgress = true + const result = this.undoManager.redo() + + // After redo, manually update React state from CRDT without triggering collaboration + if (result && this.reactFlowStore) { + requestAnimationFrame(() => { + // Get ReactFlow's native setters, not the collaborative ones + const state = this.reactFlowStore.getState() + const updatedNodes = Array.from(this.nodesMap.values()) + const updatedEdges = Array.from(this.edgesMap.values()) + console.log('Manually updating React state after redo') + + // Call ReactFlow's native setters directly to avoid triggering collaboration + state.setNodes(updatedNodes) + state.setEdges(updatedEdges) + + this.isUndoRedoInProgress = false + + // Emit event to update UI button states + this.eventEmitter.emit('undoRedoStateChange', { + canUndo: this.undoManager?.canUndo() || false, + canRedo: this.undoManager?.canRedo() || false, + }) + }) + } + else { + this.isUndoRedoInProgress = false + } + + console.log('Redo result:', result) + return result + } + + return false + } + + canUndo(): boolean { + if (!this.undoManager) return false + return this.undoManager.canUndo() + } + + canRedo(): boolean { + if (!this.undoManager) return false + return this.undoManager.canRedo() + } + + clearUndoStack(): void { + if (!this.undoManager) return + this.undoManager.clear() + } + debugLeaderStatus(): void { console.log('=== Leader Status Debug ===') console.log('Current leader status:', this.isLeader) @@ -334,21 +514,43 @@ export class CollaborationManager { private setupSubscriptions(): void { this.nodesMap?.subscribe((event: any) => { + console.log('nodesMap subscription event:', event) if (event.by === 'import' && this.reactFlowStore) { + // Don't update React nodes during undo/redo to prevent loops + if (this.isUndoRedoInProgress) { + console.log('Skipping nodes subscription update during undo/redo') + return + } + requestAnimationFrame(() => { - const { setNodes } = this.reactFlowStore.getState() + // Get ReactFlow's native setters, not the collaborative ones + const state = this.reactFlowStore.getState() const updatedNodes = Array.from(this.nodesMap.values()) - setNodes(updatedNodes) + console.log('Updating React nodes from subscription') + + // Call ReactFlow's native setter directly to avoid triggering collaboration + state.setNodes(updatedNodes) }) } }) this.edgesMap?.subscribe((event: any) => { + console.log('edgesMap subscription event:', event) if (event.by === 'import' && this.reactFlowStore) { + // Don't update React edges during undo/redo to prevent loops + if (this.isUndoRedoInProgress) { + console.log('Skipping edges subscription update during undo/redo') + return + } + requestAnimationFrame(() => { - const { setEdges } = this.reactFlowStore.getState() + // Get ReactFlow's native setters, not the collaborative ones + const state = this.reactFlowStore.getState() const updatedEdges = Array.from(this.edgesMap.values()) - setEdges(updatedEdges) + console.log('Updating React edges from subscription') + + // Call ReactFlow's native setter directly to avoid triggering collaboration + state.setEdges(updatedEdges) }) } }) diff --git a/web/app/components/workflow/header/undo-redo.tsx b/web/app/components/workflow/header/undo-redo.tsx index 9beb655e28..2e3ea8bffd 100644 --- a/web/app/components/workflow/header/undo-redo.tsx +++ b/web/app/components/workflow/header/undo-redo.tsx @@ -6,27 +6,39 @@ import { RiArrowGoForwardFill, } from '@remixicon/react' import TipPopup from '../operator/tip-popup' -import { useWorkflowHistoryStore } from '../workflow-history-store' import Divider from '../../base/divider' import { useNodesReadOnly } from '@/app/components/workflow/hooks' import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history' +import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager' import classNames from '@/utils/classnames' export type UndoRedoProps = { handleUndo: () => void; handleRedo: () => void } const UndoRedo: FC = ({ handleUndo, handleRedo }) => { const { t } = useTranslation() - const { store } = useWorkflowHistoryStore() const [buttonsDisabled, setButtonsDisabled] = useState({ undo: true, redo: true }) useEffect(() => { - const unsubscribe = store.temporal.subscribe((state) => { + // Update button states based on Loro's UndoManager + const updateButtonStates = () => { setButtonsDisabled({ - undo: state.pastStates.length === 0, - redo: state.futureStates.length === 0, + undo: !collaborationManager.canUndo(), + redo: !collaborationManager.canRedo(), + }) + } + + // Initial state + updateButtonStates() + + // Listen for undo/redo state changes + const unsubscribe = collaborationManager.onUndoRedoStateChange((state) => { + setButtonsDisabled({ + undo: !state.canUndo, + redo: !state.canRedo, }) }) + return () => unsubscribe() - }, [store]) + }, []) const { nodesReadOnly } = useNodesReadOnly() @@ -36,9 +48,9 @@ const UndoRedo: FC = ({ handleUndo, handleRedo }) => {
!nodesReadOnly && !buttonsDisabled.undo && handleUndo()} > @@ -48,9 +60,9 @@ const UndoRedo: FC = ({ handleUndo, handleRedo }) => {
!nodesReadOnly && !buttonsDisabled.redo && handleRedo()} > diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 854d54b110..4925274e8d 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -50,7 +50,7 @@ import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants' import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types' import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions' import { useNodeLoopInteractions } from '../nodes/loop/use-interactions' -import { useWorkflowHistoryStore } from '../workflow-history-store' +import { collaborationManager } from '../collaboration/core/collaboration-manager' import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useHelpline } from './use-helpline' import { @@ -67,7 +67,6 @@ export const useNodesInteractions = () => { const collaborativeWorkflow = useCollaborativeWorkflow() const workflowStore = useWorkflowStore() const reactflow = useReactFlow() - const { store: workflowHistoryStore } = useWorkflowHistoryStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { checkNestedParallelLimit, @@ -86,7 +85,7 @@ export const useNodesInteractions = () => { } = useNodeLoopInteractions() const dragNodeStartPosition = useRef({ x: 0, y: 0 } as { x: number; y: number }) - const { saveStateToHistory, undo, redo } = useWorkflowHistory() + const { saveStateToHistory } = useWorkflowHistory() const handleNodeDragStart = useCallback((_, node) => { workflowStore.setState({ nodeAnimation: false }) @@ -1427,31 +1426,35 @@ export const useNodesInteractions = () => { if (getNodesReadOnly() || getWorkflowReadOnly()) return - const { setNodes, setEdges } = collaborativeWorkflow.getState() - undo() + // Use collaborative undo from Loro + const undoResult = collaborationManager.undo() - const { edges, nodes } = workflowHistoryStore.getState() - if (edges.length === 0 && nodes.length === 0) - return - - setEdges(edges) - setNodes(nodes) - }, [collaborativeWorkflow, undo, workflowHistoryStore, getNodesReadOnly, getWorkflowReadOnly]) + if (undoResult) { + // The undo operation will automatically trigger subscriptions + // which will update the nodes and edges through setupSubscriptions + console.log('Collaborative undo performed') + } + else { + console.log('Nothing to undo') + } + }, [getNodesReadOnly, getWorkflowReadOnly]) const handleHistoryForward = useCallback(() => { if (getNodesReadOnly() || getWorkflowReadOnly()) return - const { setNodes, setEdges } = collaborativeWorkflow.getState() - redo() + // Use collaborative redo from Loro + const redoResult = collaborationManager.redo() - const { edges, nodes } = workflowHistoryStore.getState() - if (edges.length === 0 && nodes.length === 0) - return - - setEdges(edges) - setNodes(nodes) - }, [redo, collaborativeWorkflow, workflowHistoryStore, getNodesReadOnly, getWorkflowReadOnly]) + if (redoResult) { + // The redo operation will automatically trigger subscriptions + // which will update the nodes and edges through setupSubscriptions + console.log('Collaborative redo performed') + } + else { + console.log('Nothing to redo') + } + }, [getNodesReadOnly, getWorkflowReadOnly]) return { handleNodeDragStart,