Merge branch 'feat/collaboration' into deploy/dev

This commit is contained in:
hjlarry 2025-10-15 09:14:07 +08:00
commit ca46718a88
28 changed files with 218 additions and 11 deletions

View File

@ -35,6 +35,7 @@ export class CollaborationManager {
private nodePanelPresence: NodePanelPresenceMap = {}
private activeConnections = new Set<string>()
private isUndoRedoInProgress = false
private pendingInitialSync = false
private getNodePanelPresenceSnapshot(): NodePanelPresenceMap {
const snapshot: NodePanelPresenceMap = {}
@ -741,6 +742,8 @@ export class CollaborationManager {
.map(node => node.id),
)
this.pendingInitialSync = false
const updatedNodes = Array
.from(this.nodesMap.values())
.map((node: Node) => {
@ -790,6 +793,8 @@ export class CollaborationManager {
const updatedEdges = Array.from(this.edgesMap.values())
console.log('Updating React edges from subscription')
this.pendingInitialSync = false
// Call ReactFlow's native setter directly to avoid triggering collaboration
state.setEdges(updatedEdges)
})
@ -852,6 +857,11 @@ export class CollaborationManager {
this.eventEmitter.emit('syncRequest', {})
}
}
else if (update.type === 'graph_resync_request') {
console.log('Received graph resync request from collaborator')
if (this.isLeader)
this.broadcastCurrentGraph()
}
})
socket.on('online_users', (data: { users: OnlineUser[]; leader?: string }) => {
@ -898,6 +908,11 @@ export class CollaborationManager {
const wasLeader = this.isLeader
this.isLeader = data.isLeader
if (this.isLeader)
this.pendingInitialSync = false
else
this.requestInitialSyncIfNeeded()
if (wasLeader !== this.isLeader)
this.eventEmitter.emit('leaderChange', this.isLeader)
}
@ -912,6 +927,10 @@ export class CollaborationManager {
console.log(`Collaboration: I am now the ${this.isLeader ? 'Leader' : 'Follower'}.`)
this.eventEmitter.emit('leaderChange', this.isLeader)
}
if (this.isLeader)
this.pendingInitialSync = false
else
this.requestInitialSyncIfNeeded()
})
socket.on('status', (data: { isLeader: boolean }) => {
@ -920,11 +939,16 @@ export class CollaborationManager {
console.log(`Collaboration: I am now the ${this.isLeader ? 'Leader' : 'Follower'}.`)
this.eventEmitter.emit('leaderChange', this.isLeader)
}
if (this.isLeader)
this.pendingInitialSync = false
else
this.requestInitialSyncIfNeeded()
})
socket.on('connect', () => {
console.log('WebSocket connected successfully')
this.eventEmitter.emit('stateChange', { isConnected: true })
this.pendingInitialSync = true
})
socket.on('disconnect', (reason: string) => {
@ -932,6 +956,7 @@ export class CollaborationManager {
this.cursors = {}
this.isLeader = false
this.leaderId = null
this.pendingInitialSync = false
this.eventEmitter.emit('stateChange', { isConnected: false })
this.eventEmitter.emit('cursors', {})
})
@ -945,6 +970,49 @@ export class CollaborationManager {
console.error('WebSocket error:', error)
})
}
// We currently only relay CRDT updates; the server doesn't persist them.
// When a follower joins mid-session, it might miss earlier broadcasts and render stale data.
// This lightweight checkpoint asks the leader to rebroadcast the latest graph snapshot once.
private requestInitialSyncIfNeeded(): void {
if (!this.pendingInitialSync) return
if (this.isLeader) {
this.pendingInitialSync = false
return
}
this.emitGraphResyncRequest()
this.pendingInitialSync = false
}
private emitGraphResyncRequest(): void {
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return
const socket = webSocketClient.getSocket(this.currentAppId)
if (!socket) return
socket.emit('collaboration_event', {
type: 'graph_resync_request',
data: { timestamp: Date.now() },
timestamp: Date.now(),
})
}
private broadcastCurrentGraph(): void {
if (!this.currentAppId || !webSocketClient.isConnected(this.currentAppId)) return
if (!this.doc) return
const socket = webSocketClient.getSocket(this.currentAppId)
if (!socket) return
try {
const snapshot = this.doc.export({ mode: 'snapshot' })
socket.emit('graph_event', snapshot)
}
catch (error) {
console.error('Failed to broadcast graph snapshot:', error)
}
}
}
export const collaborationManager = new CollaborationManager()

