mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 18:58:35 +08:00
682 lines
23 KiB
TypeScript
682 lines
23 KiB
TypeScript
'use client'
|
|
|
|
import type { ReactNode } from 'react'
|
|
import type { UserProfile } from '@/app/components/workflow/comment/types'
|
|
import { Avatar } from '@langgenius/dify-ui/avatar'
|
|
import { Button } from '@langgenius/dify-ui/button'
|
|
import { cn } from '@langgenius/dify-ui/cn'
|
|
import { RiArrowUpLine, RiAtLine, RiLoader2Line } from '@remixicon/react'
|
|
import {
|
|
forwardRef,
|
|
memo,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { useTranslation } from 'react-i18next'
|
|
import Textarea from 'react-textarea-autosize'
|
|
import EnterKey from '@/app/components/base/icons/src/public/common/EnterKey'
|
|
import { useParams } from '@/next/navigation'
|
|
import { consoleClient } from '@/service/client'
|
|
import { useStore, useWorkflowStore } from '../store'
|
|
|
|
type MentionInputProps = {
|
|
value: string
|
|
onChange: (value: string) => void
|
|
onSubmit: (content: string, mentionedUserIds: string[]) => void
|
|
onCancel?: () => void
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
loading?: boolean
|
|
className?: string
|
|
isEditing?: boolean
|
|
autoFocus?: boolean
|
|
}
|
|
|
|
const EMPTY_USERS: UserProfile[] = []
|
|
|
|
const MentionInputInner = forwardRef<HTMLTextAreaElement, MentionInputProps>(
|
|
(
|
|
{
|
|
value,
|
|
onChange,
|
|
onSubmit,
|
|
onCancel,
|
|
placeholder,
|
|
disabled = false,
|
|
loading = false,
|
|
className,
|
|
isEditing = false,
|
|
autoFocus = false,
|
|
},
|
|
forwardedRef,
|
|
) => {
|
|
const params = useParams()
|
|
const { t } = useTranslation()
|
|
const appId = params.appId as string
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
const highlightContentRef = useRef<HTMLDivElement>(null)
|
|
const actionContainerRef = useRef<HTMLDivElement | null>(null)
|
|
const actionRightRef = useRef<HTMLDivElement | null>(null)
|
|
const baseTextareaHeightRef = useRef<number | null>(null)
|
|
|
|
// Expose textarea ref to parent component
|
|
useImperativeHandle(forwardedRef, () => textareaRef.current!, [])
|
|
|
|
const workflowStore = useWorkflowStore()
|
|
const mentionUsersFromStore = useStore((state) =>
|
|
appId ? state.mentionableUsersCache[appId] : undefined,
|
|
)
|
|
const mentionUsers = useMemo(
|
|
() => mentionUsersFromStore ?? EMPTY_USERS,
|
|
[mentionUsersFromStore],
|
|
)
|
|
|
|
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 resolvedPlaceholder =
|
|
placeholder ?? t(($) => $['comments.placeholder.add'], { ns: 'workflow' })
|
|
const BASE_PADDING = 4
|
|
const [shouldReserveButtonGap, setShouldReserveButtonGap] = useState(isEditing)
|
|
const [shouldReserveHorizontalSpace, setShouldReserveHorizontalSpace] = useState(
|
|
() => !isEditing,
|
|
)
|
|
const [paddingRight, setPaddingRight] = useState(() => BASE_PADDING + (isEditing ? 0 : 48))
|
|
const [paddingBottom, setPaddingBottom] = useState(() => BASE_PADDING + (isEditing ? 32 : 0))
|
|
|
|
const mentionNameList = useMemo(() => {
|
|
const names = mentionUsers
|
|
.map((user) => user.name?.trim())
|
|
.filter((name): name is string => Boolean(name))
|
|
|
|
const uniqueNames = Array.from(new Set(names))
|
|
uniqueNames.sort((a, b) => b.length - a.length)
|
|
return uniqueNames
|
|
}, [mentionUsers])
|
|
|
|
const highlightedValue = useMemo<ReactNode>(() => {
|
|
if (!value) return ''
|
|
|
|
if (mentionNameList.length === 0) return value
|
|
|
|
const segments: ReactNode[] = []
|
|
let cursor = 0
|
|
let hasMention = false
|
|
|
|
while (cursor < value.length) {
|
|
let nextMatchStart = -1
|
|
let matchedName = ''
|
|
|
|
for (const name of mentionNameList) {
|
|
const searchStart = value.indexOf(`@${name}`, cursor)
|
|
if (searchStart === -1) continue
|
|
|
|
const previousChar = searchStart > 0 ? value[searchStart - 1] : ''
|
|
if (searchStart > 0 && !/\s/.test(previousChar!)) continue
|
|
|
|
if (
|
|
nextMatchStart === -1 ||
|
|
searchStart < nextMatchStart ||
|
|
(searchStart === nextMatchStart && name.length > matchedName.length)
|
|
) {
|
|
nextMatchStart = searchStart
|
|
matchedName = name
|
|
}
|
|
}
|
|
|
|
if (nextMatchStart === -1) break
|
|
|
|
if (nextMatchStart > cursor)
|
|
segments.push(<span key={`text-${cursor}`}>{value.slice(cursor, nextMatchStart)}</span>)
|
|
|
|
const mentionEnd = nextMatchStart + matchedName.length + 1
|
|
segments.push(
|
|
<span key={`mention-${nextMatchStart}`} className="text-primary-600">
|
|
{value.slice(nextMatchStart, mentionEnd)}
|
|
</span>,
|
|
)
|
|
|
|
hasMention = true
|
|
cursor = mentionEnd
|
|
}
|
|
|
|
if (!hasMention) return value
|
|
|
|
if (cursor < value.length)
|
|
segments.push(<span key={`text-${cursor}`}>{value.slice(cursor)}</span>)
|
|
|
|
return segments
|
|
}, [value, mentionNameList])
|
|
|
|
const loadMentionableUsers = useCallback(async () => {
|
|
if (!appId) return
|
|
|
|
const state = workflowStore.getState()
|
|
if (state.mentionableUsersCache[appId] !== undefined) return
|
|
|
|
if (state.mentionableUsersLoading[appId]) return
|
|
|
|
state.setMentionableUsersLoading(appId, true)
|
|
try {
|
|
const response = await consoleClient.apps.byAppId.workflow.comments.mentionUsers.get({
|
|
params: { app_id: appId },
|
|
})
|
|
workflowStore.getState().setMentionableUsersCache(appId, response.users)
|
|
} catch (error) {
|
|
console.error('Failed to load mentionable users:', error)
|
|
} finally {
|
|
workflowStore.getState().setMentionableUsersLoading(appId, false)
|
|
}
|
|
}, [appId, workflowStore])
|
|
|
|
useEffect(() => {
|
|
loadMentionableUsers()
|
|
}, [loadMentionableUsers])
|
|
const syncHighlightScroll = useCallback(() => {
|
|
const textarea = textareaRef.current
|
|
const highlightContent = highlightContentRef.current
|
|
if (!textarea || !highlightContent) return
|
|
|
|
const { scrollTop, scrollLeft } = textarea
|
|
highlightContent.style.transform = `translate(${-scrollLeft}px, ${-scrollTop}px)`
|
|
}, [])
|
|
|
|
const evaluateContentLayout = useCallback(() => {
|
|
const textarea = textareaRef.current
|
|
if (!textarea) return
|
|
|
|
const extraBottom = Math.max(0, paddingBottom - BASE_PADDING)
|
|
const effectiveClientHeight = textarea.clientHeight - extraBottom
|
|
|
|
if (baseTextareaHeightRef.current === null)
|
|
baseTextareaHeightRef.current = effectiveClientHeight
|
|
|
|
const baseHeight = baseTextareaHeightRef.current ?? effectiveClientHeight
|
|
const hasMultiline = effectiveClientHeight > baseHeight + 1
|
|
const shouldReserveVertical = isEditing ? true : hasMultiline
|
|
|
|
setShouldReserveButtonGap(shouldReserveVertical)
|
|
setShouldReserveHorizontalSpace(!hasMultiline)
|
|
}, [isEditing, paddingBottom])
|
|
|
|
const updateLayoutPadding = useCallback(() => {
|
|
const actionEl = actionContainerRef.current
|
|
const rect = actionEl?.getBoundingClientRect()
|
|
const rightRect = actionRightRef.current?.getBoundingClientRect()
|
|
let actionWidth = 0
|
|
if (rightRect) actionWidth = Math.ceil(rightRect.width)
|
|
else if (rect) actionWidth = Math.ceil(rect.width)
|
|
|
|
const actionHeight = rect ? Math.ceil(rect.height) : 0
|
|
const fallbackWidth = Math.max(0, paddingRight - BASE_PADDING)
|
|
const fallbackHeight = Math.max(0, paddingBottom - BASE_PADDING)
|
|
const effectiveWidth = actionWidth > 0 ? actionWidth : fallbackWidth
|
|
const effectiveHeight = actionHeight > 0 ? actionHeight : fallbackHeight
|
|
|
|
const nextRight = BASE_PADDING + (shouldReserveHorizontalSpace ? effectiveWidth : 0)
|
|
const nextBottom = BASE_PADDING + (shouldReserveButtonGap ? effectiveHeight : 0)
|
|
|
|
setPaddingRight((prev) => (prev === nextRight ? prev : nextRight))
|
|
setPaddingBottom((prev) => (prev === nextBottom ? prev : nextBottom))
|
|
}, [shouldReserveButtonGap, shouldReserveHorizontalSpace, paddingRight, paddingBottom])
|
|
|
|
const setActionContainerRef = useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
actionContainerRef.current = node
|
|
|
|
if (!isEditing) actionRightRef.current = node
|
|
else if (!node) actionRightRef.current = null
|
|
|
|
if (node && typeof window !== 'undefined')
|
|
window.requestAnimationFrame(() => updateLayoutPadding())
|
|
},
|
|
[isEditing, updateLayoutPadding],
|
|
)
|
|
|
|
const setActionRightRef = useCallback(
|
|
(node: HTMLDivElement | null) => {
|
|
actionRightRef.current = node
|
|
|
|
if (node && typeof window !== 'undefined')
|
|
window.requestAnimationFrame(() => updateLayoutPadding())
|
|
},
|
|
[updateLayoutPadding],
|
|
)
|
|
|
|
useLayoutEffect(() => {
|
|
syncHighlightScroll()
|
|
}, [value, syncHighlightScroll])
|
|
|
|
useLayoutEffect(() => {
|
|
Promise.resolve().then(() => {
|
|
evaluateContentLayout()
|
|
})
|
|
}, [value, evaluateContentLayout])
|
|
|
|
useLayoutEffect(() => {
|
|
Promise.resolve().then(() => {
|
|
updateLayoutPadding()
|
|
})
|
|
}, [updateLayoutPadding, isEditing, shouldReserveButtonGap])
|
|
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
evaluateContentLayout()
|
|
updateLayoutPadding()
|
|
}
|
|
|
|
window.addEventListener('resize', handleResize)
|
|
return () => window.removeEventListener('resize', handleResize)
|
|
}, [evaluateContentLayout, updateLayoutPadding])
|
|
|
|
useEffect(() => {
|
|
Promise.resolve().then(() => {
|
|
baseTextareaHeightRef.current = null
|
|
evaluateContentLayout()
|
|
setShouldReserveHorizontalSpace(!isEditing)
|
|
})
|
|
}, [isEditing, evaluateContentLayout])
|
|
|
|
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 shouldDisableMentionButton = useMemo(() => {
|
|
if (showMentionDropdown) return true
|
|
|
|
const textarea = textareaRef.current
|
|
if (!textarea) return false
|
|
|
|
const cursorPosition = textarea.selectionStart || 0
|
|
const textBeforeCursor = value.slice(0, cursorPosition)
|
|
return /@\w*$/.test(textBeforeCursor)
|
|
}, [showMentionDropdown, value])
|
|
|
|
const dropdownPosition = useMemo(() => {
|
|
if (!showMentionDropdown || !textareaRef.current)
|
|
return { x: 0, y: 0, placement: 'bottom' as const }
|
|
|
|
const textareaRect = textareaRef.current.getBoundingClientRect()
|
|
const dropdownHeight = 160 // max-h-40 = 10rem = 160px
|
|
const viewportHeight = window.innerHeight
|
|
const spaceBelow = viewportHeight - textareaRect.bottom
|
|
const spaceAbove = textareaRect.top
|
|
|
|
const shouldPlaceAbove = spaceBelow < dropdownHeight && spaceAbove > spaceBelow
|
|
|
|
return {
|
|
x: textareaRect.left,
|
|
y: shouldPlaceAbove ? textareaRect.top - 4 : textareaRect.bottom + 4,
|
|
placement: shouldPlaceAbove ? ('top' as const) : ('bottom' as const),
|
|
}
|
|
}, [showMentionDropdown])
|
|
|
|
const handleContentChange = useCallback(
|
|
(newValue: string) => {
|
|
onChange(newValue)
|
|
|
|
setTimeout(() => {
|
|
const cursorPosition = textareaRef.current?.selectionStart || 0
|
|
const textBeforeCursor = newValue.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)
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.requestAnimationFrame(() => {
|
|
evaluateContentLayout()
|
|
syncHighlightScroll()
|
|
})
|
|
}
|
|
}, 0)
|
|
},
|
|
[onChange, evaluateContentLayout, syncHighlightScroll],
|
|
)
|
|
|
|
const handleMentionButtonClick = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
const textarea = textareaRef.current
|
|
if (!textarea) return
|
|
|
|
const cursorPosition = textarea.selectionStart || 0
|
|
const textBeforeCursor = value.slice(0, cursorPosition)
|
|
|
|
if (showMentionDropdown) return
|
|
|
|
if (/@\w*$/.test(textBeforeCursor)) return
|
|
|
|
const newContent = `${value.slice(0, cursorPosition)}@${value.slice(cursorPosition)}`
|
|
|
|
onChange(newContent)
|
|
|
|
setTimeout(() => {
|
|
const newCursorPos = cursorPosition + 1
|
|
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
|
textarea.focus()
|
|
|
|
setMentionQuery('')
|
|
setMentionPosition(cursorPosition)
|
|
setShowMentionDropdown(true)
|
|
setSelectedMentionIndex(0)
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.requestAnimationFrame(() => {
|
|
evaluateContentLayout()
|
|
syncHighlightScroll()
|
|
})
|
|
}
|
|
}, 0)
|
|
},
|
|
[value, onChange, evaluateContentLayout, syncHighlightScroll, showMentionDropdown],
|
|
)
|
|
|
|
const insertMention = useCallback(
|
|
(user: UserProfile) => {
|
|
const textarea = textareaRef.current
|
|
if (!textarea) return
|
|
|
|
const beforeMention = value.slice(0, mentionPosition)
|
|
const afterMention = value.slice(textarea.selectionStart || 0)
|
|
|
|
const needsSpaceBefore = mentionPosition > 0 && !/\s/.test(value[mentionPosition - 1]!)
|
|
const prefix = needsSpaceBefore ? ' ' : ''
|
|
const newContent = `${beforeMention}${prefix}@${user.name} ${afterMention}`
|
|
|
|
onChange(newContent)
|
|
setShowMentionDropdown(false)
|
|
|
|
const newMentionedUserIds = [...mentionedUserIds, user.id]
|
|
setMentionedUserIds(newMentionedUserIds)
|
|
|
|
setTimeout(() => {
|
|
const extraSpace = needsSpaceBefore ? 1 : 0
|
|
const newCursorPos = mentionPosition + extraSpace + user.name.length + 2 // (space) + @ + name + space
|
|
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
|
textarea.focus()
|
|
if (typeof window !== 'undefined') {
|
|
window.requestAnimationFrame(() => {
|
|
evaluateContentLayout()
|
|
syncHighlightScroll()
|
|
})
|
|
}
|
|
}, 0)
|
|
},
|
|
[
|
|
value,
|
|
mentionPosition,
|
|
onChange,
|
|
mentionedUserIds,
|
|
evaluateContentLayout,
|
|
syncHighlightScroll,
|
|
],
|
|
)
|
|
|
|
const handleSubmit = useCallback(
|
|
async (e?: React.MouseEvent) => {
|
|
if (e) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
}
|
|
|
|
if (value.trim()) {
|
|
try {
|
|
await onSubmit(value.trim(), mentionedUserIds)
|
|
setMentionedUserIds([])
|
|
setShowMentionDropdown(false)
|
|
} catch (error) {
|
|
console.error('Failed to submit', error)
|
|
}
|
|
}
|
|
},
|
|
[value, mentionedUserIds, onSubmit],
|
|
)
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
// Ignore key events during IME composition (e.g., Chinese, Japanese input)
|
|
if (e.nativeEvent.isComposing) return
|
|
|
|
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()
|
|
handleSubmit()
|
|
}
|
|
},
|
|
[
|
|
showMentionDropdown,
|
|
filteredMentionUsers,
|
|
selectedMentionIndex,
|
|
insertMention,
|
|
handleSubmit,
|
|
],
|
|
)
|
|
|
|
const resetMentionState = useCallback(() => {
|
|
setMentionedUserIds([])
|
|
setShowMentionDropdown(false)
|
|
setMentionQuery('')
|
|
setMentionPosition(0)
|
|
setSelectedMentionIndex(0)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!value) {
|
|
Promise.resolve().then(() => {
|
|
resetMentionState()
|
|
})
|
|
}
|
|
}, [value, resetMentionState])
|
|
|
|
useEffect(() => {
|
|
if (!autoFocus || !textareaRef.current) return
|
|
|
|
const textarea = textareaRef.current
|
|
const timeout = window.setTimeout(() => {
|
|
textarea.focus()
|
|
const length = textarea.value.length
|
|
textarea.setSelectionRange(length, length)
|
|
}, 0)
|
|
|
|
return () => window.clearTimeout(timeout)
|
|
}, [autoFocus])
|
|
|
|
return (
|
|
<>
|
|
<div className={cn('relative flex items-center', className)}>
|
|
<div
|
|
aria-hidden
|
|
className={cn(
|
|
'pointer-events-none absolute inset-0 z-0 overflow-hidden p-1 leading-6 wrap-break-word whitespace-pre-wrap',
|
|
'body-lg-regular text-text-primary',
|
|
)}
|
|
style={{ paddingRight, paddingBottom }}
|
|
>
|
|
<div
|
|
ref={highlightContentRef}
|
|
className="min-h-full"
|
|
style={{ willChange: 'transform' }}
|
|
>
|
|
{highlightedValue}
|
|
</div>
|
|
</div>
|
|
<Textarea
|
|
ref={textareaRef}
|
|
className={cn(
|
|
'relative z-10 w-full resize-none bg-transparent p-1 body-lg-regular leading-6 text-transparent caret-primary-500 outline-hidden',
|
|
'placeholder:text-text-tertiary',
|
|
)}
|
|
style={{ paddingRight, paddingBottom }}
|
|
placeholder={resolvedPlaceholder}
|
|
autoFocus={autoFocus}
|
|
minRows={isEditing ? 4 : 1}
|
|
maxRows={4}
|
|
value={value}
|
|
disabled={disabled || loading}
|
|
onChange={(e) => handleContentChange(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onScroll={syncHighlightScroll}
|
|
/>
|
|
|
|
{!isEditing && (
|
|
<div
|
|
ref={setActionContainerRef}
|
|
className="absolute right-1 bottom-0 z-20 flex items-end gap-1"
|
|
>
|
|
<div
|
|
className={cn(
|
|
'z-20 flex size-8 items-center justify-center rounded-lg transition-opacity',
|
|
shouldDisableMentionButton
|
|
? 'cursor-not-allowed opacity-40'
|
|
: 'cursor-pointer hover:bg-state-base-hover',
|
|
)}
|
|
onClick={shouldDisableMentionButton ? undefined : handleMentionButtonClick}
|
|
>
|
|
<RiAtLine className="size-4 text-text-tertiary" />
|
|
</div>
|
|
<Button
|
|
className="z-20 ml-2 w-8 px-0"
|
|
variant="primary"
|
|
disabled={!value.trim() || disabled || loading}
|
|
onClick={handleSubmit}
|
|
>
|
|
{loading ? (
|
|
<RiLoader2Line className="size-4 animate-spin text-components-button-primary-text" />
|
|
) : (
|
|
<RiArrowUpLine className="size-4 text-components-button-primary-text" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{isEditing && (
|
|
<div
|
|
ref={setActionContainerRef}
|
|
className="absolute inset-x-1 bottom-0 z-20 flex items-end justify-between"
|
|
>
|
|
<div
|
|
className={cn(
|
|
'z-20 flex size-8 items-center justify-center rounded-lg transition-opacity',
|
|
shouldDisableMentionButton
|
|
? 'cursor-not-allowed opacity-40'
|
|
: 'cursor-pointer hover:bg-state-base-hover',
|
|
)}
|
|
onClick={shouldDisableMentionButton ? undefined : handleMentionButtonClick}
|
|
>
|
|
<RiAtLine className="size-4 text-text-tertiary" />
|
|
</div>
|
|
<div ref={setActionRightRef} className="flex items-center gap-2">
|
|
<Button variant="secondary" size="small" onClick={onCancel} disabled={loading}>
|
|
{t(($) => $['operation.cancel'], { ns: 'common' })}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
size="small"
|
|
disabled={loading || !value.trim()}
|
|
onClick={() => handleSubmit()}
|
|
className="gap-1"
|
|
>
|
|
{loading && <RiLoader2Line className="mr-1 size-3.5 animate-spin" />}
|
|
<span>{t(($) => $['operation.save'], { ns: 'common' })}</span>
|
|
{!loading && <EnterKey className="size-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showMentionDropdown &&
|
|
filteredMentionUsers.length > 0 &&
|
|
typeof document !== 'undefined' &&
|
|
createPortal(
|
|
<div
|
|
className="fixed z-50 max-h-[248px] w-[280px] overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg/95 shadow-lg backdrop-blur-[10px]"
|
|
style={{
|
|
left: dropdownPosition.x,
|
|
[dropdownPosition.placement === 'top' ? 'bottom' : 'top']:
|
|
dropdownPosition.placement === 'top'
|
|
? window.innerHeight - dropdownPosition.y
|
|
: dropdownPosition.y,
|
|
}}
|
|
data-mention-dropdown
|
|
>
|
|
{filteredMentionUsers.map((user, index) => (
|
|
<div
|
|
key={user.id}
|
|
className={cn(
|
|
'flex cursor-pointer items-center gap-2 rounded-md py-1 pr-3 pl-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="sm"
|
|
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,
|
|
)}
|
|
</>
|
|
)
|
|
},
|
|
)
|
|
|
|
MentionInputInner.displayName = 'MentionInputInner'
|
|
|
|
export const MentionInput = memo(MentionInputInner)
|