mirror of
https://github.com/langgenius/dify.git
synced 2026-04-27 19:27:23 +08:00
can mention user in the create comment
This commit is contained in:
parent
dd8577f832
commit
affbe7ccdb
@ -1,16 +1,19 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import Textarea from 'react-textarea-autosize'
|
import Textarea from 'react-textarea-autosize'
|
||||||
import { RiSendPlane2Fill } from '@remixicon/react'
|
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
import { useReactFlow, useViewport } from 'reactflow'
|
import { useReactFlow, useViewport } from 'reactflow'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
import Button from '@/app/components/base/button'
|
import Button from '@/app/components/base/button'
|
||||||
import Avatar from '@/app/components/base/avatar'
|
import Avatar from '@/app/components/base/avatar'
|
||||||
import { useAppContext } from '@/context/app-context'
|
import { useAppContext } from '@/context/app-context'
|
||||||
|
import { type UserProfile, fetchMentionableUsers } from '@/service/workflow-comment'
|
||||||
|
|
||||||
type CommentInputProps = {
|
type CommentInputProps = {
|
||||||
position: { x: number; y: number }
|
position: { x: number; y: number }
|
||||||
onSubmit: (content: string) => void
|
onSubmit: (content: string, mentionedUserIds: string[]) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,11 +23,35 @@ export const CommentInput: FC<CommentInputProps> = memo(({ position, onSubmit, o
|
|||||||
const { userProfile } = useAppContext()
|
const { userProfile } = useAppContext()
|
||||||
const { flowToScreenPosition } = useReactFlow()
|
const { flowToScreenPosition } = useReactFlow()
|
||||||
const viewport = useViewport()
|
const viewport = useViewport()
|
||||||
|
const params = useParams()
|
||||||
|
const appId = params.appId as string
|
||||||
|
|
||||||
|
const [mentionUsers, setMentionUsers] = useState<UserProfile[]>([])
|
||||||
|
const [showMentionDropdown, setShowMentionDropdown] = useState(false)
|
||||||
|
const [mentionQuery, setMentionQuery] = useState('')
|
||||||
|
const [mentionPosition, setMentionPosition] = useState(0)
|
||||||
|
const [selectedMentionIndex, setSelectedMentionIndex] = useState(0)
|
||||||
|
const [mentionedUserIds, setMentionedUserIds] = useState<string[]>([])
|
||||||
|
|
||||||
const screenPosition = useMemo(() => {
|
const screenPosition = useMemo(() => {
|
||||||
return flowToScreenPosition(position)
|
return flowToScreenPosition(position)
|
||||||
}, [position.x, position.y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition])
|
}, [position.x, position.y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition])
|
||||||
|
|
||||||
|
const loadMentionableUsers = useCallback(async () => {
|
||||||
|
if (!appId) return
|
||||||
|
try {
|
||||||
|
const users = await fetchMentionableUsers(appId)
|
||||||
|
setMentionUsers(users)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Failed to load mentionable users:', error)
|
||||||
|
}
|
||||||
|
}, [appId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMentionableUsers()
|
||||||
|
}, [loadMentionableUsers])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@ -40,27 +67,111 @@ export const CommentInput: FC<CommentInputProps> = memo(({ position, onSubmit, o
|
|||||||
}
|
}
|
||||||
}, [onCancel])
|
}, [onCancel])
|
||||||
|
|
||||||
|
const filteredMentionUsers = useMemo(() => {
|
||||||
|
if (!mentionQuery) return mentionUsers
|
||||||
|
return mentionUsers.filter(user =>
|
||||||
|
user.name.toLowerCase().includes(mentionQuery.toLowerCase())
|
||||||
|
|| user.email.toLowerCase().includes(mentionQuery.toLowerCase()),
|
||||||
|
)
|
||||||
|
}, [mentionUsers, mentionQuery])
|
||||||
|
|
||||||
|
const dropdownPosition = useMemo(() => {
|
||||||
|
if (!showMentionDropdown || !textareaRef.current)
|
||||||
|
return { x: 0, y: 0 }
|
||||||
|
|
||||||
|
const textareaRect = textareaRef.current.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
x: textareaRect.left,
|
||||||
|
y: textareaRect.bottom + 4,
|
||||||
|
}
|
||||||
|
}, [showMentionDropdown])
|
||||||
|
|
||||||
|
const handleContentChange = useCallback((value: string) => {
|
||||||
|
setContent(value)
|
||||||
|
|
||||||
|
// Delay getting cursor position to ensure the textarea has updated
|
||||||
|
setTimeout(() => {
|
||||||
|
const cursorPosition = textareaRef.current?.selectionStart || 0
|
||||||
|
const textBeforeCursor = value.slice(0, cursorPosition)
|
||||||
|
const mentionMatch = textBeforeCursor.match(/@(\w*)$/)
|
||||||
|
|
||||||
|
if (mentionMatch) {
|
||||||
|
setMentionQuery(mentionMatch[1])
|
||||||
|
setMentionPosition(cursorPosition - mentionMatch[0].length)
|
||||||
|
setShowMentionDropdown(true)
|
||||||
|
setSelectedMentionIndex(0)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setShowMentionDropdown(false)
|
||||||
|
}
|
||||||
|
}, 0)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const insertMention = useCallback((user: UserProfile) => {
|
||||||
|
const textarea = textareaRef.current
|
||||||
|
if (!textarea) return
|
||||||
|
|
||||||
|
const beforeMention = content.slice(0, mentionPosition)
|
||||||
|
const afterMention = content.slice(textarea.selectionStart || 0)
|
||||||
|
const newContent = `${beforeMention}@${user.name} ${afterMention}`
|
||||||
|
|
||||||
|
setContent(newContent)
|
||||||
|
setShowMentionDropdown(false)
|
||||||
|
setMentionedUserIds(prev => [...prev, user.id])
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const newCursorPos = mentionPosition + user.name.length + 2 // @ + name + space
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
textarea.focus()
|
||||||
|
}, 0)
|
||||||
|
}, [content, mentionPosition])
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
if (content.trim()) {
|
if (content.trim()) {
|
||||||
onSubmit(content.trim())
|
onSubmit(content.trim(), mentionedUserIds)
|
||||||
setContent('')
|
setContent('')
|
||||||
|
setMentionedUserIds([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error('Error in CommentInput handleSubmit:', error)
|
console.error('Error in CommentInput handleSubmit:', error)
|
||||||
}
|
}
|
||||||
}, [content, onSubmit])
|
}, [content, mentionedUserIds, onSubmit])
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (showMentionDropdown) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedMentionIndex(prev =>
|
||||||
|
prev < filteredMentionUsers.length - 1 ? prev + 1 : 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedMentionIndex(prev =>
|
||||||
|
prev > 0 ? prev - 1 : filteredMentionUsers.length - 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (filteredMentionUsers[selectedMentionIndex])
|
||||||
|
insertMention(filteredMentionUsers[selectedMentionIndex])
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
setShowMentionDropdown(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !showMentionDropdown) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSubmit()
|
handleSubmit()
|
||||||
}
|
}
|
||||||
else if (e.key === 'Escape') {
|
}, [showMentionDropdown, filteredMentionUsers, selectedMentionIndex, insertMention, handleSubmit])
|
||||||
onCancel()
|
|
||||||
}
|
|
||||||
}, [handleSubmit, onCancel])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -92,7 +203,7 @@ export const CommentInput: FC<CommentInputProps> = memo(({ position, onSubmit, o
|
|||||||
'relative z-10 flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md',
|
'relative z-10 flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur pb-[9px] shadow-md',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className='relative overflow-hidden px-[9px] pt-[9px]'>
|
<div className='relative px-[9px] pt-[9px]'>
|
||||||
<div className='relative'>
|
<div className='relative'>
|
||||||
<div className='relative flex w-full grow items-start'>
|
<div className='relative flex w-full grow items-start'>
|
||||||
<Textarea
|
<Textarea
|
||||||
@ -106,7 +217,7 @@ export const CommentInput: FC<CommentInputProps> = memo(({ position, onSubmit, o
|
|||||||
maxRows={4}
|
maxRows={4}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setContent(e.target.value)
|
handleContentChange(e.target.value)
|
||||||
}}
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
/>
|
||||||
@ -130,6 +241,43 @@ export const CommentInput: FC<CommentInputProps> = memo(({ position, onSubmit, o
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showMentionDropdown && filteredMentionUsers.length > 0 && typeof document !== 'undefined' && createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[9999] max-h-40 w-64 overflow-y-auto rounded-lg border border-components-panel-border bg-white shadow-lg"
|
||||||
|
style={{
|
||||||
|
left: dropdownPosition.x,
|
||||||
|
top: dropdownPosition.y,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredMentionUsers.map((user, index) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer items-center gap-2 p-2 hover:bg-state-base-hover',
|
||||||
|
index === selectedMentionIndex && 'bg-state-base-hover',
|
||||||
|
)}
|
||||||
|
onClick={() => insertMention(user)}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
avatar={user.avatar_url || null}
|
||||||
|
name={user.name}
|
||||||
|
size={24}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm font-medium text-text-primary">
|
||||||
|
{user.name}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-xs text-text-tertiary">
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -37,10 +37,10 @@ export const useWorkflowComment = () => {
|
|||||||
loadComments()
|
loadComments()
|
||||||
}, [loadComments])
|
}, [loadComments])
|
||||||
|
|
||||||
const handleCommentSubmit = useCallback(async (content: string) => {
|
const handleCommentSubmit = useCallback(async (content: string, mentionedUserIds: string[] = []) => {
|
||||||
if (!pendingComment) return
|
if (!pendingComment) return
|
||||||
|
|
||||||
console.log('Submitting comment:', { appId, pendingComment, content })
|
console.log('Submitting comment:', { appId, pendingComment, content, mentionedUserIds })
|
||||||
|
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
console.error('AppId is missing')
|
console.error('AppId is missing')
|
||||||
@ -52,7 +52,7 @@ export const useWorkflowComment = () => {
|
|||||||
position_x: pendingComment.x,
|
position_x: pendingComment.x,
|
||||||
position_y: pendingComment.y,
|
position_y: pendingComment.y,
|
||||||
content,
|
content,
|
||||||
mentioned_user_ids: [],
|
mentioned_user_ids: mentionedUserIds,
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('Comment created successfully:', newComment)
|
console.log('Comment created successfully:', newComment)
|
||||||
|
|||||||
@ -126,6 +126,6 @@ export const deleteWorkflowCommentReply = async (appId: string, commentId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const fetchMentionableUsers = async (appId: string) => {
|
export const fetchMentionableUsers = async (appId: string) => {
|
||||||
const response = await get<{ users: Array<{ id: string; name: string; email: string; avatar?: string }> }>(`apps/${appId}/workflow/comments/mention-users`)
|
const response = await get<{ users: Array<UserProfile> }>(`apps/${appId}/workflow/comments/mention-users`)
|
||||||
return response.users
|
return response.users
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user