'use client' import type { ReactNode } from 'react' import type { UserProfile } from '@/service/workflow-comment' import { RiArrowUpLine, RiAtLine, RiLoader2Line } from '@remixicon/react' import { useParams } from 'next/navigation' 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 Avatar from '@/app/components/base/avatar' import Button from '@/app/components/base/button' import { EnterKey } from '@/app/components/base/icons/src/public/common' import { fetchMentionableUsers } from '@/service/workflow-comment' import { cn } from '@/utils/classnames' 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 MentionInputInner = forwardRef(({ 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(null) const highlightContentRef = useRef(null) const actionContainerRef = useRef(null) const actionRightRef = useRef(null) const baseTextareaHeightRef = useRef(null) const mentionTimerRef = useRef(null) const focusTimerRef = useRef(null) const layoutRafRef = useRef(null) // Expose textarea ref to parent component useImperativeHandle(forwardedRef, () => textareaRef.current!, []) useEffect(() => { return () => { if (mentionTimerRef.current !== null) { window.clearTimeout(mentionTimerRef.current) mentionTimerRef.current = null } if (focusTimerRef.current !== null) { window.clearTimeout(focusTimerRef.current) focusTimerRef.current = null } if (layoutRafRef.current !== null) { window.cancelAnimationFrame(layoutRafRef.current) layoutRafRef.current = null } } }, []) const workflowStore = useWorkflowStore() const mentionUsersFromStore = useStore(state => ( appId ? state.mentionableUsersCache[appId] : undefined )) const mentionUsers = useMemo(() => mentionUsersFromStore ?? [], [mentionUsersFromStore]) const [showMentionDropdown, setShowMentionDropdown] = useState(false) const [mentionQuery, setMentionQuery] = useState('') const [mentionPosition, setMentionPosition] = useState(0) const [selectedMentionIndex, setSelectedMentionIndex] = useState(0) const [mentionedUserIds, setMentionedUserIds] = useState([]) 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(() => { 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({value.slice(cursor, nextMatchStart)}) const mentionEnd = nextMatchStart + matchedName.length + 1 segments.push( {value.slice(nextMatchStart, mentionEnd)} , ) hasMention = true cursor = mentionEnd } if (!hasMention) return value if (cursor < value.length) segments.push({value.slice(cursor)}) 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 users = await fetchMentionableUsers(appId) workflowStore.getState().setMentionableUsersCache(appId, 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 scheduleLayoutSync = useCallback(() => { if (typeof window === 'undefined') return if (layoutRafRef.current !== null) window.cancelAnimationFrame(layoutRafRef.current) layoutRafRef.current = window.requestAnimationFrame(() => { evaluateContentLayout() syncHighlightScroll() }) }, [evaluateContentLayout, syncHighlightScroll]) 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) if (mentionTimerRef.current !== null) window.clearTimeout(mentionTimerRef.current) mentionTimerRef.current = window.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) } scheduleLayoutSync() }, 0) }, [onChange, scheduleLayoutSync]) 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) if (mentionTimerRef.current !== null) window.clearTimeout(mentionTimerRef.current) mentionTimerRef.current = window.setTimeout(() => { const newCursorPos = cursorPosition + 1 textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.focus() setMentionQuery('') setMentionPosition(cursorPosition) setShowMentionDropdown(true) setSelectedMentionIndex(0) scheduleLayoutSync() }, 0) }, [value, onChange, scheduleLayoutSync, 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) if (mentionTimerRef.current !== null) window.clearTimeout(mentionTimerRef.current) mentionTimerRef.current = window.setTimeout(() => { const extraSpace = needsSpaceBefore ? 1 : 0 const newCursorPos = mentionPosition + extraSpace + user.name.length + 2 // (space) + @ + name + space textarea.setSelectionRange(newCursorPos, newCursorPos) textarea.focus() scheduleLayoutSync() }, 0) }, [value, mentionPosition, onChange, mentionedUserIds, scheduleLayoutSync]) 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) { const textarea = textareaRef.current if (focusTimerRef.current !== null) window.clearTimeout(focusTimerRef.current) focusTimerRef.current = window.setTimeout(() => { textarea.focus() const length = textarea.value.length textarea.setSelectionRange(length, length) }, 0) } }, [autoFocus]) return ( <>
{highlightedValue}