import { useRef, useEffect, useCallback, useState } from 'react'; import DOMPurify from 'dompurify'; import { useInputBarStore } from '../store/inputBar'; import { assetsApi, tosThumb, rewriteTosUrl } from '../lib/api'; import type { UploadedFile, AssetSearchResult } from '../types'; import { parseAssetMentionsFromDOM } from '../lib/assetMentions'; import { showToast } from './Toast'; 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 }); const [mentionMode, setMentionMode] = useState<'references' | 'assets'>('references'); const [assetSearchResults, setAssetSearchResults] = useState([]); const searchTimerRef = useRef | null>(null); // 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', 'img'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type', 'data-asset-group-id', 'data-group-name', 'data-asset-id', 'data-asset-type', 'data-asset-name', 'data-duration', 'data-thumb-url', 'draggable', 'src', 'alt', 'width', 'height', 'style'] }); // If the HTML is plain text but we have references or asset mentions, rebuild mention spans // This handles the case where editorHtml comes from backend (plain text only) const currentAssetMentions = useInputBarStore.getState().assetMentions || []; if (editorHtml && !editorHtml.includes('data-ref-id') && (references.length > 0 || currentAssetMentions.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]); // Helper: create a mention span with optional thumbnail const createMentionSpan = useCallback((opts: { refId: string; refType: string; label: string; thumbUrl?: string; assetGroupId?: string; groupName?: string; assetId?: string; assetType?: string; assetName?: string; duration?: string; }) => { const span = document.createElement('span'); span.className = styles.mention; span.contentEditable = 'false'; span.dataset.refId = opts.refId; span.dataset.refType = opts.refType; span.draggable = true; if (opts.thumbUrl) span.dataset.thumbUrl = opts.thumbUrl; // New asset attributes (individual asset reference) if (opts.assetId) span.dataset.assetId = opts.assetId; if (opts.assetType) span.dataset.assetType = opts.assetType; if (opts.assetName) span.dataset.assetName = opts.assetName; if (opts.duration) span.dataset.duration = opts.duration; // Legacy group attributes (backward compat for old records) if (opts.assetGroupId) span.dataset.assetGroupId = opts.assetGroupId; if (opts.groupName) span.dataset.groupName = opts.groupName; // Render icon/thumbnail based on type const isAudio = opts.refType === 'audio' || opts.assetType === 'Audio'; if (isAudio) { const icon = document.createElement('span'); icon.className = styles.mentionAudioIcon; icon.setAttribute('aria-hidden', 'true'); span.appendChild(icon); } else if (opts.thumbUrl) { const img = document.createElement('img'); img.src = tosThumb(opts.thumbUrl, 32); img.className = styles.mentionImg; img.setAttribute('width', '16'); img.setAttribute('height', '16'); img.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none'; img.onerror = () => { img.style.display = 'none'; }; span.appendChild(img); } // @ 前缀隐藏(textContent 保留用于模式匹配,视觉上不显示) const atHidden = document.createElement('span'); atHidden.style.cssText = 'font-size:0;width:0;overflow:hidden;display:inline'; atHidden.textContent = '@'; span.appendChild(atHidden); span.appendChild(document.createTextNode(opts.label)); return span; }, []); // Rebuild mention spans from plain text @label patterns const rebuildMentionSpans = useCallback((el: HTMLElement) => { // Collect all targets to match: references + asset mentions const currentAssetMentions = useInputBarStore.getState().assetMentions || []; type MatchTarget = { label: string; refId: string; refType: string; thumbUrl: string; assetGroupId?: string; groupName?: string; assetId?: string; assetType?: string; assetName?: string; duration?: string; }; const targets: MatchTarget[] = [ ...references.map((ref) => ({ label: ref.label, refId: ref.id, refType: ref.type, thumbUrl: ref.previewUrl, })), ...currentAssetMentions.map((am: Record) => { // New format (individual asset) if (am.assetId) { return { label: am.label as string, refId: am.assetId as string, refType: 'asset', thumbUrl: (am.thumbUrl as string) || '', assetId: am.assetId as string, assetType: am.assetType as string, assetName: am.label as string, duration: String(am.duration || 0), }; } // Legacy format (group reference) return { label: am.label as string, refId: (am.groupId as string) || '', refType: 'asset', thumbUrl: (am.thumbUrl as string) || '', assetGroupId: am.groupId as string, groupName: am.label as string, }; }), ]; if (targets.length === 0) return; // Sort targets by label length descending — longer labels match first // Prevents "苏晓雨" from stealing the match before "苏晓雨音频" targets.sort((a, b) => b.label.length - a.label.length); const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); const replacements: { node: Text; matches: { start: number; end: number; target: MatchTarget }[] }[] = []; let textNode: Text | null; while ((textNode = walker.nextNode() as Text | null)) { const text = textNode.textContent || ''; const matches: { start: number; end: number; target: MatchTarget }[] = []; for (const target of targets) { const pattern = `@${target.label}`; let idx = text.indexOf(pattern); while (idx !== -1) { matches.push({ start: idx, end: idx + pattern.length, target }); idx = text.indexOf(pattern, idx + pattern.length); } } if (matches.length > 0) { // Sort by position, remove overlapping matches matches.sort((a, b) => a.start - b.start); const filtered: typeof matches = []; let lastEnd = 0; for (const m of matches) { if (m.start >= lastEnd) { filtered.push(m); lastEnd = m.end; } } replacements.push({ node: textNode, matches: filtered }); } } 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 = createMentionSpan({ refId: m.target.refId, refType: m.target.refType, label: m.target.label, thumbUrl: m.target.thumbUrl, assetGroupId: m.target.assetGroupId, groupName: m.target.groupName, assetId: m.target.assetId, assetType: m.target.assetType, assetName: m.target.assetName, duration: m.target.duration, }); 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, createMentionSpan]); 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); // Sync assetMentions from DOM — prevents stale refs after deleting @mention spans const mentions: Record[] = []; el.querySelectorAll('[data-ref-type="asset"]').forEach((span) => { const s = span as HTMLElement; if (s.dataset.assetId) { mentions.push({ assetId: s.dataset.assetId, label: s.dataset.assetName || s.textContent?.replace('@', '') || '', thumbUrl: s.dataset.thumbUrl || '', assetType: s.dataset.assetType || 'Image', duration: parseFloat(s.dataset.duration || '0'), }); } else if (s.dataset.assetGroupId) { mentions.push({ groupId: s.dataset.assetGroupId, label: s.dataset.groupName || s.textContent?.replace('@', '') || '', thumbUrl: s.dataset.thumbUrl || '', }); } }); useInputBarStore.setState({ assetMentions: mentions }); }, [setPrompt, setEditorHtml]); // Remove orphaned mention spans when a reference is deleted // Skip asset-type spans — they are not tied to uploaded references 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 (span.dataset.refType === 'asset') return; // skip asset mentions if (!refIds.has(span.dataset.refId!)) { span.remove(); changed = true; } }); if (changed) { el.normalize(); extractText(); } }, [references, extractText]); // Sync editorHtml immediately on ANY DOM change (backspace delete, etc.) // Without this, deleting a mention span doesn't update editorHtml until next input event useEffect(() => { const el = editorRef.current; if (!el) return; const observer = new MutationObserver(() => extractText()); observer.observe(el, { childList: true, subtree: true, characterData: true }); return () => observer.disconnect(); }, [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; // Find the last @ before cursor const textBeforeCursor = text.substring(0, offset); const lastAtIdx = textBeforeCursor.lastIndexOf('@'); if (lastAtIdx < 0) { // No @ before cursor, close popup setShowMentionPopup(false); return; } if (lastAtIdx >= 0) { const textAfterAt = textBeforeCursor.substring(lastAtIdx + 1); if (textAfterAt.length === 0 && references.length > 0) { // Just typed @, show reference popup typedAtRef.current = true; setMentionMode('references'); openMentionPopup(); } else if (textAfterAt.length > 0 && !textAfterAt.includes(' ')) { // Text after @, search assets (Chinese + English) if (searchTimerRef.current) clearTimeout(searchTimerRef.current); searchTimerRef.current = setTimeout(() => { assetsApi.search(textAfterAt).then((res) => { if (res.data.results.length > 0) { setAssetSearchResults(res.data.results); setMentionMode('assets'); typedAtRef.current = true; setHighlightedIdx(0); openMentionPopup(); } else { setShowMentionPopup(false); } }).catch(() => { showToast('素材搜索失败,请重试'); }); }, 300); } else if (textAfterAt.includes(' ')) { // Space after @ text, close popup setShowMentionPopup(false); } } }, [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 with thumbnail const mention = createMentionSpan({ refId: ref.id, refType: ref.type, label: ref.label, thumbUrl: ref.previewUrl, }); // 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 insertAssetMention = useCallback((asset: AssetSearchResult) => { // Instant check: count limit const stats = editorRef.current ? parseAssetMentionsFromDOM(editorRef.current) : { counts: { image: 0, video: 0, audio: 0 }, durations: { video: 0, audio: 0 } }; const refs = useInputBarStore.getState().references; const refCounts = { image: 0, video: 0, audio: 0 }; refs.forEach((r) => refCounts[r.type]++); const typeKey = asset.asset_type === 'Video' ? 'video' : asset.asset_type === 'Audio' ? 'audio' : 'image'; const maxMap = { image: 9, video: 3, audio: 3 }; if (refCounts[typeKey] + stats.counts[typeKey] >= maxMap[typeKey]) { const typeLabel = asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片'; showToast(`${typeLabel}已达上限`); return; } // Instant check: duration limit (video/audio) if (asset.asset_type === 'Video' || asset.asset_type === 'Audio') { if (!asset.duration) { // Duration unknown (still processing or ffprobe failed) — warn but allow showToast('该素材时长未确定,提交时将由服务端校验'); } else { const existingDur = refs.filter((r) => r.type === typeKey && r.duration).reduce((s, r) => s + (r.duration || 0), 0); const assetDur = typeKey === 'video' ? stats.durations.video : stats.durations.audio; if (existingDur + assetDur + asset.duration > 15.4) { const typeLabel = asset.asset_type === 'Video' ? '视频' : '音频'; showToast(`${typeLabel}总时长超过15秒限制`); return; } } } setShowMentionPopup(false); setMentionMode('references'); setAssetSearchResults([]); const el = editorRef.current; if (!el) return; el.focus(); const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); // Remove the @query text that was typed 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 for individual asset const mention = createMentionSpan({ refId: String(asset.id), refType: 'asset', label: asset.name, thumbUrl: asset.thumbnail_url || asset.url, assetId: String(asset.id), assetType: asset.asset_type, assetName: asset.name, duration: asset.duration != null ? String(asset.duration) : '', }); range.insertNode(mention); const space = document.createTextNode('\u00A0'); mention.after(space); const newRange = document.createRange(); newRange.setStartAfter(space); newRange.collapse(true); sel.removeAllRanges(); sel.addRange(newRange); extractText(); }, [extractText, editorHtml, references]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (showMentionPopup) { const items = mentionMode === 'assets' ? assetSearchResults : references; if (items.length === 0) return; if (e.key === 'Escape') { e.preventDefault(); setShowMentionPopup(false); setMentionMode('references'); } else if (e.key === 'ArrowDown') { e.preventDefault(); setHighlightedIdx((prev) => (prev + 1) % items.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlightedIdx((prev) => (prev - 1 + items.length) % items.length); } else if (e.key === 'Enter') { e.preventDefault(); if (mentionMode === 'assets') { insertAssetMention(assetSearchResults[highlightedIdx]); } else { insertMention(references[highlightedIdx]); } } } }, [showMentionPopup, mentionMode, references, assetSearchResults, highlightedIdx, insertMention, insertAssetMention]); 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; } // Check if clipboard HTML contains mention spans (from our editor) const html = e.clipboardData.getData('text/html'); if (html && html.includes('data-ref-id')) { const sanitized = DOMPurify.sanitize(html, { ALLOWED_TAGS: ['span', 'br', 'img'], ALLOWED_ATTR: [ 'class', 'contenteditable', 'data-ref-id', 'data-ref-type', 'data-asset-group-id', 'data-group-name', 'data-asset-id', 'data-asset-type', 'data-asset-name', 'data-duration', 'data-thumb-url', 'draggable', 'src', 'alt', 'width', 'height', 'style', ], }); document.execCommand('insertHTML', false, sanitized); extractText(); 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 (supports both reference and asset mentions) 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 refType = target.dataset.refType; // 音频标签不显示 hover 预览 if (refType === 'audio') return; // 参考图:从 references 中查找 let found = references.find((r) => r.id === refId); // 素材库标签:用 data-thumb-url 构造预览数据 if (!found && refType === 'asset') { const assetType = target.dataset.assetType || 'Image'; if (assetType === 'Audio') return; // 音频素材不弹预览 const thumbUrl = target.dataset.thumbUrl; if (thumbUrl) { found = { id: refId || '', type: assetType === 'Video' ? 'video' : 'image', previewUrl: thumbUrl, label: target.dataset.assetName || target.textContent || '', }; } } if (!found) return; const rect = target.getBoundingClientRect(); const wrapperRect = editorRef.current!.parentElement!.getBoundingClientRect(); setHoverRef(found); 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]}
)}
{ const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null; if (target) { e.dataTransfer.setData('text/html', target.outerHTML); e.dataTransfer.effectAllowed = 'move'; target.classList.add(styles.dragging); setHoverRef(null); } }} onDragOver={(e) => { e.preventDefault(); // 拖拽 mention 标签时让光标跟随鼠标位置 if (!e.dataTransfer.types.includes('Files')) { const range = document.caretRangeFromPoint(e.clientX, e.clientY); if (range) { const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range); } } }} onDrop={(e) => { e.preventDefault(); const html = e.dataTransfer.getData('text/html'); if (html && html.includes('data-ref-id')) { // 1. 先用鼠标坐标算出目标位置,插入临时 marker(此时 DOM 还没变) const dropRange = document.caretRangeFromPoint(e.clientX, e.clientY); if (!dropRange) return; const marker = document.createTextNode('\u200B'); dropRange.insertNode(marker); // 2. 再删除原始标签(DOM 重排不影响 marker 位置) const dragging = editorRef.current?.querySelector(`.${styles.dragging}`); if (dragging) dragging.remove(); // 3. 在 marker 位置插入标签 const temp = document.createElement('div'); temp.innerHTML = html; const node = temp.firstChild; if (node) { marker.parentNode?.insertBefore(node, marker); } marker.remove(); editorRef.current?.normalize(); extractText(); } }} /> {/* Mention popup */} {showMentionPopup && (
{mentionMode === 'references' && references.length > 0 && ( <>
可能@的内容
{references.map((ref, idx) => ( ))} )} {mentionMode === 'assets' && assetSearchResults.length > 0 && ( <>
人物素材库匹配
{assetSearchResults.map((asset, idx) => ( ))} )}
)} {/* Hover preview tooltip */} {hoverRef && (
{hoverRef.type === 'video' ? (
)}
); }