click canvas icon display the active comment detail

This commit is contained in:
hjlarry 2025-09-17 09:01:16 +08:00
parent 89bed479e4
commit 4d3adec738
5 changed files with 217 additions and 23 deletions

View File

@ -1,3 +1,4 @@
export { CommentCursor } from './cursor' export { CommentCursor } from './cursor'
export { CommentInput } from './input' export { CommentInput } from './input'
export { CommentIcon } from './icon' export { CommentIcon } from './icon'
export { CommentThread } from './thread'

View File

@ -0,0 +1,116 @@
'use client'
import type { FC } from 'react'
import { memo, useMemo } from 'react'
import { useReactFlow, useViewport } from 'reactflow'
import { RiCloseLine } from '@remixicon/react'
import Avatar from '@/app/components/base/avatar'
import cn from '@/utils/classnames'
import { useFormatTimeFromNow } from '@/app/components/workflow/hooks'
import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment'
type CommentThreadProps = {
comment: WorkflowCommentDetail
loading?: boolean
onClose: () => void
}
const ThreadMessage: FC<{
authorName: string
avatarUrl?: string | null
createdAt: number
content: string
isReply?: boolean
}> = ({ authorName, avatarUrl, createdAt, content, isReply }) => {
const { formatTimeFromNow } = useFormatTimeFromNow()
return (
<div className={cn('flex gap-3', isReply && 'pl-9')}>
<div className='shrink-0'>
<Avatar
name={authorName}
avatar={avatarUrl || null}
size={32}
className='h-8 w-8'
/>
</div>
<div className='min-w-0 flex-1 pb-4 text-text-primary last:pb-0'>
<div className='flex flex-wrap items-center gap-x-2 gap-y-1'>
<span className='system-sm-medium text-text-primary'>{authorName}</span>
<span className='system-2xs-regular text-text-tertiary'>{formatTimeFromNow(createdAt * 1000)}</span>
</div>
<div className='system-sm-regular mt-1 whitespace-pre-wrap break-words text-text-secondary'>
{content}
</div>
</div>
</div>
)
}
const renderReply = (reply: WorkflowCommentDetailReply) => (
<ThreadMessage
key={reply.id}
authorName={reply.created_by_account?.name || 'User'}
avatarUrl={reply.created_by_account?.avatar_url || null}
createdAt={reply.created_at}
content={reply.content}
isReply
/>
)
export const CommentThread: FC<CommentThreadProps> = memo(({ comment, loading = false, onClose }) => {
const { flowToScreenPosition } = useReactFlow()
const viewport = useViewport()
const screenPosition = useMemo(() => {
return flowToScreenPosition({
x: comment.position_x,
y: comment.position_y,
})
}, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition])
return (
<div
className='absolute z-50 w-[360px] max-w-[360px]'
style={{
left: screenPosition.x,
top: screenPosition.y,
transform: 'translate(-50%, -100%) translateY(-24px)',
}}
>
<div className='relative rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl'>
<div className='flex items-center justify-between rounded-t-2xl px-4 py-3'>
<div className='system-2xs-semibold uppercase tracking-[0.08em] text-text-tertiary'>Comment</div>
<button
type='button'
className='flex h-6 w-6 items-center justify-center rounded-full text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
onClick={onClose}
aria-label='Close comment'
>
<RiCloseLine className='h-4 w-4' />
</button>
</div>
<div className='relative px-4 pb-4'>
<ThreadMessage
authorName={comment.created_by_account?.name || 'User'}
avatarUrl={comment.created_by_account?.avatar_url || null}
createdAt={comment.created_at}
content={comment.content}
/>
{comment.replies?.length > 0 && (
<div className='mt-2 flex flex-col gap-2'>
{comment.replies.map(renderReply)}
</div>
)}
</div>
{loading && (
<div className='bg-components-panel-bg/70 absolute inset-0 flex items-center justify-center rounded-2xl text-sm text-text-tertiary'>
Loading
</div>
)}
</div>
</div>
)
})
CommentThread.displayName = 'CommentThread'

