①视频收藏(is_favorited + toggle API + 卡片/详情页收藏按钮 + 资产页「我的收藏」筛选) ②联网搜索按钮永久禁用(待开放) ③音频标签加音符符号,hover 不弹预览 ④轮询完成后自动更新 token/费用(不用刷新页面) ⑤超管/团管内容资产页视频详情加上下切换箭头 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
683 lines
24 KiB
TypeScript
683 lines
24 KiB
TypeScript
import { useRef, useEffect, useCallback, useState } from 'react';
|
||
import DOMPurify from 'dompurify';
|
||
import { useInputBarStore } from '../store/inputBar';
|
||
import { assetsApi, tosThumb } from '../lib/api';
|
||
import type { UploadedFile, AssetGroup } from '../types';
|
||
import styles from './PromptInput.module.css';
|
||
|
||
const placeholders: Record<string, string> = {
|
||
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<HTMLDivElement>(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<UploadedFile | null>(null);
|
||
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
|
||
const [mentionMode, setMentionMode] = useState<'references' | 'assets'>('references');
|
||
const [assetSearchResults, setAssetSearchResults] = useState<AssetGroup[]>([]);
|
||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | 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-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;
|
||
}) => {
|
||
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;
|
||
if (opts.assetGroupId) span.dataset.assetGroupId = opts.assetGroupId;
|
||
if (opts.groupName) span.dataset.groupName = opts.groupName;
|
||
|
||
if (opts.refType === 'audio') {
|
||
const icon = document.createElement('span');
|
||
icon.textContent = '\u266B';
|
||
icon.style.cssText = 'margin-right:3px;font-size:13px;vertical-align:middle;pointer-events:none';
|
||
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';
|
||
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 };
|
||
const targets: MatchTarget[] = [
|
||
...references.map((ref) => ({
|
||
label: ref.label, refId: ref.id, refType: ref.type, thumbUrl: ref.previewUrl,
|
||
})),
|
||
...currentAssetMentions.map((am) => ({
|
||
label: am.label, refId: am.groupId, refType: 'asset', thumbUrl: am.thumbUrl || '',
|
||
assetGroupId: am.groupId, groupName: am.label,
|
||
})),
|
||
];
|
||
|
||
if (targets.length === 0) return;
|
||
|
||
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,
|
||
});
|
||
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);
|
||
}, [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<HTMLElement>('[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.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;
|
||
|
||
// 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 (/[\u4e00-\u9fff]+/.test(textAfterAt) && !textAfterAt.includes(' ')) {
|
||
// Chinese text after @, search assets
|
||
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(() => {});
|
||
}, 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((group: AssetGroup) => {
|
||
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 asset with thumbnail
|
||
const mention = createMentionSpan({
|
||
refId: String(group.id),
|
||
refType: 'asset',
|
||
label: group.name,
|
||
thumbUrl: group.thumbnail_url,
|
||
assetGroupId: String(group.id),
|
||
groupName: group.name,
|
||
});
|
||
|
||
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]);
|
||
|
||
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-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 thumbUrl = target.dataset.thumbUrl;
|
||
if (thumbUrl) {
|
||
found = {
|
||
id: refId || '',
|
||
type: 'image',
|
||
previewUrl: thumbUrl,
|
||
label: target.dataset.groupName || 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 (
|
||
<div className={styles.wrapper}>
|
||
{isEmpty && (
|
||
<div className={styles.placeholder}>{placeholders[mode]}</div>
|
||
)}
|
||
<div
|
||
ref={editorRef}
|
||
className={styles.editor}
|
||
contentEditable
|
||
suppressContentEditableWarning
|
||
onInput={handleInput}
|
||
onKeyDown={handleKeyDown}
|
||
onPaste={handlePaste}
|
||
onMouseOver={handleMouseOver}
|
||
onMouseOut={handleMouseOut}
|
||
onDragStart={(e) => {
|
||
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 && (
|
||
<div
|
||
className={styles.mentionPopup}
|
||
style={{ top: mentionPos.top, left: mentionPos.left }}
|
||
>
|
||
{mentionMode === 'references' && references.length > 0 && (
|
||
<>
|
||
<div className={styles.mentionHeader}>可能@的内容</div>
|
||
{references.map((ref, idx) => (
|
||
<button
|
||
key={`${ref.id}-${idx}`}
|
||
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
insertMention(ref);
|
||
}}
|
||
>
|
||
<div className={styles.mentionThumb}>
|
||
{ref.type === 'video' ? (
|
||
<video src={ref.previewUrl} muted className={styles.thumbMedia} />
|
||
) : (
|
||
<img src={tosThumb(ref.previewUrl, 72)} alt="" className={styles.thumbMedia} />
|
||
)}
|
||
</div>
|
||
<span className={styles.mentionLabel}>{ref.label}</span>
|
||
<span className={styles.mentionType}>
|
||
{ref.type === 'video' ? '视频' : '图片'}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
{mentionMode === 'assets' && assetSearchResults.length > 0 && (
|
||
<>
|
||
<div className={styles.mentionHeader}>素材库匹配</div>
|
||
{assetSearchResults.map((group, idx) => (
|
||
<button
|
||
key={group.id}
|
||
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
insertAssetMention(group);
|
||
}}
|
||
>
|
||
<div className={styles.mentionThumb}>
|
||
<img src={tosThumb(group.thumbnail_url, 72)} alt="" className={styles.thumbMedia} />
|
||
</div>
|
||
<span className={styles.mentionLabel}>{group.name}</span>
|
||
<span className={styles.mentionType}>人像</span>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Hover preview tooltip */}
|
||
{hoverRef && (
|
||
<div
|
||
className={styles.previewTooltip}
|
||
style={{ top: hoverPos.top, left: hoverPos.left }}
|
||
>
|
||
{hoverRef.type === 'video' ? (
|
||
<video
|
||
src={hoverRef.previewUrl}
|
||
autoPlay
|
||
loop
|
||
muted
|
||
playsInline
|
||
className={styles.previewMedia}
|
||
/>
|
||
) : (
|
||
<img
|
||
src={tosThumb(hoverRef.previewUrl, 200)}
|
||
alt={hoverRef.label}
|
||
className={styles.previewMedia}
|
||
/>
|
||
)}
|
||
<div className={styles.previewLabel}>{hoverRef.label}</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|