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>
799 lines
30 KiB
TypeScript
799 lines
30 KiB
TypeScript
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>
|
||
);
|
||
}
|