can update comment position

This commit is contained in:
hjlarry 2025-10-05 10:17:04 +08:00
parent 659cbc05a9
commit 33d4c95470
3 changed files with 207 additions and 19 deletions

View File

@ -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<CommentIconProps> = memo(({ comment, onClick, isActive = false }) => {
const { flowToScreenPosition } = useReactFlow()
export const CommentIcon: FC<CommentIconProps> = 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<CommentIconProps> = 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<CommentIconProps> = 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 (
<>
<div
className="absolute z-10"
style={{
left: screenPosition.x,
top: screenPosition.y,
left: effectivePosition.x,
top: effectivePosition.y,
transform: 'translate(-50%, -50%)',
}}
data-role='comment-marker'
{...pointerEventHandlers}
>
<div
className={isActive ? '' : 'cursor-pointer'}
onClick={isActive ? undefined : onClick}
onMouseEnter={isActive ? undefined : () => setShowPreview(true)}
onMouseLeave={isActive ? undefined : () => setShowPreview(false)}
className={isActive ? (isDragging ? 'cursor-grabbing' : '') : isDragging ? 'cursor-grabbing' : 'cursor-pointer'}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div
className={'relative h-10 overflow-hidden rounded-br-full rounded-tl-full rounded-tr-full'}
@ -84,14 +199,19 @@ export const CommentIcon: FC<CommentIconProps> = memo(({ comment, onClick, isAct
<div
className="absolute z-20"
style={{
left: screenPosition.x - dynamicWidth / 2,
top: screenPosition.y + 20,
left: (dragPosition ?? screenPosition).x - dynamicWidth / 2,
top: (dragPosition ?? screenPosition).y + 20,
transform: 'translateY(-100%)',
}}
data-role='comment-preview'
{...pointerEventHandlers}
onMouseEnter={() => setShowPreview(true)}
onMouseLeave={() => setShowPreview(false)}
>
<CommentPreview comment={comment} onClick={handlePreviewClick} />
<CommentPreview comment={comment} onClick={() => {
setShowPreview(false)
onClick()
}} />
</div>
)}
</>
@ -103,6 +223,7 @@ export const CommentIcon: FC<CommentIconProps> = memo(({ comment, onClick, isAct
&& prevProps.comment.position_y === nextProps.comment.position_y
&& prevProps.onClick === nextProps.onClick
&& prevProps.isActive === nextProps.isActive
&& prevProps.onPositionUpdate === nextProps.onPositionUpdate
)
})

View File

@ -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,

View File

@ -198,6 +198,7 @@ export const Workflow: FC<WorkflowProps> = 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<WorkflowProps> = memo(({
comment={comment}
onClick={() => handleCommentIconClick(comment)}
isActive={true}
onPositionUpdate={position => handleCommentPositionUpdate(comment.id, position)}
/>
<CommentThread
key={`${comment.id}-thread`}
@ -486,6 +488,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
key={comment.id}
comment={comment}
onClick={() => handleCommentIconClick(comment)}
onPositionUpdate={position => handleCommentPositionUpdate(comment.id, position)}
/>
) : null
})}