mirror of
https://github.com/langgenius/dify.git
synced 2026-03-18 05:09:54 +08:00
Merge branch 'p284' into deploy/dev
This commit is contained in:
@ -0,0 +1,78 @@
|
||||
import type { FC } from 'react'
|
||||
import { useViewport } from 'reactflow'
|
||||
import type { CursorPosition, OnlineUser } from '@/app/components/workflow/collaboration/types'
|
||||
import { getUserColor } from '../utils/user-color'
|
||||
|
||||
type UserCursorsProps = {
|
||||
cursors: Record<string, CursorPosition>
|
||||
myUserId: string | null
|
||||
onlineUsers: OnlineUser[]
|
||||
}
|
||||
|
||||
const UserCursors: FC<UserCursorsProps> = ({
|
||||
cursors,
|
||||
myUserId,
|
||||
onlineUsers,
|
||||
}) => {
|
||||
const viewport = useViewport()
|
||||
|
||||
const convertToScreenCoordinates = (cursor: CursorPosition) => {
|
||||
// Convert world coordinates to screen coordinates using current viewport
|
||||
const screenX = cursor.x * viewport.zoom + viewport.x
|
||||
const screenY = cursor.y * viewport.zoom + viewport.y
|
||||
|
||||
return { x: screenX, y: screenY }
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{Object.entries(cursors || {}).map(([userId, cursor]) => {
|
||||
if (userId === myUserId)
|
||||
return null
|
||||
|
||||
const userInfo = onlineUsers.find(user => user.user_id === userId)
|
||||
const userName = userInfo?.username || `User ${userId.slice(-4)}`
|
||||
const userColor = getUserColor(userId)
|
||||
const screenPos = convertToScreenCoordinates(cursor)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={userId}
|
||||
className="pointer-events-none absolute z-[10000] transition-all duration-150 ease-out"
|
||||
style={{
|
||||
left: screenPos.x,
|
||||
top: screenPos.y,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="drop-shadow-md"
|
||||
>
|
||||
<path
|
||||
d="M5 3L5 15L8 11.5L11 16L13 15L10 10.5L14 10.5L5 3Z"
|
||||
fill={userColor}
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="absolute left-4 top-4 max-w-[120px] overflow-hidden text-ellipsis whitespace-nowrap rounded px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm"
|
||||
style={{
|
||||
backgroundColor: userColor,
|
||||
}}
|
||||
>
|
||||
{userName}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserCursors
|
||||
@ -0,0 +1,856 @@
|
||||
import { LoroDoc, UndoManager } from 'loro-crdt'
|
||||
import { isEqual } from 'lodash-es'
|
||||
import { webSocketClient } from './websocket-manager'
|
||||
import { CRDTProvider } from './crdt-provider'
|
||||
import { EventEmitter } from './event-emitter'
|
||||
import type { Edge, Node } from '../../types'
|
||||
import type {
|
||||
CollaborationState,
|
||||
CursorPosition,
|
||||
NodePanelPresenceMap,
|
||||
NodePanelPresenceUser,
|
||||
OnlineUser,
|
||||
} from '../types/collaboration'
|
||||
|
||||
type NodePanelPresenceEventData = {
|
||||
nodeId: string
|
||||
action: 'open' | 'close'
|
||||
user: NodePanelPresenceUser
|
||||
clientId: string
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
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
|
||||
private eventEmitter = new EventEmitter()
|
||||
private currentAppId: string | null = null
|
||||
private reactFlowStore: any = null
|
||||
private isLeader = false
|
||||
private leaderId: string | null = null
|
||||
private cursors: Record<string, CursorPosition> = {}
|
||||
private nodePanelPresence: NodePanelPresenceMap = {}
|
||||
private activeConnections = new Set<string>()
|
||||
private isUndoRedoInProgress = false
|
||||
|
||||
private getNodePanelPresenceSnapshot(): NodePanelPresenceMap {
|
||||
const snapshot: NodePanelPresenceMap = {}
|
||||
Object.entries(this.nodePanelPresence).forEach(([nodeId, viewers]) => {
|
||||
snapshot[nodeId] = { ...viewers }
|
||||
})
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private applyNodePanelPresenceUpdate(update: NodePanelPresenceEventData): void {
|
||||
const { nodeId, action, clientId, user, timestamp } = update
|
||||
|
||||
if (action === 'open') {
|
||||
// ensure a client only appears on a single node at a time
|
||||
Object.entries(this.nodePanelPresence).forEach(([id, viewers]) => {
|
||||
if (viewers[clientId]) {
|
||||
delete viewers[clientId]
|
||||
if (Object.keys(viewers).length === 0)
|
||||
delete this.nodePanelPresence[id]
|
||||
}
|
||||
})
|
||||
|
||||
if (!this.nodePanelPresence[nodeId])
|
||||
this.nodePanelPresence[nodeId] = {}
|
||||
|
||||
this.nodePanelPresence[nodeId][clientId] = {
|
||||
...user,
|
||||
clientId,
|
||||
timestamp: timestamp || Date.now(),
|
||||
}
|
||||
}
|
||||
else {
|
||||
const viewers = this.nodePanelPresence[nodeId]
|
||||
if (viewers) {
|
||||
delete viewers[clientId]
|
||||
if (Object.keys(viewers).length === 0)
|
||||
delete this.nodePanelPresence[nodeId]
|
||||
}
|
||||
}
|
||||
|
||||
this.eventEmitter.emit('nodePanelPresence', this.getNodePanelPresenceSnapshot())
|
||||
}
|
||||
|
||||
private cleanupNodePanelPresence(activeClientIds: Set<string>, activeUserIds: Set<string>): void {
|
||||
let hasChanges = false
|
||||
|
||||
Object.entries(this.nodePanelPresence).forEach(([nodeId, viewers]) => {
|
||||
Object.keys(viewers).forEach((clientId) => {
|
||||
const viewer = viewers[clientId]
|
||||
const clientActive = activeClientIds.has(clientId)
|
||||
const userActive = viewer?.userId ? activeUserIds.has(viewer.userId) : false
|
||||
|
||||
if (!clientActive && !userActive) {
|
||||
delete viewers[clientId]
|
||||
hasChanges = true
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(viewers).length === 0)
|
||||
delete this.nodePanelPresence[nodeId]
|
||||
})
|
||||
|
||||
if (hasChanges)
|
||||
this.eventEmitter.emit('nodePanelPresence', this.getNodePanelPresenceSnapshot())
|
||||
}
|
||||
|
||||
init = (appId: string, reactFlowStore: any): void => {
|
||||
if (!reactFlowStore) {
|
||||
console.warn('CollaborationManager.init called without reactFlowStore, deferring to connect()')
|
||||
return
|
||||
}
|
||||
this.connect(appId, reactFlowStore)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
this.doc.commit()
|
||||
}
|
||||
|
||||
destroy = (): void => {
|
||||
this.disconnect()
|
||||
}
|
||||
|
||||
async connect(appId: string, reactFlowStore?: any): Promise<string> {
|
||||
const connectionId = Math.random().toString(36).substring(2, 11)
|
||||
|
||||
this.activeConnections.add(connectionId)
|
||||
|
||||
if (this.currentAppId === appId && this.doc) {
|
||||
// Already connected to the same app, only update store if provided and we don't have one
|
||||
if (reactFlowStore && !this.reactFlowStore)
|
||||
this.reactFlowStore = reactFlowStore
|
||||
|
||||
return connectionId
|
||||
}
|
||||
|
||||
// Only disconnect if switching to a different app
|
||||
if (this.currentAppId && this.currentAppId !== appId)
|
||||
this.forceDisconnect()
|
||||
|
||||
this.currentAppId = appId
|
||||
// Only set store if provided
|
||||
if (reactFlowStore)
|
||||
this.reactFlowStore = reactFlowStore
|
||||
|
||||
const socket = webSocketClient.connect(appId)
|
||||
|
||||
// Setup event listeners BEFORE any other operations
|
||||
this.setupSocketEventListeners(socket)
|
||||
|
||||
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()
|
||||
|
||||
// Force user_connect if already connected
|
||||
if (socket.connected)
|
||||
socket.emit('user_connect', { workflow_id: appId })
|
||||
|
||||
return connectionId
|
||||
}
|
||||
|
||||
disconnect = (connectionId?: string): void => {
|
||||
if (connectionId)
|
||||
this.activeConnections.delete(connectionId)
|
||||
|
||||
// Only disconnect when no more connections
|
||||
if (this.activeConnections.size === 0)
|
||||
this.forceDisconnect()
|
||||
}
|
||||
|
||||
private forceDisconnect = (): void => {
|
||||
if (this.currentAppId)
|
||||
webSocketClient.disconnect(this.currentAppId)
|
||||
|
||||
this.provider?.destroy()
|
||||
this.undoManager = null
|
||||
this.doc = null
|
||||
this.provider = null
|
||||
this.nodesMap = null
|
||||
this.edgesMap = null
|
||||
this.currentAppId = null
|
||||
this.reactFlowStore = null
|
||||
this.cursors = {}
|
||||
this.nodePanelPresence = {}
|
||||
this.isUndoRedoInProgress = false
|
||||
|
||||
// Only reset leader status when actually disconnecting
|
||||
const wasLeader = this.isLeader
|
||||
this.isLeader = false
|
||||
this.leaderId = null
|
||||
|
||||
if (wasLeader)
|
||||
this.eventEmitter.emit('leaderChange', false)
|
||||
|
||||
this.activeConnections.clear()
|
||||
this.eventEmitter.removeAllListeners()
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.currentAppId ? webSocketClient.isConnected(this.currentAppId) : false
|
||||
}
|
||||
|
||||
getNodes(): Node[] {
|
||||
return this.nodesMap ? Array.from(this.nodesMap.values()) : []
|
||||
}
|
||||
|
||||
getEdges(): Edge[] {
|
||||
return this.edgesMap ? Array.from(this.edgesMap.values()) : []
|
||||
}
|
||||
|
||||
emitCursorMove(position: CursorPosition): void {
|
||||
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return
|
||||
|
||||
const socket = webSocketClient.getSocket(this.currentAppId)
|
||||
if (socket) {
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'mouseMove',
|
||||
userId: socket.id,
|
||||
data: { x: position.x, y: position.y },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emitSyncRequest(): void {
|
||||
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return
|
||||
|
||||
const socket = webSocketClient.getSocket(this.currentAppId)
|
||||
if (socket) {
|
||||
console.log('Emitting sync request to leader')
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'syncRequest',
|
||||
data: { timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emitWorkflowUpdate(appId: string): void {
|
||||
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return
|
||||
|
||||
const socket = webSocketClient.getSocket(this.currentAppId)
|
||||
if (socket) {
|
||||
console.log('Emitting Workflow update event')
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'workflowUpdate',
|
||||
data: { appId, timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emitNodePanelPresence(nodeId: string, isOpen: boolean, user: NodePanelPresenceUser): void {
|
||||
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return
|
||||
|
||||
const socket = webSocketClient.getSocket(this.currentAppId)
|
||||
if (!socket || !nodeId || !user?.userId) return
|
||||
|
||||
const payload: NodePanelPresenceEventData = {
|
||||
nodeId,
|
||||
action: isOpen ? 'open' : 'close',
|
||||
user,
|
||||
clientId: socket.id as string,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'nodePanelPresence',
|
||||
data: payload,
|
||||
timestamp: payload.timestamp,
|
||||
})
|
||||
|
||||
this.applyNodePanelPresenceUpdate(payload)
|
||||
}
|
||||
|
||||
onSyncRequest(callback: () => void): () => void {
|
||||
return this.eventEmitter.on('syncRequest', callback)
|
||||
}
|
||||
|
||||
onStateChange(callback: (state: Partial<CollaborationState>) => void): () => void {
|
||||
return this.eventEmitter.on('stateChange', callback)
|
||||
}
|
||||
|
||||
onCursorUpdate(callback: (cursors: Record<string, CursorPosition>) => void): () => void {
|
||||
return this.eventEmitter.on('cursors', callback)
|
||||
}
|
||||
|
||||
onOnlineUsersUpdate(callback: (users: OnlineUser[]) => void): () => void {
|
||||
return this.eventEmitter.on('onlineUsers', callback)
|
||||
}
|
||||
|
||||
onWorkflowUpdate(callback: (update: { appId: string; timestamp: number }) => void): () => void {
|
||||
return this.eventEmitter.on('workflowUpdate', callback)
|
||||
}
|
||||
|
||||
onVarsAndFeaturesUpdate(callback: (update: any) => void): () => void {
|
||||
return this.eventEmitter.on('varsAndFeaturesUpdate', callback)
|
||||
}
|
||||
|
||||
onAppStateUpdate(callback: (update: any) => void): () => void {
|
||||
return this.eventEmitter.on('appStateUpdate', callback)
|
||||
}
|
||||
|
||||
onMcpServerUpdate(callback: (update: any) => void): () => void {
|
||||
return this.eventEmitter.on('mcpServerUpdate', callback)
|
||||
}
|
||||
|
||||
onNodePanelPresenceUpdate(callback: (presence: NodePanelPresenceMap) => void): () => void {
|
||||
const off = this.eventEmitter.on('nodePanelPresence', callback)
|
||||
callback(this.getNodePanelPresenceSnapshot())
|
||||
return off
|
||||
}
|
||||
|
||||
onLeaderChange(callback: (isLeader: boolean) => void): () => void {
|
||||
return this.eventEmitter.on('leaderChange', callback)
|
||||
}
|
||||
|
||||
onCommentsUpdate(callback: (update: { appId: string; timestamp: number }) => void): () => void {
|
||||
return this.eventEmitter.on('commentsUpdate', callback)
|
||||
}
|
||||
|
||||
emitCommentsUpdate(appId: string): void {
|
||||
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return
|
||||
|
||||
const socket = webSocketClient.getSocket(this.currentAppId)
|
||||
if (socket) {
|
||||
console.log('Emitting Comments update event')
|
||||
socket.emit('collaboration_event', {
|
||||
type: 'commentsUpdate',
|
||||
data: { appId, timestamp: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onUndoRedoStateChange(callback: (state: { canUndo: boolean; canRedo: boolean }) => void): () => void {
|
||||
return this.eventEmitter.on('undoRedoStateChange', callback)
|
||||
}
|
||||
|
||||
getLeaderId(): string | null {
|
||||
return this.leaderId
|
||||
}
|
||||
|
||||
getIsLeader(): boolean {
|
||||
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)
|
||||
console.log('Current leader ID:', this.leaderId)
|
||||
console.log('Active connections:', this.activeConnections.size)
|
||||
console.log('Connected:', this.isConnected())
|
||||
console.log('Current app ID:', this.currentAppId)
|
||||
console.log('Has ReactFlow store:', !!this.reactFlowStore)
|
||||
console.log('========================')
|
||||
}
|
||||
|
||||
private syncNodes(oldNodes: Node[], newNodes: Node[]): void {
|
||||
if (!this.nodesMap || !this.doc) return
|
||||
|
||||
const oldNodesMap = new Map(oldNodes.map(node => [node.id, node]))
|
||||
const newNodesMap = new Map(newNodes.map(node => [node.id, node]))
|
||||
const shouldSyncDataKey = (key: string) => !key.startsWith('_') && key !== 'selected'
|
||||
|
||||
// Delete removed nodes
|
||||
oldNodes.forEach((oldNode) => {
|
||||
if (!newNodesMap.has(oldNode.id))
|
||||
this.nodesMap.delete(oldNode.id)
|
||||
})
|
||||
|
||||
// Add or update nodes with fine-grained sync for data properties
|
||||
newNodes.forEach((newNode) => {
|
||||
const oldNode = oldNodesMap.get(newNode.id)
|
||||
|
||||
if (!oldNode) {
|
||||
// New node - create as nested structure
|
||||
const nodeData: any = {
|
||||
id: newNode.id,
|
||||
type: newNode.type,
|
||||
position: { ...newNode.position },
|
||||
width: newNode.width,
|
||||
height: newNode.height,
|
||||
sourcePosition: newNode.sourcePosition,
|
||||
targetPosition: newNode.targetPosition,
|
||||
data: {},
|
||||
}
|
||||
|
||||
// Clone data properties, excluding private ones
|
||||
Object.entries(newNode.data).forEach(([key, value]) => {
|
||||
if (shouldSyncDataKey(key) && value !== undefined)
|
||||
nodeData.data[key] = JSON.parse(JSON.stringify(value))
|
||||
})
|
||||
|
||||
this.nodesMap.set(newNode.id, nodeData)
|
||||
}
|
||||
else {
|
||||
// Get existing node from CRDT
|
||||
const existingNode = this.nodesMap.get(newNode.id)
|
||||
|
||||
if (existingNode) {
|
||||
// Create a deep copy to modify
|
||||
const updatedNode = JSON.parse(JSON.stringify(existingNode))
|
||||
|
||||
// Update position only if changed
|
||||
if (oldNode.position.x !== newNode.position.x || oldNode.position.y !== newNode.position.y)
|
||||
updatedNode.position = { ...newNode.position }
|
||||
|
||||
// Update dimensions only if changed
|
||||
if (oldNode.width !== newNode.width)
|
||||
updatedNode.width = newNode.width
|
||||
|
||||
if (oldNode.height !== newNode.height)
|
||||
updatedNode.height = newNode.height
|
||||
|
||||
// Ensure data object exists
|
||||
if (!updatedNode.data)
|
||||
updatedNode.data = {}
|
||||
|
||||
// Fine-grained update of data properties
|
||||
const oldData = oldNode.data || {}
|
||||
const newData = newNode.data || {}
|
||||
|
||||
// Only update changed properties in data
|
||||
Object.entries(newData).forEach(([key, value]) => {
|
||||
if (shouldSyncDataKey(key)) {
|
||||
const oldValue = (oldData as any)[key]
|
||||
if (!isEqual(oldValue, value))
|
||||
updatedNode.data[key] = JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
})
|
||||
|
||||
// Remove deleted properties from data
|
||||
Object.keys(oldData).forEach((key) => {
|
||||
if (shouldSyncDataKey(key) && !(key in newData))
|
||||
delete updatedNode.data[key]
|
||||
})
|
||||
|
||||
// Only update in CRDT if something actually changed
|
||||
if (!isEqual(existingNode, updatedNode))
|
||||
this.nodesMap.set(newNode.id, updatedNode)
|
||||
}
|
||||
else {
|
||||
// Node exists locally but not in CRDT yet
|
||||
const nodeData: any = {
|
||||
id: newNode.id,
|
||||
type: newNode.type,
|
||||
position: { ...newNode.position },
|
||||
width: newNode.width,
|
||||
height: newNode.height,
|
||||
sourcePosition: newNode.sourcePosition,
|
||||
targetPosition: newNode.targetPosition,
|
||||
data: {},
|
||||
}
|
||||
|
||||
Object.entries(newNode.data).forEach(([key, value]) => {
|
||||
if (shouldSyncDataKey(key) && value !== undefined)
|
||||
nodeData.data[key] = JSON.parse(JSON.stringify(value))
|
||||
})
|
||||
|
||||
this.nodesMap.set(newNode.id, nodeData)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private syncEdges(oldEdges: Edge[], newEdges: Edge[]): void {
|
||||
if (!this.edgesMap) return
|
||||
|
||||
const oldEdgesMap = new Map(oldEdges.map(edge => [edge.id, edge]))
|
||||
const newEdgesMap = new Map(newEdges.map(edge => [edge.id, edge]))
|
||||
|
||||
oldEdges.forEach((oldEdge) => {
|
||||
if (!newEdgesMap.has(oldEdge.id))
|
||||
this.edgesMap.delete(oldEdge.id)
|
||||
})
|
||||
|
||||
newEdges.forEach((newEdge) => {
|
||||
const oldEdge = oldEdgesMap.get(newEdge.id)
|
||||
if (!oldEdge) {
|
||||
const clonedEdge = JSON.parse(JSON.stringify(newEdge))
|
||||
this.edgesMap.set(newEdge.id, clonedEdge)
|
||||
}
|
||||
else if (!isEqual(oldEdge, newEdge)) {
|
||||
const clonedEdge = JSON.parse(JSON.stringify(newEdge))
|
||||
this.edgesMap.set(newEdge.id, clonedEdge)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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(() => {
|
||||
// Get ReactFlow's native setters, not the collaborative ones
|
||||
const state = this.reactFlowStore.getState()
|
||||
const updatedNodes = Array.from(this.nodesMap.values())
|
||||
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(() => {
|
||||
// Get ReactFlow's native setters, not the collaborative ones
|
||||
const state = this.reactFlowStore.getState()
|
||||
const updatedEdges = Array.from(this.edgesMap.values())
|
||||
console.log('Updating React edges from subscription')
|
||||
|
||||
// Call ReactFlow's native setter directly to avoid triggering collaboration
|
||||
state.setEdges(updatedEdges)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private setupSocketEventListeners(socket: any): void {
|
||||
console.log('Setting up socket event listeners for collaboration')
|
||||
|
||||
socket.on('collaboration_update', (update: any) => {
|
||||
if (update.type === 'mouseMove') {
|
||||
// Update cursor state for this user
|
||||
this.cursors[update.userId] = {
|
||||
x: update.data.x,
|
||||
y: update.data.y,
|
||||
userId: update.userId,
|
||||
timestamp: update.timestamp,
|
||||
}
|
||||
|
||||
this.eventEmitter.emit('cursors', { ...this.cursors })
|
||||
}
|
||||
else if (update.type === 'varsAndFeaturesUpdate') {
|
||||
console.log('Processing varsAndFeaturesUpdate event:', update)
|
||||
this.eventEmitter.emit('varsAndFeaturesUpdate', update)
|
||||
}
|
||||
else if (update.type === 'appStateUpdate') {
|
||||
console.log('Processing appStateUpdate event:', update)
|
||||
this.eventEmitter.emit('appStateUpdate', update)
|
||||
}
|
||||
else if (update.type === 'mcpServerUpdate') {
|
||||
console.log('Processing mcpServerUpdate event:', update)
|
||||
this.eventEmitter.emit('mcpServerUpdate', update)
|
||||
}
|
||||
else if (update.type === 'workflowUpdate') {
|
||||
console.log('Processing workflowUpdate event:', update)
|
||||
this.eventEmitter.emit('workflowUpdate', update.data)
|
||||
}
|
||||
else if (update.type === 'commentsUpdate') {
|
||||
console.log('Processing commentsUpdate event:', update)
|
||||
this.eventEmitter.emit('commentsUpdate', update.data)
|
||||
}
|
||||
else if (update.type === 'nodePanelPresence') {
|
||||
console.log('Processing nodePanelPresence event:', update)
|
||||
this.applyNodePanelPresenceUpdate(update.data as NodePanelPresenceEventData)
|
||||
}
|
||||
else if (update.type === 'syncRequest') {
|
||||
console.log('Received sync request from another user')
|
||||
// Only process if we are the leader
|
||||
if (this.isLeader) {
|
||||
console.log('Leader received sync request, triggering sync')
|
||||
this.eventEmitter.emit('syncRequest', {})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('online_users', (data: { users: OnlineUser[]; leader?: string }) => {
|
||||
try {
|
||||
if (!data || !Array.isArray(data.users)) {
|
||||
console.warn('Invalid online_users data structure:', data)
|
||||
return
|
||||
}
|
||||
|
||||
const onlineUserIds = new Set(data.users.map((user: OnlineUser) => user.user_id))
|
||||
const onlineClientIds = new Set(
|
||||
data.users
|
||||
.map((user: OnlineUser) => user.sid)
|
||||
.filter((sid): sid is string => typeof sid === 'string' && sid.length > 0),
|
||||
)
|
||||
|
||||
// Remove cursors for offline users
|
||||
Object.keys(this.cursors).forEach((userId) => {
|
||||
if (!onlineUserIds.has(userId))
|
||||
delete this.cursors[userId]
|
||||
})
|
||||
|
||||
this.cleanupNodePanelPresence(onlineClientIds, onlineUserIds)
|
||||
|
||||
// Update leader information
|
||||
if (data.leader && typeof data.leader === 'string')
|
||||
this.leaderId = data.leader
|
||||
|
||||
this.eventEmitter.emit('onlineUsers', data.users)
|
||||
this.eventEmitter.emit('cursors', { ...this.cursors })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error processing online_users update:', error)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('status', (data: any) => {
|
||||
try {
|
||||
if (!data || typeof data.isLeader !== 'boolean') {
|
||||
console.warn('Invalid status data:', data)
|
||||
return
|
||||
}
|
||||
|
||||
const wasLeader = this.isLeader
|
||||
this.isLeader = data.isLeader
|
||||
|
||||
if (wasLeader !== this.isLeader)
|
||||
this.eventEmitter.emit('leaderChange', this.isLeader)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error processing status update:', error)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('status', (data: { isLeader: boolean }) => {
|
||||
if (this.isLeader !== data.isLeader) {
|
||||
this.isLeader = data.isLeader
|
||||
console.log(`Collaboration: I am now the ${this.isLeader ? 'Leader' : 'Follower'}.`)
|
||||
this.eventEmitter.emit('leaderChange', this.isLeader)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('status', (data: { isLeader: boolean }) => {
|
||||
if (this.isLeader !== data.isLeader) {
|
||||
this.isLeader = data.isLeader
|
||||
console.log(`Collaboration: I am now the ${this.isLeader ? 'Leader' : 'Follower'}.`)
|
||||
this.eventEmitter.emit('leaderChange', this.isLeader)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket connected successfully')
|
||||
this.eventEmitter.emit('stateChange', { isConnected: true })
|
||||
})
|
||||
|
||||
socket.on('disconnect', (reason: string) => {
|
||||
console.log('WebSocket disconnected:', reason)
|
||||
this.cursors = {}
|
||||
this.isLeader = false
|
||||
this.leaderId = null
|
||||
this.eventEmitter.emit('stateChange', { isConnected: false })
|
||||
this.eventEmitter.emit('cursors', {})
|
||||
})
|
||||
|
||||
socket.on('connect_error', (error: any) => {
|
||||
console.error('WebSocket connection error:', error)
|
||||
this.eventEmitter.emit('stateChange', { isConnected: false, error: error.message })
|
||||
})
|
||||
|
||||
socket.on('error', (error: any) => {
|
||||
console.error('WebSocket error:', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const collaborationManager = new CollaborationManager()
|
||||
@ -0,0 +1,36 @@
|
||||
import type { LoroDoc } from 'loro-crdt'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
|
||||
export class CRDTProvider {
|
||||
private doc: LoroDoc
|
||||
private socket: Socket
|
||||
|
||||
constructor(socket: Socket, doc: LoroDoc) {
|
||||
this.socket = socket
|
||||
this.doc = doc
|
||||
this.setupEventListeners()
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
this.doc.subscribe((event: any) => {
|
||||
if (event.by === 'local') {
|
||||
const update = this.doc.export({ mode: 'update' })
|
||||
this.socket.emit('graph_event', update)
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('graph_update', (updateData: Uint8Array) => {
|
||||
try {
|
||||
const data = new Uint8Array(updateData)
|
||||
this.doc.import(data)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error importing graph update:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.socket.off('graph_update')
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
export type EventHandler<T = any> = (data: T) => void
|
||||
|
||||
export class EventEmitter {
|
||||
private events: Map<string, Set<EventHandler>> = new Map()
|
||||
|
||||
on<T = any>(event: string, handler: EventHandler<T>): () => void {
|
||||
if (!this.events.has(event))
|
||||
this.events.set(event, new Set())
|
||||
|
||||
this.events.get(event)!.add(handler)
|
||||
|
||||
return () => this.off(event, handler)
|
||||
}
|
||||
|
||||
off<T = any>(event: string, handler?: EventHandler<T>): void {
|
||||
if (!this.events.has(event)) return
|
||||
|
||||
const handlers = this.events.get(event)!
|
||||
if (handler)
|
||||
handlers.delete(handler)
|
||||
else
|
||||
handlers.clear()
|
||||
|
||||
if (handlers.size === 0)
|
||||
this.events.delete(event)
|
||||
}
|
||||
|
||||
emit<T = any>(event: string, data: T): void {
|
||||
if (!this.events.has(event)) return
|
||||
|
||||
const handlers = this.events.get(event)!
|
||||
handlers.forEach((handler) => {
|
||||
try {
|
||||
handler(data)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`Error in event handler for ${event}:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
removeAllListeners(): void {
|
||||
this.events.clear()
|
||||
}
|
||||
|
||||
getListenerCount(event: string): number {
|
||||
return this.events.get(event)?.size || 0
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import { io } from 'socket.io-client'
|
||||
import type { DebugInfo, WebSocketConfig } from '../types/websocket'
|
||||
|
||||
export class WebSocketClient {
|
||||
private connections: Map<string, Socket> = new Map()
|
||||
private connecting: Set<string> = new Set()
|
||||
private config: WebSocketConfig
|
||||
|
||||
constructor(config: WebSocketConfig = {}) {
|
||||
const inferUrl = () => {
|
||||
if (typeof window === 'undefined')
|
||||
return 'ws://localhost:5001'
|
||||
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${scheme}//${window.location.host}`
|
||||
}
|
||||
this.config = {
|
||||
url: config.url || process.env.NEXT_PUBLIC_SOCKET_URL || inferUrl(),
|
||||
transports: config.transports || ['websocket'],
|
||||
withCredentials: config.withCredentials !== false,
|
||||
...config,
|
||||
}
|
||||
}
|
||||
|
||||
connect(appId: string): Socket {
|
||||
const existingSocket = this.connections.get(appId)
|
||||
if (existingSocket?.connected)
|
||||
return existingSocket
|
||||
|
||||
if (this.connecting.has(appId)) {
|
||||
const pendingSocket = this.connections.get(appId)
|
||||
if (pendingSocket)
|
||||
return pendingSocket
|
||||
}
|
||||
|
||||
if (existingSocket && !existingSocket.connected) {
|
||||
existingSocket.disconnect()
|
||||
this.connections.delete(appId)
|
||||
}
|
||||
|
||||
this.connecting.add(appId)
|
||||
|
||||
const authToken = localStorage.getItem('console_token')
|
||||
const socket = io(this.config.url!, {
|
||||
path: '/socket.io',
|
||||
transports: this.config.transports,
|
||||
auth: { token: authToken },
|
||||
withCredentials: this.config.withCredentials,
|
||||
})
|
||||
|
||||
this.connections.set(appId, socket)
|
||||
this.setupBaseEventListeners(socket, appId)
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
disconnect(appId?: string): void {
|
||||
if (appId) {
|
||||
const socket = this.connections.get(appId)
|
||||
if (socket) {
|
||||
socket.disconnect()
|
||||
this.connections.delete(appId)
|
||||
this.connecting.delete(appId)
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.connections.forEach(socket => socket.disconnect())
|
||||
this.connections.clear()
|
||||
this.connecting.clear()
|
||||
}
|
||||
}
|
||||
|
||||
getSocket(appId: string): Socket | null {
|
||||
return this.connections.get(appId) || null
|
||||
}
|
||||
|
||||
isConnected(appId: string): boolean {
|
||||
return this.connections.get(appId)?.connected || false
|
||||
}
|
||||
|
||||
getConnectedApps(): string[] {
|
||||
const connectedApps: string[] = []
|
||||
this.connections.forEach((socket, appId) => {
|
||||
if (socket.connected)
|
||||
connectedApps.push(appId)
|
||||
})
|
||||
return connectedApps
|
||||
}
|
||||
|
||||
getDebugInfo(): DebugInfo {
|
||||
const info: DebugInfo = {}
|
||||
this.connections.forEach((socket, appId) => {
|
||||
info[appId] = {
|
||||
connected: socket.connected,
|
||||
connecting: this.connecting.has(appId),
|
||||
socketId: socket.id,
|
||||
}
|
||||
})
|
||||
return info
|
||||
}
|
||||
|
||||
private setupBaseEventListeners(socket: Socket, appId: string): void {
|
||||
socket.on('connect', () => {
|
||||
this.connecting.delete(appId)
|
||||
socket.emit('user_connect', { workflow_id: appId })
|
||||
})
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
this.connecting.delete(appId)
|
||||
})
|
||||
|
||||
socket.on('connect_error', () => {
|
||||
this.connecting.delete(appId)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const webSocketClient = new WebSocketClient()
|
||||
|
||||
export const fetchAppsOnlineUsers = async (appIds: string[]) => {
|
||||
const response = await fetch(`/api/online-users?${new URLSearchParams({
|
||||
app_ids: appIds.join(','),
|
||||
})}`)
|
||||
return response.json()
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ReactFlowInstance } from 'reactflow'
|
||||
import { collaborationManager } from '../core/collaboration-manager'
|
||||
import { CursorService } from '../services/cursor-service'
|
||||
import type { CollaborationState } from '../types/collaboration'
|
||||
|
||||
export function useCollaboration(appId: string, reactFlowStore?: any) {
|
||||
const [state, setState] = useState<Partial<CollaborationState & { isLeader: boolean }>>({
|
||||
isConnected: false,
|
||||
onlineUsers: [],
|
||||
cursors: {},
|
||||
nodePanelPresence: {},
|
||||
isLeader: false,
|
||||
})
|
||||
|
||||
const cursorServiceRef = useRef<CursorService | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId) return
|
||||
|
||||
let connectionId: string | null = null
|
||||
|
||||
if (!cursorServiceRef.current)
|
||||
cursorServiceRef.current = new CursorService()
|
||||
|
||||
const initCollaboration = async () => {
|
||||
connectionId = await collaborationManager.connect(appId, reactFlowStore)
|
||||
setState((prev: any) => ({ ...prev, appId, isConnected: collaborationManager.isConnected() }))
|
||||
}
|
||||
|
||||
initCollaboration()
|
||||
|
||||
const unsubscribeStateChange = collaborationManager.onStateChange((newState: any) => {
|
||||
console.log('Collaboration state change:', newState)
|
||||
setState((prev: any) => ({ ...prev, ...newState }))
|
||||
})
|
||||
|
||||
const unsubscribeCursors = collaborationManager.onCursorUpdate((cursors: any) => {
|
||||
setState((prev: any) => ({ ...prev, cursors }))
|
||||
})
|
||||
|
||||
const unsubscribeUsers = collaborationManager.onOnlineUsersUpdate((users: any) => {
|
||||
console.log('Online users update:', users)
|
||||
setState((prev: any) => ({ ...prev, onlineUsers: users }))
|
||||
})
|
||||
|
||||
const unsubscribeNodePanelPresence = collaborationManager.onNodePanelPresenceUpdate((presence) => {
|
||||
setState((prev: any) => ({ ...prev, nodePanelPresence: presence }))
|
||||
})
|
||||
|
||||
const unsubscribeLeaderChange = collaborationManager.onLeaderChange((isLeader: boolean) => {
|
||||
console.log('Leader status changed:', isLeader)
|
||||
setState((prev: any) => ({ ...prev, isLeader }))
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribeStateChange()
|
||||
unsubscribeCursors()
|
||||
unsubscribeUsers()
|
||||
unsubscribeNodePanelPresence()
|
||||
unsubscribeLeaderChange()
|
||||
cursorServiceRef.current?.stopTracking()
|
||||
if (connectionId)
|
||||
collaborationManager.disconnect(connectionId)
|
||||
}
|
||||
}, [appId, reactFlowStore])
|
||||
|
||||
const startCursorTracking = (containerRef: React.RefObject<HTMLElement>, reactFlowInstance?: ReactFlowInstance) => {
|
||||
if (cursorServiceRef.current) {
|
||||
cursorServiceRef.current.startTracking(containerRef, (position) => {
|
||||
collaborationManager.emitCursorMove(position)
|
||||
}, reactFlowInstance)
|
||||
}
|
||||
}
|
||||
|
||||
const stopCursorTracking = () => {
|
||||
cursorServiceRef.current?.stopTracking()
|
||||
}
|
||||
|
||||
const result = {
|
||||
isConnected: state.isConnected || false,
|
||||
onlineUsers: state.onlineUsers || [],
|
||||
cursors: state.cursors || {},
|
||||
nodePanelPresence: state.nodePanelPresence || {},
|
||||
isLeader: state.isLeader || false,
|
||||
leaderId: collaborationManager.getLeaderId(),
|
||||
startCursorTracking,
|
||||
stopCursorTracking,
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
5
web/app/components/workflow/collaboration/index.ts
Normal file
5
web/app/components/workflow/collaboration/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { collaborationManager } from './core/collaboration-manager'
|
||||
export { webSocketClient, fetchAppsOnlineUsers } from './core/websocket-manager'
|
||||
export { CursorService } from './services/cursor-service'
|
||||
export { useCollaboration } from './hooks/use-collaboration'
|
||||
export * from './types'
|
||||
@ -0,0 +1,88 @@
|
||||
import type { RefObject } from 'react'
|
||||
import type { CursorPosition } from '../types/collaboration'
|
||||
import type { ReactFlowInstance } from 'reactflow'
|
||||
|
||||
const CURSOR_MIN_MOVE_DISTANCE = 10
|
||||
const CURSOR_THROTTLE_MS = 500
|
||||
|
||||
export class CursorService {
|
||||
private containerRef: RefObject<HTMLElement> | null = null
|
||||
private reactFlowInstance: ReactFlowInstance | null = null
|
||||
private isTracking = false
|
||||
private onCursorUpdate: ((cursors: Record<string, CursorPosition>) => void) | null = null
|
||||
private onEmitPosition: ((position: CursorPosition) => void) | null = null
|
||||
private lastEmitTime = 0
|
||||
private lastPosition: { x: number; y: number } | null = null
|
||||
|
||||
startTracking(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
onEmitPosition: (position: CursorPosition) => void,
|
||||
reactFlowInstance?: ReactFlowInstance,
|
||||
): void {
|
||||
if (this.isTracking) this.stopTracking()
|
||||
|
||||
this.containerRef = containerRef
|
||||
this.onEmitPosition = onEmitPosition
|
||||
this.reactFlowInstance = reactFlowInstance || null
|
||||
this.isTracking = true
|
||||
|
||||
if (containerRef.current)
|
||||
containerRef.current.addEventListener('mousemove', this.handleMouseMove)
|
||||
}
|
||||
|
||||
stopTracking(): void {
|
||||
if (this.containerRef?.current)
|
||||
this.containerRef.current.removeEventListener('mousemove', this.handleMouseMove)
|
||||
|
||||
this.containerRef = null
|
||||
this.reactFlowInstance = null
|
||||
this.onEmitPosition = null
|
||||
this.isTracking = false
|
||||
this.lastPosition = null
|
||||
}
|
||||
|
||||
setCursorUpdateHandler(handler: (cursors: Record<string, CursorPosition>) => void): void {
|
||||
this.onCursorUpdate = handler
|
||||
}
|
||||
|
||||
updateCursors(cursors: Record<string, CursorPosition>): void {
|
||||
if (this.onCursorUpdate)
|
||||
this.onCursorUpdate(cursors)
|
||||
}
|
||||
|
||||
private handleMouseMove = (event: MouseEvent): void => {
|
||||
if (!this.containerRef?.current || !this.onEmitPosition) return
|
||||
|
||||
const rect = this.containerRef.current.getBoundingClientRect()
|
||||
let x = event.clientX - rect.left
|
||||
let y = event.clientY - rect.top
|
||||
|
||||
// Transform coordinates to ReactFlow world coordinates if ReactFlow instance is available
|
||||
if (this.reactFlowInstance) {
|
||||
const viewport = this.reactFlowInstance.getViewport()
|
||||
// Convert screen coordinates to world coordinates
|
||||
// World coordinates = (screen coordinates - viewport translation) / zoom
|
||||
x = (x - viewport.x) / viewport.zoom
|
||||
y = (y - viewport.y) / viewport.zoom
|
||||
}
|
||||
|
||||
// Always emit cursor position (remove boundary check since world coordinates can be negative)
|
||||
const now = Date.now()
|
||||
const timeThrottled = now - this.lastEmitTime > CURSOR_THROTTLE_MS
|
||||
const minDistance = CURSOR_MIN_MOVE_DISTANCE / (this.reactFlowInstance?.getZoom() || 1)
|
||||
const distanceThrottled = !this.lastPosition
|
||||
|| (Math.abs(x - this.lastPosition.x) > minDistance)
|
||||
|| (Math.abs(y - this.lastPosition.y) > minDistance)
|
||||
|
||||
if (timeThrottled && distanceThrottled) {
|
||||
this.lastPosition = { x, y }
|
||||
this.lastEmitTime = now
|
||||
this.onEmitPosition({
|
||||
x,
|
||||
y,
|
||||
userId: '',
|
||||
timestamp: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import type { Edge, Node } from '../../types'
|
||||
|
||||
export type OnlineUser = {
|
||||
user_id: string
|
||||
username: string
|
||||
avatar: string
|
||||
sid: string
|
||||
}
|
||||
|
||||
export type WorkflowOnlineUsers = {
|
||||
workflow_id: string
|
||||
users: OnlineUser[]
|
||||
}
|
||||
|
||||
export type OnlineUserListResponse = {
|
||||
data: WorkflowOnlineUsers[]
|
||||
}
|
||||
|
||||
export type CursorPosition = {
|
||||
x: number
|
||||
y: number
|
||||
userId: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type NodePanelPresenceUser = {
|
||||
userId: string
|
||||
username: string
|
||||
avatar?: string | null
|
||||
}
|
||||
|
||||
export type NodePanelPresenceInfo = NodePanelPresenceUser & {
|
||||
clientId: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type NodePanelPresenceMap = Record<string, Record<string, NodePanelPresenceInfo>>
|
||||
|
||||
export type CollaborationState = {
|
||||
appId: string
|
||||
isConnected: boolean
|
||||
onlineUsers: OnlineUser[]
|
||||
cursors: Record<string, CursorPosition>
|
||||
nodePanelPresence: NodePanelPresenceMap
|
||||
}
|
||||
|
||||
export type GraphSyncData = {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
}
|
||||
|
||||
export type CollaborationUpdate = {
|
||||
type: 'mouseMove' | 'graphUpdate' | 'userJoin' | 'userLeave'
|
||||
userId: string
|
||||
data: any
|
||||
timestamp: number
|
||||
}
|
||||
38
web/app/components/workflow/collaboration/types/events.ts
Normal file
38
web/app/components/workflow/collaboration/types/events.ts
Normal file
@ -0,0 +1,38 @@
|
||||
export type CollaborationEvent = {
|
||||
type: string
|
||||
data: any
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type GraphUpdateEvent = {
|
||||
type: 'graph_update'
|
||||
data: Uint8Array
|
||||
} & CollaborationEvent
|
||||
|
||||
export type CursorMoveEvent = {
|
||||
type: 'cursor_move'
|
||||
data: {
|
||||
x: number
|
||||
y: number
|
||||
userId: string
|
||||
}
|
||||
} & CollaborationEvent
|
||||
|
||||
export type UserConnectEvent = {
|
||||
type: 'user_connect'
|
||||
data: {
|
||||
workflow_id: string
|
||||
}
|
||||
} & CollaborationEvent
|
||||
|
||||
export type OnlineUsersEvent = {
|
||||
type: 'online_users'
|
||||
data: {
|
||||
users: Array<{
|
||||
user_id: string
|
||||
username: string
|
||||
avatar: string
|
||||
sid: string
|
||||
}>
|
||||
}
|
||||
} & CollaborationEvent
|
||||
3
web/app/components/workflow/collaboration/types/index.ts
Normal file
3
web/app/components/workflow/collaboration/types/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './websocket'
|
||||
export * from './collaboration'
|
||||
export * from './events'
|
||||
16
web/app/components/workflow/collaboration/types/websocket.ts
Normal file
16
web/app/components/workflow/collaboration/types/websocket.ts
Normal file
@ -0,0 +1,16 @@
|
||||
export type WebSocketConfig = {
|
||||
url?: string
|
||||
token?: string
|
||||
transports?: string[]
|
||||
withCredentials?: boolean
|
||||
}
|
||||
|
||||
export type ConnectionInfo = {
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
socketId?: string
|
||||
}
|
||||
|
||||
export type DebugInfo = {
|
||||
[appId: string]: ConnectionInfo
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generate a consistent color for a user based on their ID
|
||||
* Used for cursor colors and avatar backgrounds
|
||||
*/
|
||||
export const getUserColor = (id: string): string => {
|
||||
const colors = ['#155AEF', '#0BA5EC', '#444CE7', '#7839EE', '#4CA30D', '#0E9384', '#DD2590', '#FF4405', '#D92D20', '#F79009', '#828DAD']
|
||||
const hash = id.split('').reduce((a, b) => {
|
||||
a = ((a << 5) - a) + b.charCodeAt(0)
|
||||
return a & a
|
||||
}, 0)
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
Reference in New Issue
Block a user