View File

@ -6,13 +6,13 @@ import { ControlMode } from '../types'
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, deleteWorkflowCommentReply, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment, updateWorkflowComment, updateWorkflowCommentReply } from '@/service/workflow-comment'
import { collaborationManager } from '@/app/components/workflow/collaboration'
import { useAppContext } from '@/context/app-context'
export const useWorkflowComment = () => {
const params = useParams()
const appId = params.appId as string
const reactflow = useReactFlow()
const controlMode = useStore(s => s.controlMode)
const setControlMode = useStore(s => s.setControlMode)
const pendingComment = useStore(s => s.pendingComment)
const setPendingComment = useStore(s => s.setPendingComment)
const setActiveCommentId = useStore(s => s.setActiveCommentId)
@ -31,6 +31,9 @@ export const useWorkflowComment = () => {
const setReplyUpdating = useStore(s => s.setReplyUpdating)
const commentDetailCache = useStore(s => s.commentDetailCache)
const setCommentDetailCache = useStore(s => s.setCommentDetailCache)
const rightPanelWidth = useStore(s => s.rightPanelWidth)
const nodePanelWidth = useStore(s => s.nodePanelWidth)
const { userProfile } = useAppContext()
const commentDetailCacheRef = useRef<Record<string, WorkflowCommentDetail>>(commentDetailCache)
const activeCommentIdRef = useRef<string | null>(null)
@ -113,16 +116,63 @@ export const useWorkflowComment = () => {
console.log('Comment created successfully:', newComment)
const createdAt = (newComment as any)?.created_at
const createdByAccount = {
id: userProfile?.id ?? '',
name: userProfile?.name ?? '',
email: userProfile?.email ?? '',
avatar_url: userProfile?.avatar_url || userProfile?.avatar || undefined,
}
const composedComment: WorkflowCommentList = {
id: newComment.id,
position_x: flowPosition.x,
position_y: flowPosition.y,
content,
created_by: createdByAccount.id,
created_by_account: createdByAccount,
created_at: createdAt,
updated_at: createdAt,
resolved: false,
mention_count: mentionedUserIds.length,
reply_count: 0,
participants: createdByAccount.id ? [createdByAccount] : [],
}
const composedDetail: WorkflowCommentDetail = {
id: newComment.id,
position_x: flowPosition.x,
position_y: flowPosition.y,
content,
created_by: createdByAccount.id,
created_by_account: createdByAccount,
created_at: createdAt,
updated_at: createdAt,
resolved: false,
replies: [],
mentions: mentionedUserIds.map(mentionedId => ({
mentioned_user_id: mentionedId,
mentioned_user_account: null,
reply_id: null,
})),
}
setComments([...comments, composedComment])
commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
[newComment.id]: composedDetail,
}
setCommentDetailCache(commentDetailCacheRef.current)
collaborationManager.emitCommentsUpdate(appId)
await loadComments()
setPendingComment(null)
}
catch (error) {
console.error('Failed to create comment:', error)
setPendingComment(null)
}
}, [appId, pendingComment, setPendingComment, loadComments, reactflow])
}, [appId, pendingComment, setPendingComment, reactflow, comments, setComments, userProfile, setCommentDetailCache])
const handleCommentCancel = useCallback(() => {
setPendingComment(null)
@ -142,9 +192,16 @@ export const useWorkflowComment = () => {
const cachedDetail = commentDetailCacheRef.current[comment.id]
setActiveComment(cachedDetail || comment)
let horizontalOffsetPx = 220
const hasSelectedNode = reactflow.getNodes().some(node => node.data?.selected)
const commentPanelWidth = controlMode === ControlMode.Comment ? 420 : 0
const fallbackPanelWidth = (hasSelectedNode ? nodePanelWidth : 0) + commentPanelWidth
const effectivePanelWidth = Math.max(rightPanelWidth ?? 0, fallbackPanelWidth)
const baseHorizontalOffsetPx = 220
const panelCompensationPx = effectivePanelWidth / 2
const desiredHorizontalOffsetPx = baseHorizontalOffsetPx + panelCompensationPx
const maxOffset = Math.max(0, (window.innerWidth / 2) - 60)
horizontalOffsetPx = Math.min(horizontalOffsetPx, maxOffset)
const horizontalOffsetPx = Math.min(desiredHorizontalOffsetPx, maxOffset)
reactflow.setCenter(
comment.position_x + horizontalOffsetPx,
@ -175,7 +232,18 @@ export const useWorkflowComment = () => {
finally {
setActiveCommentLoading(false)
}
}, [appId, reactflow, setActiveComment, setActiveCommentId, setActiveCommentLoading, setCommentDetailCache, setControlMode, setPendingComment])
}, [
appId,
controlMode,
nodePanelWidth,
reactflow,
rightPanelWidth,
setActiveComment,
setActiveCommentId,
setActiveCommentLoading,
setCommentDetailCache,
setPendingComment,
])
const handleCommentResolve = useCallback(async (commentId: string) => {
if (!appId) return

View File

@ -422,7 +422,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
<CandidateNode />
<CommentManager />
<div
className='pointer-events-none absolute left-0 top-0 z-10 flex w-12 items-center justify-center p-1 pl-2'
className='pointer-events-none absolute left-0 top-0 z-[70] flex w-12 items-center justify-center p-1 pl-2'
style={{ height: controlHeight }}
>
<Control />
@ -528,7 +528,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
defaultViewport={viewport}
multiSelectionKeyCode={null}
deleteKeyCode={null}
nodesDraggable={!nodesReadOnly}
nodesDraggable={!nodesReadOnly && controlMode !== ControlMode.Comment}
nodesConnectable={!nodesReadOnly}
nodesFocusable={!nodesReadOnly}
edgesFocusable={!nodesReadOnly}

View File

@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next'
import type { NodeProps } from '../../types'
import {
BlockEnum,
ControlMode,
NodeRunningStatus,
} from '../../types'
import {
@ -72,6 +73,7 @@ const BaseNode: FC<BaseNodeProps> = ({
const { userProfile } = useAppContext()
const appId = useStore(s => s.appId)
const { nodePanelPresence } = useCollaboration(appId as string)
const controlMode = useStore(s => s.controlMode)
const currentUserPresence = useMemo(() => {
const userId = userProfile?.id || ''
@ -196,6 +198,7 @@ const BaseNode: FC<BaseNodeProps> = ({
className={cn(
'group relative pb-1 shadow-xs',
'rounded-[15px] border border-transparent',
(controlMode === ControlMode.Comment) && 'hover:cursor-none',
(data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop) && 'w-[240px] bg-workflow-block-bg',
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && 'flex h-full w-full flex-col border-workflow-block-border bg-workflow-block-bg-transparent',
!data._runningStatus && 'hover:shadow-lg',

View File

@ -26,7 +26,9 @@ export const useNodeIterationInteractions = () => {
const { nodes, setNodes } = collaborativeWorkflow.getState()
const currentNode = nodes.find(n => n.id === nodeId)!
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_ITERATION_START_NODE)
if (!childrenNodes.length)
return
let rightNode: Node
let bottomNode: Node

View File

@ -1,5 +1,6 @@
import {
useCallback,
useEffect,
useRef,
} from 'react'
import produce from 'immer'
@ -26,6 +27,9 @@ const useConfig = (id: string, payload: LoopNodeType) => {
const { inputs, setInputs } = useNodeCrud<LoopNodeType>(id, payload)
const inputsRef = useRef(inputs)
useEffect(() => {
inputsRef.current = inputs
}, [inputs])
const handleInputsChange = useCallback((newInputs: LoopNodeType) => {
inputsRef.current = newInputs
setInputs(newInputs)

View File

@ -22,7 +22,9 @@ export const useNodeLoopInteractions = () => {
const handleNodeLoopRerender = useCallback((nodeId: string) => {
const { nodes, setNodes } = collaborativeWorkflow.getState()
const currentNode = nodes.find(n => n.id === nodeId)!
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
const childrenNodes = nodes.filter(n => n.parentId === nodeId && n.type !== CUSTOM_LOOP_START_NODE)
if (!childrenNodes.length)
return
let rightNode: Node
let bottomNode: Node

View File

@ -229,7 +229,7 @@ const ZoomInOut: FC<ZoomInOutProps> = ({
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<PortalToFollowElemContent className='z-[60]'>
<div className='w-[192px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
{
ZOOM_IN_OUT_OPTIONS.map((options, i) => (

View File

@ -328,6 +328,9 @@ const translation = {
zoomTo50: 'Auf 50% vergrößern',
zoomTo100: 'Auf 100% vergrößern',
zoomToFit: 'An Bildschirm anpassen',
showUserComments: 'Kommentare',
showUserCursors: 'Cursor von Mitarbeitenden',
showMiniMap: 'Minikarte',
selectionAlignment: 'Ausrichtung der Auswahl',
alignLeft: 'Links',
alignTop: 'Nach oben',

View File

@ -328,6 +328,9 @@ const translation = {
zoomTo50: 'Zoom al 50%',
zoomTo100: 'Zoom al 100%',
zoomToFit: 'Ajustar al tamaño',
showUserComments: 'Comentarios',
showUserCursors: 'Cursores de colaboradores',
showMiniMap: 'Minimapa',
alignTop: 'Arriba',
alignBottom: 'Abajo',
alignNodes: 'Alinear nodos',

View File

@ -328,6 +328,9 @@ const translation = {
zoomTo50: 'بزرگ‌نمایی به 50%',
zoomTo100: 'بزرگ‌نمایی به 100%',
zoomToFit: 'تناسب با اندازه',
showUserComments: 'نظرات',
showUserCursors: 'نشانگرهای همکاران',
showMiniMap: 'نقشه کوچک',
horizontal: 'افقی',
alignBottom: 'پایین',
alignRight: 'راست',

View File

@ -328,6 +328,9 @@ const translation = {
zoomTo50: 'Zoomer à 50%',
zoomTo100: 'Zoomer à 100%',
zoomToFit: 'Zoomer pour ajuster',
showUserComments: 'Commentaires',
showUserCursors: 'Curseurs des collaborateurs',
showMiniMap: 'Mini-carte',
alignBottom: 'Bas',
alignLeft: 'Gauche',
alignCenter: 'Centre',

View File

@ -339,6 +339,9 @@ const translation = {
zoomTo50: '50% पर ज़ूम करें',
zoomTo100: '100% पर ज़ूम करें',
zoomToFit: 'फिट करने के लिए ज़ूम करें',
showUserComments: 'टिप्पणियाँ',
showUserCursors: 'सहयोगी कर्सर',
showMiniMap: 'मिनी मानचित्र',
alignRight: 'दाएं',
alignLeft: 'बाएं',
alignTop: 'शीर्ष',

View File

@ -317,6 +317,9 @@ const translation = {
alignCenter: 'Pusat',
zoomOut: 'Perkecil',
zoomToFit: 'Perbesar agar sesuai',
showUserComments: 'Komentar',
showUserCursors: 'Kursor kolaborator',
showMiniMap: 'Peta mini',
vertical: 'Vertikal',
alignTop: 'Puncak',
alignMiddle: 'Tengah',

View File

@ -342,6 +342,9 @@ const translation = {
zoomTo50: 'Zoom al 50%',
zoomTo100: 'Zoom al 100%',
zoomToFit: 'Zoom per Adattare',
showUserComments: 'Commenti',
showUserCursors: 'Cursori dei collaboratori',
showMiniMap: 'Mini mappa',
alignRight: 'A destra',
selectionAlignment: 'Allineamento della selezione',
alignBottom: 'In basso',

View File

@ -333,6 +333,9 @@ const translation = {
zoomTo50: '50% サイズ',
zoomTo100: '等倍表示',
zoomToFit: '画面に合わせる',
showUserComments: 'コメント',
showUserCursors: '協働者のカーソル',
showMiniMap: 'ミニマップ',
horizontal: '水平',
alignBottom: '下',
alignNodes: 'ノードを整列',

View File

@ -349,6 +349,9 @@ const translation = {
zoomTo50: '50% 로 확대',
zoomTo100: '100% 로 확대',
zoomToFit: '화면에 맞게 확대',
showUserComments: '댓글',
showUserCursors: '협업자 커서',
showMiniMap: '미니맵',
alignCenter: '중앙',
alignRight: '오른쪽',
alignLeft: '왼쪽',

View File

@ -302,6 +302,9 @@ const translation = {
alignCenter: 'Centrum',
alignRight: 'Prawy',
alignNodes: 'Wyrównywanie węzłów',
showUserComments: 'Komentarze',
showUserCursors: 'Kursory współpracowników',
showMiniMap: 'Minimapa',
selectionAlignment: 'Wyrównanie zaznaczenia',
horizontal: 'Poziomy',
distributeVertical: 'Rozmieść pionowo',

View File

@ -302,6 +302,9 @@ const translation = {
alignLeft: 'Esquerda',
alignBottom: 'Inferior',
distributeHorizontal: 'Distribuir horizontalmente',
showUserComments: 'Comentários',
showUserCursors: 'Cursores dos colaboradores',
showMiniMap: 'Minimapa',
alignMiddle: 'Meio',
alignRight: 'Direita',
horizontal: 'Horizontal',

View File

@ -304,6 +304,9 @@ const translation = {
alignMiddle: 'Mijloc',
distributeVertical: 'Distribuie vertical',
alignCenter: 'Centru',
showUserComments: 'Comentarii',
showUserCursors: 'Cursoarele colaboratorilor',
showMiniMap: 'Mini-hartă',
distributeHorizontal: 'Distribuie orizontal',
alignBottom: 'Jos',
alignTop: 'Sus',

View File

@ -300,6 +300,9 @@ const translation = {
alignBottom: 'Вниз',
alignRight: 'Вправо',
distributeHorizontal: 'Распределить по горизонтали',
showUserComments: 'Комментарии',
showUserCursors: 'Курсоры участников',
showMiniMap: 'Мини-карта',
alignMiddle: 'По центру',
vertical: 'Вертикальный',
alignCenter: 'Центр',

View File

@ -300,6 +300,9 @@ const translation = {
alignBottom: 'Spodaj',
alignCenter: 'Center',
distributeVertical: 'Razporedi navpično',
showUserComments: 'Komentarji',
showUserCursors: 'Kazalci sodelavcev',
showMiniMap: 'Mini zemljevid',
alignRight: 'Desno',
alignTop: 'Vrh',
vertical: 'Navpičen',

View File

@ -302,6 +302,9 @@ const translation = {
horizontal: 'แนวนอน',
vertical: 'แนวตั้ง',
alignTop: 'ด้านบน',
showUserComments: 'ความคิดเห็น',
showUserCursors: 'เคอร์เซอร์ของผู้ร่วมงาน',
showMiniMap: 'แผนที่ย่อ',
distributeVertical: 'ระยะห่างแนวตั้ง',
alignLeft: 'ซ้าย',
selectionAlignment: 'การจัดตําแหน่งการเลือก',

View File

@ -301,6 +301,9 @@ const translation = {
alignLeft: 'Sol',
alignNodes: 'Düğümleri Hizala',
vertical: 'Dikey',
showUserComments: 'Yorumlar',
showUserCursors: 'İşbirlikçi imleçleri',
showMiniMap: 'Mini harita',
alignRight: 'Sağ',
alignTop: 'Üst',
alignBottom: 'Alt',

View File

@ -302,6 +302,9 @@ const translation = {
alignBottom: 'Низ',
alignLeft: 'Ліворуч',
alignTop: 'Верх',
showUserComments: 'Коментарі',
showUserCursors: 'Курсори співучасників',
showMiniMap: 'Мінікарта',
horizontal: 'Горизонтальний',
alignMiddle: 'По центру',
distributeVertical: 'Розподілити по вертикалі',

View File

@ -300,6 +300,9 @@ const translation = {
alignMiddle: 'Giữa',
alignRight: 'Phải',
alignNodes: 'Căn chỉnh các nút',
showUserComments: 'Bình luận',
showUserCursors: 'Con trỏ của cộng tác viên',
showMiniMap: 'Bản đồ nhỏ',
alignLeft: 'Trái',
horizontal: 'Ngang',
alignCenter: 'Giữa',

View File

@ -334,6 +334,9 @@ const translation = {
zoomTo50: '缩放到 50%',
zoomTo100: '放大到 100%',
zoomToFit: '自适应视图',
showUserComments: '评论',
showUserCursors: '协作者光标',
showMiniMap: '小地图',
alignNodes: '对齐节点',
alignLeft: '左对齐',
alignCenter: '居中对齐',

View File

@ -328,6 +328,9 @@ const translation = {
zoomTo50: '縮放到 50%',
zoomTo100: '放大到 100%',
zoomToFit: '自適應視圖',
showUserComments: '評論',
showUserCursors: '協作者游標',
showMiniMap: '小地圖',
alignNodes: '對齊節點',
distributeVertical: '垂直等間距',
alignLeft: '左對齊',