View File

@ -1,10 +1,10 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { useReactFlow } from 'reactflow' import { useReactFlow } from 'reactflow'
import { useStore } from '../store' import { useStore } from '../store'
import { ControlMode } from '../types' import { ControlMode } from '../types'
import type { WorkflowCommentList } from '@/service/workflow-comment' import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
import { createWorkflowComment, fetchWorkflowComments } from '@/service/workflow-comment' import { createWorkflowComment, fetchWorkflowComment, fetchWorkflowComments } from '@/service/workflow-comment'
export const useWorkflowComment = () => { export const useWorkflowComment = () => {
const params = useParams() const params = useParams()
@ -14,8 +14,17 @@ export const useWorkflowComment = () => {
const setControlMode = useStore(s => s.setControlMode) const setControlMode = useStore(s => s.setControlMode)
const pendingComment = useStore(s => s.pendingComment) const pendingComment = useStore(s => s.pendingComment)
const setPendingComment = useStore(s => s.setPendingComment) const setPendingComment = useStore(s => s.setPendingComment)
const setActiveCommentId = useStore(s => s.setActiveCommentId)
const activeCommentId = useStore(s => s.activeCommentId)
const [comments, setComments] = useState<WorkflowCommentList[]>([]) const [comments, setComments] = useState<WorkflowCommentList[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [activeComment, setActiveComment] = useState<WorkflowCommentDetail | null>(null)
const [activeCommentLoading, setActiveCommentLoading] = useState(false)
const commentDetailCacheRef = useRef<Record<string, WorkflowCommentDetail>>({})
const activeCommentIdRef = useRef<string | null>(null)
useEffect(() => {
activeCommentIdRef.current = activeCommentId ?? null
}, [activeCommentId])
const loadComments = useCallback(async () => { const loadComments = useCallback(async () => {
if (!appId) return if (!appId) return
@ -72,17 +81,50 @@ export const useWorkflowComment = () => {
setControlMode(ControlMode.Pointer) setControlMode(ControlMode.Pointer)
}, [setControlMode, setPendingComment]) }, [setControlMode, setPendingComment])
const handleCommentIconClick = useCallback((comment: WorkflowCommentList) => { const handleCommentIconClick = useCallback(async (comment: WorkflowCommentList) => {
setPendingComment(null)
activeCommentIdRef.current = comment.id
setControlMode(ControlMode.Comment)
setActiveCommentId(comment.id)
const cachedDetail = commentDetailCacheRef.current[comment.id]
setActiveComment(cachedDetail || comment)
reactflow.setCenter(comment.position_x, comment.position_y, { zoom: 1, duration: 600 })
if (!appId) return
if (!cachedDetail)
setActiveCommentLoading(true)
try { try {
const store = useStore.getState() const detailResponse = await fetchWorkflowComment(appId, comment.id)
store.setControlMode(ControlMode.Comment) const detail = (detailResponse as any)?.data ?? detailResponse
store.setActiveCommentId(comment.id)
reactflow.setCenter(comment.position_x, comment.position_y, { zoom: 1, duration: 600 }) commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
[comment.id]: detail,
}
if (activeCommentIdRef.current === comment.id)
setActiveComment(detail)
} }
catch (e) { catch (e) {
console.error('Failed to open comments panel:', e) console.warn('Failed to load workflow comment detail', e)
} }
}, [reactflow]) finally {
setActiveCommentLoading(false)
}
}, [appId, reactflow, setPendingComment])
const handleActiveCommentClose = useCallback(() => {
setActiveComment(null)
setActiveCommentLoading(false)
setActiveCommentId(null)
setControlMode(ControlMode.Pointer)
activeCommentIdRef.current = null
}, [setActiveCommentId, setControlMode])
const handleCreateComment = useCallback((mousePosition: { pageX: number; pageY: number }) => { const handleCreateComment = useCallback((mousePosition: { pageX: number; pageY: number }) => {
if (controlMode === ControlMode.Comment) { if (controlMode === ControlMode.Comment) {
@ -101,9 +143,12 @@ export const useWorkflowComment = () => {
comments, comments,
loading, loading,
pendingComment, pendingComment,
activeComment,
activeCommentLoading,
handleCommentSubmit, handleCommentSubmit,
handleCommentCancel, handleCommentCancel,
handleCommentIconClick, handleCommentIconClick,
handleActiveCommentClose,
handleCreateComment, handleCreateComment,
loadComments, loadComments,
} }

View File

@ -69,7 +69,7 @@ import PanelContextmenu from './panel-contextmenu'
import NodeContextmenu from './node-contextmenu' import NodeContextmenu from './node-contextmenu'
import SyncingDataModal from './syncing-data-modal' import SyncingDataModal from './syncing-data-modal'
import LimitTips from './limit-tips' import LimitTips from './limit-tips'
import { CommentCursor, CommentIcon, CommentInput } from './comment' import { CommentCursor, CommentIcon, CommentInput, CommentThread } from './comment'
import { useWorkflowComment } from './hooks/use-workflow-comment' import { useWorkflowComment } from './hooks/use-workflow-comment'
import { import {
useStore, useStore,
@ -164,9 +164,12 @@ export const Workflow: FC<WorkflowProps> = memo(({
const { const {
comments, comments,
pendingComment, pendingComment,
activeComment,
activeCommentLoading,
handleCommentSubmit, handleCommentSubmit,
handleCommentCancel, handleCommentCancel,
handleCommentIconClick, handleCommentIconClick,
handleActiveCommentClose,
} = useWorkflowComment() } = useWorkflowComment()
const mousePosition = useStore(s => s.mousePosition) const mousePosition = useStore(s => s.mousePosition)
@ -351,13 +354,28 @@ export const Workflow: FC<WorkflowProps> = memo(({
onCancel={handleCommentCancel} onCancel={handleCommentCancel}
/> />
)} )}
{comments.map(comment => ( {comments.map((comment) => {
<CommentIcon const isActive = activeComment?.id === comment.id
key={comment.id}
comment={comment} if (isActive && activeComment) {
onClick={() => handleCommentIconClick(comment)} return (
/> <CommentThread
))} key={comment.id}
comment={activeComment}
loading={activeCommentLoading}
onClose={handleActiveCommentClose}
/>
)
}
return (
<CommentIcon
key={comment.id}
comment={comment}
onClick={() => handleCommentIconClick(comment)}
/>
)
})}
{children} {children}
<ReactFlow <ReactFlow
nodeTypes={nodeTypes} nodeTypes={nodeTypes}

View File

@ -26,6 +26,20 @@ export type WorkflowCommentList = {
participants: UserProfile[] participants: UserProfile[]
} }
export type WorkflowCommentDetailMention = {
mentioned_user_id: string
mentioned_user_account?: UserProfile | null
reply_id: string | null
}
export type WorkflowCommentDetailReply = {
id: string
content: string
created_by: string
created_by_account?: UserProfile | null
created_at: number
}
export type WorkflowCommentDetail = { export type WorkflowCommentDetail = {
id: string id: string
position_x: number position_x: number
@ -33,14 +47,14 @@ export type WorkflowCommentDetail = {
content: string content: string
created_by: string created_by: string
created_by_account: UserProfile created_by_account: UserProfile
created_at: string created_at: number
updated_at: string updated_at: number
resolved: boolean resolved: boolean
resolved_by?: string resolved_by?: string
resolved_by_account?: UserProfile resolved_by_account?: UserProfile
resolved_at?: string resolved_at?: number
replies: [] replies: WorkflowCommentDetailReply[]
mentions: [] mentions: WorkflowCommentDetailMention[]
} }
export type WorkflowCommentCreateRes = { export type WorkflowCommentCreateRes = {