feat: mouse right click can add new comment

This commit is contained in:
hjlarry 2026-04-10 10:07:28 +08:00
parent 59e752dcd3
commit 205d771bfa
32 changed files with 172 additions and 15 deletions

View File

@ -7,14 +7,25 @@ const CommentManager = () => {
const { handleCreateComment, handleCommentCancel } = useWorkflowComment()
useEventListener('click', (e) => {
const { controlMode, mousePosition, pendingComment } = workflowStore.getState()
const { controlMode, mousePosition, pendingComment, isCommentPlacing } = workflowStore.getState()
const target = e.target as HTMLElement
const isInDropdown = target.closest('[data-mention-dropdown]')
const isInCommentInput = target.closest('[data-comment-input]')
const isOnCanvasPane = target.closest('.react-flow__pane')
if (isCommentPlacing) {
if (!isInDropdown && !isInCommentInput && isOnCanvasPane) {
e.preventDefault()
e.stopPropagation()
workflowStore.setState({
pendingComment: mousePosition,
isCommentPlacing: false,
})
}
return
}
if (controlMode === 'comment') {
const target = e.target as HTMLElement
const isInDropdown = target.closest('[data-mention-dropdown]')
const isInCommentInput = target.closest('[data-comment-input]')
const isOnCanvasPane = target.closest('.react-flow__pane')
// Only when clicking on the React Flow canvas pane (background),
// and not inside comment input or its dropdown
if (!isInDropdown && !isInCommentInput && isOnCanvasPane) {
@ -28,6 +39,16 @@ const CommentManager = () => {
}
})
useEventListener('contextmenu', () => {
const { isCommentPlacing } = workflowStore.getState()
if (!isCommentPlacing)
return
workflowStore.setState({
isCommentPlacing: false,
isCommentQuickAdd: false,
})
})
return null
}

View File

@ -8,6 +8,7 @@ type MentionInputProps = {
onChange: (value: string) => void
onSubmit: (content: string, mentionedUserIds: string[]) => void
placeholder?: string
disabled?: boolean
autoFocus?: boolean
className?: string
}
@ -70,6 +71,7 @@ describe('CommentInput', () => {
expect(mentionInputProps?.placeholder).toBe('workflow.comments.placeholder.add')
expect(mentionInputProps?.autoFocus).toBe(true)
expect(mentionInputProps?.disabled).toBe(false)
})
it('calls onCancel when Escape is pressed', () => {

View File

@ -10,6 +10,8 @@ type CommentInputProps = {
position: { x: number, y: number }
onSubmit: (content: string, mentionedUserIds: string[]) => void
onCancel: () => void
autoFocus?: boolean
disabled?: boolean
onPositionChange?: (position: {
pageX: number
pageY: number
@ -18,7 +20,14 @@ type CommentInputProps = {
}) => void
}
export const CommentInput: FC<CommentInputProps> = memo(({ position, onSubmit, onCancel, onPositionChange }) => {
export const CommentInput: FC<CommentInputProps> = memo(({
position,
onSubmit,
onCancel,
autoFocus = true,
disabled = false,
onPositionChange,
}) => {
const [content, setContent] = useState('')
const { t } = useTranslation()
const { userProfile } = useAppContext()
@ -124,7 +133,10 @@ export const CommentInput: FC<CommentInputProps> = memo(({ position, onSubmit, o
return (
<div
className="absolute z-[60] w-96"
className={cn(
'absolute z-[60] w-96',
disabled && 'pointer-events-none opacity-80',
)}
style={{
left: position.x,
top: position.y,
@ -162,7 +174,8 @@ export const CommentInput: FC<CommentInputProps> = memo(({ position, onSubmit, o
onChange={setContent}
onSubmit={handleMentionSubmit}
placeholder={t('comments.placeholder.add', { ns: 'workflow' })}
autoFocus
autoFocus={autoFocus}
disabled={disabled}
className="relative"
/>
</div>

View File

@ -5,6 +5,7 @@ import { CommentCursor } from './cursor'
const mockState = {
controlMode: ControlMode.Pointer,
isCommentPlacing: false,
mousePosition: {
elementX: 10,
elementY: 20,
@ -34,6 +35,7 @@ describe('CommentCursor', () => {
it('renders at current mouse position when in comment mode', () => {
mockState.controlMode = ControlMode.Comment
mockState.isCommentPlacing = false
render(<CommentCursor />)
@ -42,4 +44,13 @@ describe('CommentCursor', () => {
expect(container).toHaveStyle({ left: '10px', top: '20px' })
})
it('renders nothing when comment is in placing mode', () => {
mockState.controlMode = ControlMode.Comment
mockState.isCommentPlacing = true
render(<CommentCursor />)
expect(screen.queryByTestId('comment-icon')).not.toBeInTheDocument()
})
})

View File

@ -7,8 +7,9 @@ import { ControlMode } from '../types'
export const CommentCursor: FC = memo(() => {
const controlMode = useStore(s => s.controlMode)
const mousePosition = useStore(s => s.mousePosition)
const isCommentPlacing = useStore(s => s.isCommentPlacing)
if (controlMode !== ControlMode.Comment)
if (controlMode !== ControlMode.Comment || isCommentPlacing)
return null
return (

View File

@ -25,6 +25,9 @@ export const useWorkflowComment = () => {
const controlMode = useStore(s => s.controlMode)
const pendingComment = useStore(s => s.pendingComment)
const setPendingComment = useStore(s => s.setPendingComment)
const isCommentQuickAdd = useStore(s => s.isCommentQuickAdd)
const setCommentQuickAdd = useStore(s => s.setCommentQuickAdd)
const isCommentPlacing = useStore(s => s.isCommentPlacing)
const setActiveCommentId = useStore(s => s.setActiveCommentId)
const activeCommentId = useStore(s => s.activeCommentId)
const comments = useStore(s => s.comments)
@ -204,21 +207,29 @@ export const useWorkflowComment = () => {
collaborationManager.emitCommentsUpdate(appId)
setPendingComment(null)
setCommentQuickAdd(false)
}
catch (error) {
console.error('Failed to create comment:', error)
setPendingComment(null)
setCommentQuickAdd(false)
}
}, [appId, pendingComment, setPendingComment, reactflow, comments, setComments, userProfile, setCommentDetailCache, mentionableUsers])
}, [appId, pendingComment, setPendingComment, setCommentQuickAdd, reactflow, comments, setComments, userProfile, setCommentDetailCache, mentionableUsers])
const handleCommentCancel = useCallback(() => {
setPendingComment(null)
}, [setPendingComment])
setCommentQuickAdd(false)
}, [setPendingComment, setCommentQuickAdd])
useEffect(() => {
if (controlMode !== ControlMode.Comment)
if (controlMode !== ControlMode.Comment && !isCommentQuickAdd)
setPendingComment(null)
}, [controlMode, setPendingComment])
}, [controlMode, isCommentQuickAdd, setPendingComment])
useEffect(() => {
if (!pendingComment && !isCommentPlacing && isCommentQuickAdd)
setCommentQuickAdd(false)
}, [isCommentPlacing, isCommentQuickAdd, pendingComment, setCommentQuickAdd])
const handleCommentIconClick = useCallback(async (comment: WorkflowCommentList) => {
setPendingComment(null)

View File

@ -154,6 +154,37 @@ export type WorkflowProps = {
myUserId?: string | null
onlineUsers?: OnlineUser[]
}
const CommentPlacementPreview = memo(({
onSubmit,
onCancel,
}: {
onSubmit: (content: string, mentionedUserIds: string[]) => void
onCancel: () => void
}) => {
const isCommentPlacing = useStore(s => s.isCommentPlacing)
const pendingComment = useStore(s => s.pendingComment)
const mousePosition = useStore(s => s.mousePosition)
if (!isCommentPlacing || pendingComment)
return null
return (
<CommentInput
position={{
x: mousePosition.elementX,
y: mousePosition.elementY,
}}
onSubmit={onSubmit}
onCancel={onCancel}
autoFocus={false}
disabled
/>
)
})
CommentPlacementPreview.displayName = 'CommentPlacementPreview'
export const Workflow: FC<WorkflowProps> = memo(({
nodes: originalNodes,
edges: originalEdges,
@ -264,8 +295,11 @@ export const Workflow: FC<WorkflowProps> = memo(({
const showUserCursors = useStore(s => s.showUserCursors)
const showResolvedComments = useStore(s => s.showResolvedComments)
const isCommentPreviewHovering = useStore(s => s.isCommentPreviewHovering)
const isCommentPlacing = useStore(s => s.isCommentPlacing)
const setCommentPlacing = useStore(s => s.setCommentPlacing)
const setCommentQuickAdd = useStore(s => s.setCommentQuickAdd)
const setPendingCommentState = useStore(s => s.setPendingComment)
const isCommentInputActive = Boolean(pendingComment)
const isCommentInputActive = Boolean(pendingComment) || isCommentPlacing
const { t } = useTranslation()
const visibleComments = useMemo(() => {
if (showResolvedComments)
@ -320,6 +354,12 @@ export const Workflow: FC<WorkflowProps> = memo(({
setPendingCommentState(position)
}, [setPendingCommentState])
const handleCommentPlacementCancel = useCallback(() => {
setPendingCommentState(null)
setCommentPlacing(false)
setCommentQuickAdd(false)
}, [setCommentPlacing, setCommentQuickAdd, setPendingCommentState])
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const handleSyncWorkflowDraftWhenPageClose = useCallback(() => {
if (document.visibilityState === 'hidden') {
@ -571,6 +611,10 @@ export const Workflow: FC<WorkflowProps> = memo(({
{controlMode === ControlMode.Comment && isMouseOverCanvas && (
<CommentCursor />
)}
<CommentPlacementPreview
onSubmit={handleCommentSubmit}
onCancel={handleCommentPlacementCancel}
/>
{pendingComment && (
<CommentInput
position={{

View File

@ -10,6 +10,7 @@ import {
useDSL,
useNodesInteractions,
usePanelInteractions,
useWorkflowMoveMode,
useWorkflowStartRun,
} from './hooks'
import AddBlock from './operator/add-block'
@ -23,10 +24,14 @@ const PanelContextmenu = () => {
const panelMenu = useStore(s => s.panelMenu)
const clipboardElements = useStore(s => s.clipboardElements)
const setShowImportDSLModal = useStore(s => s.setShowImportDSLModal)
const pendingComment = useStore(s => s.pendingComment)
const setCommentPlacing = useStore(s => s.setCommentPlacing)
const setCommentQuickAdd = useStore(s => s.setCommentQuickAdd)
const { handleNodesPaste } = useNodesInteractions()
const { handlePaneContextmenuCancel } = usePanelInteractions()
const { handleStartWorkflowRun } = useWorkflowStartRun()
const { handleAddNote } = useOperator()
const { isCommentModeAvailable } = useWorkflowMoveMode()
const { exportCheck } = useDSL()
useClickAway(() => {
@ -73,6 +78,24 @@ const PanelContextmenu = () => {
>
{t('nodes.note.addNote', { ns: 'workflow' })}
</div>
{isCommentModeAvailable && (
<div
className={cn(
'flex h-8 items-center justify-between rounded-lg px-3 text-sm text-text-secondary',
pendingComment ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:bg-state-base-hover',
)}
onClick={(e) => {
e.stopPropagation()
if (pendingComment)
return
setCommentQuickAdd(true)
setCommentPlacing(true)
handlePaneContextmenuCancel()
}}
>
{t('comments.actions.addComment', { ns: 'workflow' })}
</div>
)}
<div
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
onClick={() => {

View File

@ -43,6 +43,10 @@ export type WorkflowSliceShape = {
setControlMode: (controlMode: WorkflowSliceShape['controlMode']) => void
pendingComment: MousePosition | null
setPendingComment: (pendingComment: WorkflowSliceShape['pendingComment']) => void
isCommentPlacing: boolean
setCommentPlacing: (isCommentPlacing: boolean) => void
isCommentQuickAdd: boolean
setCommentQuickAdd: (isCommentQuickAdd: boolean) => void
isCommentPreviewHovering: boolean
setCommentPreviewHovering: (hovering: boolean) => void
mousePosition: { pageX: number, pageY: number, elementX: number, elementY: number }
@ -89,6 +93,10 @@ export const createWorkflowSlice: StateCreator<WorkflowSliceShape> = set => ({
},
pendingComment: null,
setPendingComment: pendingComment => set(() => ({ pendingComment })),
isCommentPlacing: false,
setCommentPlacing: isCommentPlacing => set(() => ({ isCommentPlacing })),
isCommentQuickAdd: false,
setCommentQuickAdd: isCommentQuickAdd => set(() => ({ isCommentQuickAdd })),
mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 },
setMousePosition: mousePosition => set(() => ({ mousePosition })),
isCommentPreviewHovering: false,

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "تم التحديث في ",
"comments.actions.deleteReply": "حذف الرد",
"comments.actions.editReply": "تعديل الرد",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "إغلاق التعليق",
"comments.aria.deleteComment": "حذف المناقشة",
"comments.aria.nextComment": "التعليق التالي",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Aktualisiert am ",
"comments.actions.deleteReply": "Antwort löschen",
"comments.actions.editReply": "Antwort bearbeiten",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Kommentar schließen",
"comments.aria.deleteComment": "Kommentar löschen",
"comments.aria.nextComment": "Nächster Kommentar",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Updated at ",
"comments.actions.deleteReply": "Delete reply",
"comments.actions.editReply": "Edit reply",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Close comment",
"comments.aria.deleteComment": "Delete thread",
"comments.aria.nextComment": "Next comment",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Actualizado el ",
"comments.actions.deleteReply": "Eliminar respuesta",
"comments.actions.editReply": "Editar respuesta",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Cerrar comentario",
"comments.aria.deleteComment": "Eliminar comentario",
"comments.aria.nextComment": "Comentario siguiente",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "به‌روزرسانی شده در ",
"comments.actions.deleteReply": "حذف پاسخ",
"comments.actions.editReply": "ویرایش پاسخ",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "بستن دیدگاه",
"comments.aria.deleteComment": "حذف دیدگاه",
"comments.aria.nextComment": "دیدگاه بعدی",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Mis à jour le ",
"comments.actions.deleteReply": "Supprimer la réponse",
"comments.actions.editReply": "Modifier la réponse",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Fermer le commentaire",
"comments.aria.deleteComment": "Supprimer le commentaire",
"comments.aria.nextComment": "Commentaire suivant",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "अपडेट किया गया ",
"comments.actions.deleteReply": "जवाब हटाएं",
"comments.actions.editReply": "जवाब संपादित करें",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "टिप्पणी बंद करें",
"comments.aria.deleteComment": "टिप्पणी हटाएं",
"comments.aria.nextComment": "अगली टिप्पणी",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Diperbarui pada",
"comments.actions.deleteReply": "Hapus balasan",
"comments.actions.editReply": "Edit balasan",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Tutup komentar",
"comments.aria.deleteComment": "Hapus komentar",
"comments.aria.nextComment": "Komentar berikutnya",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Aggiornato il ",
"comments.actions.deleteReply": "Elimina risposta",
"comments.actions.editReply": "Modifica risposta",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Chiudi commento",
"comments.aria.deleteComment": "Elimina commento",
"comments.aria.nextComment": "Commento successivo",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "最終更新:",
"comments.actions.deleteReply": "返信を削除",
"comments.actions.editReply": "返信を編集",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "コメントを閉じる",
"comments.aria.deleteComment": "スレッドを削除",
"comments.aria.nextComment": "次のコメント",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "업데이트 시간: ",
"comments.actions.deleteReply": "답글 삭제",
"comments.actions.editReply": "답글 편집",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "댓글 닫기",
"comments.aria.deleteComment": "댓글 삭제",
"comments.aria.nextComment": "다음 댓글",

View File

@ -109,6 +109,7 @@
"chatVariable.panelTitle": "Conversation Variables",
"chatVariable.storedContent": "Stored content",
"chatVariable.updatedAt": "Updated at ",
"comments.actions.addComment": "Add Comment",
"common.ImageUploadLegacyTip": "You can now create file type variables in the start form. We will no longer support the image upload feature in the future. ",
"common.accessAPIReference": "Access API Reference",
"common.addBlock": "Add Node",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Zaktualizowano ",
"comments.actions.deleteReply": "Delete reply",
"comments.actions.editReply": "Edit reply",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Close comment",
"comments.aria.deleteComment": "Delete thread",
"comments.aria.nextComment": "Next comment",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Atualizado em ",
"comments.actions.deleteReply": "Delete reply",
"comments.actions.editReply": "Edit reply",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Close comment",
"comments.aria.deleteComment": "Delete thread",
"comments.aria.nextComment": "Next comment",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Actualizat la ",
"comments.actions.deleteReply": "Delete reply",
"comments.actions.editReply": "Edit reply",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Close comment",
"comments.aria.deleteComment": "Delete thread",
"comments.aria.nextComment": "Next comment",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Обновлено в ",
"comments.actions.deleteReply": "Delete reply",
"comments.actions.editReply": "Edit reply",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Close comment",
"comments.aria.deleteComment": "Delete thread",
"comments.aria.nextComment": "Next comment",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Posodobljeno ob",
"comments.actions.deleteReply": "Delete reply",
"comments.actions.editReply": "Edit reply",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Close comment",
"comments.aria.deleteComment": "Delete thread",
"comments.aria.nextComment": "Next comment",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "อัพเดทเมื่อ",
"comments.actions.deleteReply": "Delete reply",
"comments.actions.editReply": "Edit reply",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Close comment",
"comments.aria.deleteComment": "Delete thread",
"comments.aria.nextComment": "Next comment",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Güncellenme zamanı: ",
"comments.actions.deleteReply": "Delete reply",
"comments.actions.editReply": "Edit reply",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Close comment",
"comments.aria.deleteComment": "Delete thread",
"comments.aria.nextComment": "Next comment",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Оновлено ",
"comments.actions.deleteReply": "Delete reply",
"comments.actions.editReply": "Edit reply",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Close comment",
"comments.aria.deleteComment": "Delete thread",
"comments.aria.nextComment": "Next comment",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "Cập nhật lúc ",
"comments.actions.deleteReply": "Delete reply",
"comments.actions.editReply": "Edit reply",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "Close comment",
"comments.aria.deleteComment": "Delete thread",
"comments.aria.nextComment": "Next comment",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "更新时间 ",
"comments.actions.deleteReply": "删除回复",
"comments.actions.editReply": "编辑回复",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "关闭评论",
"comments.aria.deleteComment": "删除讨论",
"comments.aria.nextComment": "下一条评论",

View File

@ -111,6 +111,7 @@
"chatVariable.updatedAt": "更新於 ",
"comments.actions.deleteReply": "刪除回覆",
"comments.actions.editReply": "編輯回覆",
"comments.actions.addComment": "Add Comment",
"comments.aria.closeComment": "關閉評論",
"comments.aria.deleteComment": "刪除評論",
"comments.aria.nextComment": "下一則評論",