import { useRef, useEffect, useCallback, useState } from 'react'; import DOMPurify from 'dompurify'; import { useInputBarStore } from '../store/inputBar'; import type { UploadedFile } from '../types'; import styles from './PromptInput.module.css'; const placeholders: Record = { universal: '上传参考图或视频,输入文字,通过 @ 引用素材', keyframe: '输入描述,定义首帧到尾帧的运动过程', }; export function PromptInput() { const prompt = useInputBarStore((s) => s.prompt); const setPrompt = useInputBarStore((s) => s.setPrompt); const editorHtml = useInputBarStore((s) => s.editorHtml); const setEditorHtml = useInputBarStore((s) => s.setEditorHtml); const mode = useInputBarStore((s) => s.mode); const references = useInputBarStore((s) => s.references); const insertAtTrigger = useInputBarStore((s) => s.insertAtTrigger); const editorRef = useRef(null); const [showMentionPopup, setShowMentionPopup] = useState(false); const [mentionPos, setMentionPos] = useState({ top: 0, left: 0 }); const typedAtRef = useRef(false); // tracks if popup was triggered by typing @ const [highlightedIdx, setHighlightedIdx] = useState(0); const [hoverRef, setHoverRef] = useState(null); const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 }); // Auto-focus useEffect(() => { editorRef.current?.focus(); }, []); // Sync editor when editorHtml changes (e.g. after submit or reEdit) useEffect(() => { const el = editorRef.current; if (!el) return; if (el.innerHTML !== editorHtml) { el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type'] }); // If the HTML is plain text but we have references, rebuild mention spans // This handles the case where editorHtml comes from backend (plain text only) if (editorHtml && !editorHtml.includes('data-ref-id') && references.length > 0) { rebuildMentionSpans(el); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [editorHtml]); // Handle @ button from toolbar useEffect(() => { if (insertAtTrigger === 0) return; if (references.length === 0) return; typedAtRef.current = false; openMentionPopup(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [insertAtTrigger]); // Rebuild mention spans from plain text @label patterns const rebuildMentionSpans = useCallback((el: HTMLElement) => { const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); const replacements: { node: Text; matches: { start: number; end: number; ref: UploadedFile }[] }[] = []; let textNode: Text | null; while ((textNode = walker.nextNode() as Text | null)) { const text = textNode.textContent || ''; const matches: { start: number; end: number; ref: UploadedFile }[] = []; for (const ref of references) { const pattern = `@${ref.label}`; let idx = text.indexOf(pattern); while (idx !== -1) { matches.push({ start: idx, end: idx + pattern.length, ref }); idx = text.indexOf(pattern, idx + pattern.length); } } if (matches.length > 0) { matches.sort((a, b) => a.start - b.start); replacements.push({ node: textNode, matches }); } } for (const { node, matches } of replacements) { const text = node.textContent || ''; const frag = document.createDocumentFragment(); let lastIdx = 0; for (const m of matches) { if (m.start > lastIdx) { frag.appendChild(document.createTextNode(text.slice(lastIdx, m.start))); } const span = document.createElement('span'); span.className = styles.mention; span.contentEditable = 'false'; span.dataset.refId = m.ref.id; span.dataset.refType = m.ref.type; span.textContent = `@${m.ref.label}`; frag.appendChild(span); lastIdx = m.end; } if (lastIdx < text.length) { frag.appendChild(document.createTextNode(text.slice(lastIdx))); } node.parentNode?.replaceChild(frag, node); } if (replacements.length > 0) { setEditorHtml(el.innerHTML); } }, [references, setEditorHtml]); const openMentionPopup = useCallback(() => { const el = editorRef.current; if (!el) return; el.focus(); const sel = window.getSelection(); const editorRect = el.getBoundingClientRect(); // Position popup above cursor line let top = 0; // fallback: top of editor let left = 0; if (sel && sel.rangeCount > 0) { const range = sel.getRangeAt(0); const rect = range.getBoundingClientRect(); if (rect.height > 0) { top = rect.top - editorRect.top - 8; left = Math.max(0, rect.left - editorRect.left); } else { // Collapsed range returns zero rect — use temp marker const marker = document.createElement('span'); marker.textContent = '\u200B'; range.insertNode(marker); const mRect = marker.getBoundingClientRect(); top = mRect.top - editorRect.top - 8; left = Math.max(0, mRect.left - editorRect.left); marker.remove(); el.normalize(); } } setMentionPos({ top, left }); setHighlightedIdx(0); setShowMentionPopup(true); }, []); const extractText = useCallback(() => { const el = editorRef.current; if (!el) return; setPrompt(el.textContent || ''); setEditorHtml(el.innerHTML); }, [setPrompt, setEditorHtml]); // Remove orphaned mention spans when a reference is deleted useEffect(() => { const el = editorRef.current; if (!el) return; const refIds = new Set(references.map((r) => r.id)); const spans = el.querySelectorAll('[data-ref-id]'); let changed = false; spans.forEach((span) => { if (!refIds.has(span.dataset.refId!)) { span.replaceWith(''); changed = true; } }); if (changed) { el.normalize(); extractText(); } }, [references, extractText]); const handleInput = useCallback(() => { extractText(); // Detect if user just typed @ const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); const node = range.startContainer; if (node.nodeType !== Node.TEXT_NODE) return; const text = node.textContent || ''; const offset = range.startOffset; if (offset > 0 && text[offset - 1] === '@' && references.length > 0) { // Keep the @ visible, open popup above it typedAtRef.current = true; openMentionPopup(); } }, [extractText, references.length, openMentionPopup]); const insertMention = useCallback((ref: UploadedFile) => { setShowMentionPopup(false); const el = editorRef.current; if (!el) return; el.focus(); const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); // If triggered by typing @, remove the @ character first if (typedAtRef.current) { typedAtRef.current = false; const node = range.startContainer; if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent || ''; const offset = range.startOffset; const atIdx = text.lastIndexOf('@', offset - 1); if (atIdx >= 0) { node.textContent = text.substring(0, atIdx) + text.substring(offset); range.setStart(node, atIdx); range.collapse(true); } } } range.deleteContents(); // Create mention span const mention = document.createElement('span'); mention.className = styles.mention; mention.contentEditable = 'false'; mention.dataset.refId = ref.id; mention.dataset.refType = ref.type; mention.textContent = `@${ref.label}`; // Insert mention + trailing space range.insertNode(mention); const space = document.createTextNode('\u00A0'); mention.after(space); // Move cursor after space const newRange = document.createRange(); newRange.setStartAfter(space); newRange.collapse(true); sel.removeAllRanges(); sel.addRange(newRange); extractText(); }, [extractText]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (showMentionPopup) { if (e.key === 'Escape') { e.preventDefault(); setShowMentionPopup(false); } else if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightedIdx((prev) => (prev + 1) % references.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightedIdx((prev) => (prev - 1 + references.length) % references.length); } else if (e.key === 'Enter') { e.preventDefault(); insertMention(references[highlightedIdx]); } } }, [showMentionPopup, references, highlightedIdx, insertMention]); const handlePaste = useCallback((e: React.ClipboardEvent) => { e.preventDefault(); // Handle pasted image files (Ctrl+V screenshot / copied image) const items = e.clipboardData.items; const imageFiles: File[] = []; for (let i = 0; i < items.length; i++) { if (items[i].type.startsWith('image/')) { const file = items[i].getAsFile(); if (file) imageFiles.push(file); } } if (imageFiles.length > 0) { useInputBarStore.getState().addReferences(imageFiles); return; } // Plain text paste — strip @label patterns to prevent duplicate mention tags let text = e.clipboardData.getData('text/plain'); for (const ref of references) { const pattern = `@${ref.label}`; while (text.includes(pattern)) { text = text.replace(pattern, ref.label); } } document.execCommand('insertText', false, text); extractText(); }, [extractText, references]); // Mention hover — delegated event const handleMouseOver = useCallback((e: React.MouseEvent) => { const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null; if (!target) return; const refId = target.dataset.refId; const ref = references.find((r) => r.id === refId); if (!ref) return; const rect = target.getBoundingClientRect(); const wrapperRect = editorRef.current!.parentElement!.getBoundingClientRect(); setHoverRef(ref); setHoverPos({ top: rect.top - wrapperRect.top - 8, left: rect.left - wrapperRect.left + rect.width / 2, }); }, [references]); const handleMouseOut = useCallback((e: React.MouseEvent) => { const target = (e.target as HTMLElement).closest('[data-ref-id]'); if (target) setHoverRef(null); }, []); // Close mention popup on click outside useEffect(() => { if (!showMentionPopup) return; const handler = (e: MouseEvent) => { const popup = document.querySelector(`.${styles.mentionPopup}`); if (popup && !popup.contains(e.target as Node)) { setShowMentionPopup(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [showMentionPopup]); const isEmpty = !prompt.trim() && !editorHtml; return (
{isEmpty && (
{placeholders[mode]}
)}
{/* Mention popup */} {showMentionPopup && references.length > 0 && (
可能@的内容
{references.map((ref, idx) => ( ))}
)} {/* Hover preview tooltip */} {hoverRef && (
{hoverRef.type === 'video' ? (
)}
); }