mirror of https://github.com/langgenius/dify.git
add redo undo manager of CRDT
This commit is contained in:
parent
684f7df158
commit
294fc41aec
|
|
@ -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<string>()
|
||||
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)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<UndoRedoProps> = ({ 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<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
|||
<div
|
||||
data-tooltip-id='workflow.undo'
|
||||
className={
|
||||
classNames('flex items-center px-1.5 w-8 h-8 rounded-md system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary cursor-pointer select-none',
|
||||
classNames('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
(nodesReadOnly || buttonsDisabled.undo)
|
||||
&& 'hover:bg-transparent text-text-disabled hover:text-text-disabled cursor-not-allowed')}
|
||||
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')}
|
||||
onClick={() => !nodesReadOnly && !buttonsDisabled.undo && handleUndo()}
|
||||
>
|
||||
<RiArrowGoBackLine className='h-4 w-4' />
|
||||
|
|
@ -48,9 +60,9 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
|||
<div
|
||||
data-tooltip-id='workflow.redo'
|
||||
className={
|
||||
classNames('flex items-center px-1.5 w-8 h-8 rounded-md system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary cursor-pointer select-none',
|
||||
classNames('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
(nodesReadOnly || buttonsDisabled.redo)
|
||||
&& 'hover:bg-transparent text-text-disabled hover:text-text-disabled cursor-not-allowed',
|
||||
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
|
||||
)}
|
||||
onClick={() => !nodesReadOnly && !buttonsDisabled.redo && handleRedo()}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<NodeDragHandler>((_, 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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue