video-shuoshan/web/src/components/PromptInput.tsx
seaislee1209 f0f47e8368 feat(theme): 亮色主题切换完整实现 — dark/light 双套 var + Sidebar 切换 + 浅色色板
Stage 1 (var 化, 350 处): 425 处硬编码颜色 → CSS var, 涉及 49 个 tsx/css module 文件,
   按 hot files (DashboardPage/TeamDashboardPage/RecordDetailModal/ReferenceList) →
   Modal/Asset/Profile/Login → 生成页家族/管理后台/公共 UI 三波 8 个 sub-agent 并行处理。
   index.css :root 加 ~70 个新 var (modal/text 层级/状态色 bg 变体/chart/mention pill 等)。
Stage 2 (双套 var): :root 保留 DARK 默认值, [data-theme="light"] 覆盖 ~95 个 token。
   浅色色板按 Vercel Geist (#fafafa / #171717 / shadow-border) + Linear Light surface 分层规范,
   主色 #6c63ff → #5048cc 加深 18% 满足 WCAG AA。aurora 极光在 light 下 display:none。
Stage 3 (切换机制): 新建 store/theme.ts (Zustand + localStorage 持久化),
   Sidebar 加月亮/太阳 SVG 切换按钮 (位于头像上方),
   DashboardPage/TeamDashboardPage/ProfilePage 的 ECharts 配 key={theme} 强制重渲染。
Stage 4 (微调): LandingPage 强制 data-theme="dark" 保持品牌识别 (登录流程一直深色),
   sidebar bg / card bg / border 在浅色下加深 0.02 提升轮廓辨识度。
Stage 5 (验证): Playwright 头无浏览器自动登录 admin + screenshot_user, 截深/浅各 12 个页面 = 24 张
   到 docs/screenshots/ (本地档, .gitignore 排除 png 不入库)。
   vitest 71fail/162pass 与改造前基线完全一致, 无新增回归。

完成报告: docs/todo/亮色主题切换-完成报告.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:10:35 +08:00

799 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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<AssetSearchResult[]>([]);
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-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<string, unknown>) => {
// 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<string, unknown>[] = [];
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<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.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 (
<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={rewriteTosUrl(ref.previewUrl)} muted className={styles.thumbMedia} />
) : ref.type === 'audio' ? (
<span style={{ fontSize: 16 }}>{'\u266B'}</span>
) : (
<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' ? '视频' : ref.type === 'audio' ? '音频' : '图片'}
</span>
</button>
))}
</>
)}
{mentionMode === 'assets' && assetSearchResults.length > 0 && (
<>
<div className={styles.mentionHeader}></div>
{assetSearchResults.map((asset, idx) => (
<button
key={asset.id}
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
insertAssetMention(asset);
}}
>
<div className={styles.mentionThumb}>
{asset.asset_type === 'Audio' ? (
<span style={{ fontSize: 16 }}></span>
) : (asset.thumbnail_url || asset.url) ? (
<img src={tosThumb(asset.thumbnail_url || asset.url, 72)} alt="" className={styles.thumbMedia} />
) : (
<span style={{ fontSize: 9, color: 'var(--color-text-disabled)' }}></span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<span className={styles.mentionLabel}>{asset.name}</span>
<span style={{ fontSize: 10, color: 'var(--color-border-modal-hover)', marginLeft: 4 }}>{asset.group_name}</span>
</div>
<span className={styles.mentionType}>
{asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片'}
</span>
</button>
))}
</>
)}
</div>
)}
{/* Hover preview tooltip */}
{hoverRef && (
<div
className={styles.previewTooltip}
style={{ top: hoverPos.top, left: hoverPos.left }}
>
{hoverRef.type === 'video' ? (
<video
src={rewriteTosUrl(hoverRef.previewUrl)}
autoPlay
loop
muted
playsInline
className={styles.previewMedia}
/>
) : hoverRef.type === 'audio' ? (
<div style={{ width: 120, height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32 }}>{'\u266B'}</div>
) : (
<img
src={tosThumb(hoverRef.previewUrl, 200)}
alt={hoverRef.label}
className={styles.previewMedia}
/>
)}
<div className={styles.previewLabel}>{hoverRef.label}</div>
</div>
)}
</div>
);
}