import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { FileText, Music2, Video } from 'lucide-react'; export type PlaygroundMentionUploadKind = 'audio' | 'file' | 'image' | 'video'; export interface PlaygroundMentionUpload { id: string; kind: PlaygroundMentionUploadKind; name: string; url: string; } const resourceTokenPrefix = '<<]+)>>>/g; const resourceTokenWithSpacePattern = /<<]+)>>>\s?/g; export function buildPlaygroundResourceToken(id: string) { return `${resourceTokenPrefix}${id}${resourceTokenSuffix}`; } export function removeInvalidPlaygroundResourceTokens(raw: string, uploads: PlaygroundMentionUpload[]) { if (!raw.includes(resourceTokenPrefix)) return raw; const validIds = new Set(uploads.map((item) => item.id)); return raw.replace(resourceTokenWithSpacePattern, (full, id: string) => validIds.has(id) ? full : ''); } export function replacePlaygroundResourceTokens( raw: string, uploads: PlaygroundMentionUpload[], mode: 'image' | 'video', ) { if (!raw.includes(resourceTokenPrefix)) return raw; const byId = new Map(uploads.map((item) => [item.id, item])); return raw.replace(resourceTokenPattern, (full, id: string) => { const item = byId.get(id); if (!item) return ''; return resourcePromptLabel(item, uploads, mode); }); } export function PlaygroundPromptMentionInput(props: { disabled?: boolean; placeholder: string; uploads: PlaygroundMentionUpload[]; value: string; onChange: (value: string) => void; }) { const editableRef = useRef(null); const dropdownRef = useRef(null); const blurTimerRef = useRef(undefined); const isComposingRef = useRef(false); const [text, setText] = useState(props.value); const [focused, setFocused] = useState(false); const [hasEditableContent, setHasEditableContent] = useState(() => promptTextHasContent(props.value)); const [mentionOpen, setMentionOpen] = useState(false); const [mentionAtIndex, setMentionAtIndex] = useState(-1); const [mentionSearch, setMentionSearch] = useState(''); const [highlightIndex, setHighlightIndex] = useState(0); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, placement: 'bottom' as 'bottom' | 'top' }); const showPlaceholder = !hasEditableContent; const uploadSignature = useMemo( () => props.uploads.map((item) => `${item.id}:${item.kind}:${item.name}:${item.url}`).join('|'), [props.uploads], ); const mentionItems = useMemo(() => props.uploads.map((item, index) => ({ item, label: mentionDisplayLabel(item, props.uploads), searchText: `${item.name} ${mentionDisplayLabel(item, props.uploads)} ${uploadKindChinese(item.kind)} ${index + 1}`.toLowerCase(), token: buildPlaygroundResourceToken(item.id), })), [props.uploads]); const filteredMentionItems = useMemo(() => { const keyword = mentionSearch.trim().toLowerCase(); if (!keyword) return mentionItems; return mentionItems.filter((item) => item.searchText.includes(keyword)); }, [mentionItems, mentionSearch]); useEffect(() => { if (props.value === text) { setHasEditableContent(promptTextHasContent(props.value)); return; } setText(props.value); setHasEditableContent(promptTextHasContent(props.value)); if (!focused) { requestAnimationFrame(() => renderToEditable(props.value)); } }, [focused, props.value, text]); useEffect(() => { const cleaned = removeInvalidPlaygroundResourceTokens(text, props.uploads); if (cleaned !== text) { setText(cleaned); setHasEditableContent(promptTextHasContent(cleaned)); props.onChange(cleaned); requestAnimationFrame(() => renderToEditable(cleaned)); return; } if (!focused) { requestAnimationFrame(() => renderToEditable(text)); } }, [uploadSignature]); useEffect(() => { requestAnimationFrame(() => renderToEditable(text)); return () => { if (blurTimerRef.current) window.clearTimeout(blurTimerRef.current); }; }, []); const updateMentionDropdownPosition = useCallback(() => { const editable = editableRef.current; const root = editable?.parentElement; if (!editable || !root) return; const rootRect = root.getBoundingClientRect(); const caretRect = getCaretClientRect(editable) ?? editable.getBoundingClientRect(); const dropdown = dropdownRef.current; const viewportPadding = 8; const gap = 6; const dropdownWidth = Math.min(dropdown?.offsetWidth ?? 320, Math.max(180, window.innerWidth - viewportPadding * 2)); const dropdownHeight = Math.min(dropdown?.offsetHeight ?? 220, Math.max(120, window.innerHeight - viewportPadding * 2)); const minLeft = viewportPadding - rootRect.left; const maxLeft = window.innerWidth - viewportPadding - dropdownWidth - rootRect.left; const left = clamp(caretRect.left - rootRect.left, minLeft, Math.max(minLeft, maxLeft)); const belowTop = caretRect.bottom + gap; const aboveTop = caretRect.top - dropdownHeight - gap; const shouldOpenAbove = belowTop + dropdownHeight > window.innerHeight - viewportPadding && aboveTop >= viewportPadding; const viewportTop = shouldOpenAbove ? Math.max(viewportPadding, aboveTop) : Math.min(belowTop, window.innerHeight - viewportPadding - dropdownHeight); setDropdownPosition({ top: viewportTop - rootRect.top, left, placement: shouldOpenAbove ? 'top' : 'bottom', }); }, []); useEffect(() => { if (!mentionOpen) return; const frame = window.requestAnimationFrame(updateMentionDropdownPosition); const handleViewportChange = () => updateMentionDropdownPosition(); window.addEventListener('resize', handleViewportChange); window.addEventListener('scroll', handleViewportChange, true); return () => { window.cancelAnimationFrame(frame); window.removeEventListener('resize', handleViewportChange); window.removeEventListener('scroll', handleViewportChange, true); }; }, [filteredMentionItems.length, mentionOpen, mentionSearch, updateMentionDropdownPosition]); function renderToEditable(nextText = text, caret?: number) { const editable = editableRef.current; if (!editable) return; editable.innerHTML = textToHtml(nextText, props.uploads); setHasEditableContent(promptTextHasContent(nextText)); if (typeof caret === 'number') { editable.focus(); setCaretOffset(editable, caret); } } function commitFromEditable(shouldInspectMention: boolean, event?: InputEvent) { const editable = editableRef.current; if (!editable || props.disabled) return; const nextText = serializeEditableToPlainText(editable); setHasEditableContent(promptTextHasContent(nextText)); if (isComposingRef.current || event?.isComposing) return; setText(nextText); props.onChange(nextText); if (!shouldInspectMention) return; inspectMentionTrigger(nextText, getCaretOffset(editable), event); } function inspectMentionTrigger(nextText: string, caret: number, event?: InputEvent) { const beforeCaret = nextText.slice(0, caret); const atIndex = Math.max(beforeCaret.lastIndexOf('@'), beforeCaret.lastIndexOf('@')); if (atIndex < 0) { closeMention(); return; } const search = beforeCaret.slice(atIndex + 1); if (/\s/.test(search)) { closeMention(); return; } const inputData = event?.data ?? ''; if (!mentionOpen && search.length > 0 && inputData !== '@' && inputData !== '@') { closeMention(); return; } setMentionAtIndex(atIndex); setMentionSearch(search); setMentionOpen(true); setHighlightIndex(0); requestAnimationFrame(updateMentionDropdownPosition); } function closeMention() { setMentionOpen(false); setMentionSearch(''); setMentionAtIndex(-1); setHighlightIndex(0); } function applyMention(token: string) { const editable = editableRef.current; if (!editable || mentionAtIndex < 0) return; const currentText = serializeEditableToPlainText(editable); const replaceEnd = Math.max(mentionAtIndex + 1, mentionAtIndex + 1 + mentionSearch.length); const before = currentText.slice(0, mentionAtIndex); const after = currentText.slice(replaceEnd); const nextText = `${before}${token} ${after}`; const nextCaret = before.length + token.length + 1; setText(nextText); setHasEditableContent(promptTextHasContent(nextText)); props.onChange(nextText); closeMention(); requestAnimationFrame(() => renderToEditable(nextText, nextCaret)); } function handleKeyDown(event: ReactKeyboardEvent) { if (props.disabled) { event.preventDefault(); return; } if (!mentionOpen || !filteredMentionItems.length) return; if (event.key === 'ArrowDown') { event.preventDefault(); setHighlightIndex((current) => (current + 1) % filteredMentionItems.length); return; } if (event.key === 'ArrowUp') { event.preventDefault(); setHighlightIndex((current) => (current - 1 + filteredMentionItems.length) % filteredMentionItems.length); return; } if (event.key === 'Enter') { event.preventDefault(); const item = filteredMentionItems[Math.min(highlightIndex, filteredMentionItems.length - 1)]; if (item) applyMention(item.token); return; } if (event.key === 'Escape') { event.preventDefault(); closeMention(); } } return (
{ blurTimerRef.current = window.setTimeout(() => { setFocused(false); closeMention(); commitFromEditable(false); }, 120); }} onCompositionEnd={() => { isComposingRef.current = false; setHasEditableContent(editablePromptTextHasContent(editableRef.current)); requestAnimationFrame(() => commitFromEditable(true)); }} onCompositionStart={() => { isComposingRef.current = true; }} onFocus={() => { setFocused(true); if (blurTimerRef.current) window.clearTimeout(blurTimerRef.current); }} onInput={(event) => commitFromEditable(true, event.nativeEvent instanceof InputEvent ? event.nativeEvent : undefined)} onKeyDown={handleKeyDown} onPaste={(event) => { if (props.disabled) return; event.preventDefault(); const plainText = event.clipboardData.getData('text/plain'); document.execCommand('insertText', false, plainText); }} /> {showPlaceholder && } {mentionOpen && (
event.preventDefault()} > {filteredMentionItems.length ? ( filteredMentionItems.map((candidate, index) => ( )) ) : (
暂无可引用资源
)}
)}
); } function promptTextHasContent(raw: string) { return raw.trim().length > 0; } function editablePromptTextHasContent(editable: HTMLElement | null) { return editable ? promptTextHasContent(serializeEditableToPlainText(editable)) : false; } function MentionThumb(props: { item: PlaygroundMentionUpload }) { if (props.item.kind === 'image') { return ; } if (props.item.kind === 'video') { return