diff --git a/web/app/components/workflow/comment/comment-icon.tsx b/web/app/components/workflow/comment/comment-icon.tsx index 27db952f26..873a824eff 100644 --- a/web/app/components/workflow/comment/comment-icon.tsx +++ b/web/app/components/workflow/comment/comment-icon.tsx @@ -1,7 +1,7 @@ 'use client' -import type { FC } from 'react' -import { memo, useMemo, useState } from 'react' +import type { FC, PointerEvent as ReactPointerEvent } from 'react' +import { memo, useCallback, useMemo, useRef, useState } from 'react' import { useReactFlow, useViewport } from 'reactflow' import { UserAvatarList } from '@/app/components/base/user-avatar-list' import CommentPreview from './comment-preview' @@ -11,17 +11,22 @@ type CommentIconProps = { comment: WorkflowCommentList onClick: () => void isActive?: boolean + onPositionUpdate?: (position: { x: number; y: number }) => void } -export const CommentIcon: FC = memo(({ comment, onClick, isActive = false }) => { - const { flowToScreenPosition } = useReactFlow() +export const CommentIcon: FC = memo(({ comment, onClick, isActive = false, onPositionUpdate }) => { + const { flowToScreenPosition, screenToFlowPosition } = useReactFlow() const viewport = useViewport() const [showPreview, setShowPreview] = useState(false) - - const handlePreviewClick = () => { - setShowPreview(false) - onClick() - } + const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null) + const [isDragging, setIsDragging] = useState(false) + const dragStateRef = useRef<{ + offsetX: number + offsetY: number + startX: number + startY: number + hasMoved: boolean + } | null>(null) const screenPosition = useMemo(() => { return flowToScreenPosition({ @@ -30,6 +35,108 @@ export const CommentIcon: FC = memo(({ comment, onClick, isAct }) }, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition]) + const effectivePosition = dragPosition ?? screenPosition + + const handlePointerDown = useCallback((event: ReactPointerEvent) => { + if (event.button !== 0) + return + + event.stopPropagation() + event.preventDefault() + + dragStateRef.current = { + offsetX: event.clientX - screenPosition.x, + offsetY: event.clientY - screenPosition.y, + startX: event.clientX, + startY: event.clientY, + hasMoved: false, + } + + setDragPosition(screenPosition) + setIsDragging(false) + + if (event.currentTarget.dataset.role !== 'comment-preview') + setShowPreview(false) + + if (event.currentTarget.setPointerCapture) + event.currentTarget.setPointerCapture(event.pointerId) + }, [screenPosition]) + + const handlePointerMove = useCallback((event: ReactPointerEvent) => { + const dragState = dragStateRef.current + if (!dragState) + return + + event.stopPropagation() + event.preventDefault() + + const nextX = event.clientX - dragState.offsetX + const nextY = event.clientY - dragState.offsetY + + if (!dragState.hasMoved) { + const distance = Math.hypot(event.clientX - dragState.startX, event.clientY - dragState.startY) + if (distance > 4) { + dragState.hasMoved = true + setIsDragging(true) + } + } + + setDragPosition({ x: nextX, y: nextY }) + }, []) + + const finishDrag = useCallback((event: ReactPointerEvent) => { + const dragState = dragStateRef.current + if (!dragState) + return false + + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) + event.currentTarget.releasePointerCapture(event.pointerId) + + dragStateRef.current = null + setDragPosition(null) + setIsDragging(false) + return dragState.hasMoved + }, []) + + const handlePointerUp = useCallback((event: ReactPointerEvent) => { + event.stopPropagation() + event.preventDefault() + + const finalScreenPosition = dragPosition ?? screenPosition + const didDrag = finishDrag(event) + + setShowPreview(false) + + if (didDrag) { + if (onPositionUpdate) { + const flowPosition = screenToFlowPosition({ + x: finalScreenPosition.x, + y: finalScreenPosition.y, + }) + onPositionUpdate(flowPosition) + } + } + else if (!isActive) { + onClick() + } + }, [dragPosition, finishDrag, isActive, onClick, onPositionUpdate, screenPosition, screenToFlowPosition]) + + const handlePointerCancel = useCallback((event: ReactPointerEvent) => { + event.stopPropagation() + event.preventDefault() + finishDrag(event) + }, [finishDrag]) + + const handleMouseEnter = useCallback(() => { + if (isActive || isDragging) + return + setShowPreview(true) + }, [isActive, isDragging]) + + const handleMouseLeave = useCallback(() => { + setShowPreview(false) + }, []) + // Calculate dynamic width based on number of participants const participantCount = comment.participants?.length || 0 const maxVisible = Math.min(3, participantCount) @@ -42,21 +149,29 @@ export const CommentIcon: FC = memo(({ comment, onClick, isAct 8 + avatarSize + Math.max(0, (showCount ? 2 : maxVisible - 1)) * (avatarSize - avatarSpacing) + 8, ) + const pointerEventHandlers = useMemo(() => ({ + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + onPointerCancel: handlePointerCancel, + }), [handlePointerCancel, handlePointerDown, handlePointerMove, handlePointerUp]) + return ( <>
setShowPreview(true)} - onMouseLeave={isActive ? undefined : () => setShowPreview(false)} + className={isActive ? (isDragging ? 'cursor-grabbing' : '') : isDragging ? 'cursor-grabbing' : 'cursor-pointer'} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} >
= memo(({ comment, onClick, isAct
setShowPreview(true)} onMouseLeave={() => setShowPreview(false)} > - + { + setShowPreview(false) + onClick() + }} />
)} @@ -103,6 +223,7 @@ export const CommentIcon: FC = memo(({ comment, onClick, isAct && prevProps.comment.position_y === nextProps.comment.position_y && prevProps.onClick === nextProps.onClick && prevProps.isActive === nextProps.isActive + && prevProps.onPositionUpdate === nextProps.onPositionUpdate ) }) diff --git a/web/app/components/workflow/hooks/use-workflow-comment.ts b/web/app/components/workflow/hooks/use-workflow-comment.ts index 2eb6ca0d90..64df63b2f2 100644 --- a/web/app/components/workflow/hooks/use-workflow-comment.ts +++ b/web/app/components/workflow/hooks/use-workflow-comment.ts @@ -4,7 +4,7 @@ import { useReactFlow } from 'reactflow' import { useStore } from '../store' import { ControlMode } from '../types' import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment' -import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, deleteWorkflowCommentReply, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment, updateWorkflowCommentReply } 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' export const useWorkflowComment = () => { @@ -229,6 +229,69 @@ export const useWorkflowComment = () => { } }, [appId, comments, handleCommentIconClick, loadComments, setActiveComment, setActiveCommentId, setActiveCommentLoading, setCommentDetailCache]) + const handleCommentPositionUpdate = useCallback(async (commentId: string, position: { x: number; y: number }) => { + if (!appId) return + + const targetComment = comments.find(c => c.id === commentId) + if (!targetComment) return + + const nextPosition = { + position_x: position.x, + position_y: position.y, + } + + const previousComments = comments + const updatedComments = comments.map(c => + c.id === commentId + ? { ...c, ...nextPosition } + : c, + ) + setComments(updatedComments) + + const cachedDetail = commentDetailCacheRef.current[commentId] + const updatedDetail = cachedDetail ? { ...cachedDetail, ...nextPosition } : null + if (updatedDetail) { + commentDetailCacheRef.current = { + ...commentDetailCacheRef.current, + [commentId]: updatedDetail, + } + setCommentDetailCache(commentDetailCacheRef.current) + + if (activeCommentIdRef.current === commentId) + setActiveComment(updatedDetail) + } + else if (activeComment?.id === commentId) { + setActiveComment({ ...activeComment, ...nextPosition }) + } + + try { + await updateWorkflowComment(appId, commentId, { + content: targetComment.content, + position_x: nextPosition.position_x, + position_y: nextPosition.position_y, + }) + collaborationManager.emitCommentsUpdate(appId) + } + catch (error) { + console.error('Failed to update comment position:', error) + setComments(previousComments) + + if (cachedDetail) { + commentDetailCacheRef.current = { + ...commentDetailCacheRef.current, + [commentId]: cachedDetail, + } + setCommentDetailCache(commentDetailCacheRef.current) + + if (activeCommentIdRef.current === commentId) + setActiveComment(cachedDetail) + } + else if (activeComment?.id === commentId) { + setActiveComment(activeComment) + } + } + }, [activeComment, appId, comments, setComments, setCommentDetailCache, setActiveComment]) + const handleCommentReply = useCallback(async (commentId: string, content: string, mentionedUserIds: string[] = []) => { if (!appId) return const trimmed = content.trim() @@ -336,6 +399,7 @@ export const useWorkflowComment = () => { handleCommentReply, handleCommentReplyUpdate, handleCommentReplyDelete, + handleCommentPositionUpdate, refreshActiveComment, handleCreateComment, loadComments, diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 006ba8186f..560fa87cd2 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -198,6 +198,7 @@ export const Workflow: FC = memo(({ handleCommentReply, handleCommentReplyUpdate, handleCommentReplyDelete, + handleCommentPositionUpdate, } = useWorkflowComment() const showUserComments = useStore(s => s.showUserComments) const showUserCursors = useStore(s => s.showUserCursors) @@ -461,6 +462,7 @@ export const Workflow: FC = memo(({ comment={comment} onClick={() => handleCommentIconClick(comment)} isActive={true} + onPositionUpdate={position => handleCommentPositionUpdate(comment.id, position)} /> = memo(({ key={comment.id} comment={comment} onClick={() => handleCommentIconClick(comment)} + onPositionUpdate={position => handleCommentPositionUpdate(comment.id, position)} /> ) : null })}