Fix: restore video generation UI features
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m1s

- Remove 图片生成 option — only 视频生成 available
- Remove Seedance 2.0 Fast — fixed to Seedance 2.0
- Implement @ mention system with contentEditable editor:
  - Click @ or type @ to show reference popup
  - Select media to insert dimmed mention tag
  - Hover over mention shows media preview tooltip
  - Videos auto-play on hover
This commit is contained in:
zyc 2026-03-13 10:59:24 +08:00
parent 8ef3d17553
commit 7b804df30f
3 changed files with 388 additions and 78 deletions

View File

@ -4,11 +4,10 @@
position: relative;
}
.textarea {
.editor {
background: transparent;
border: none;
outline: none;
resize: none;
color: var(--color-text-primary);
font-size: 14px;
line-height: 1.6;
@ -17,8 +16,136 @@
max-height: 144px;
font-family: 'Noto Sans SC', system-ui, sans-serif;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.textarea::placeholder {
.placeholder {
position: absolute;
top: 4px;
left: 0;
color: #5a5a6a;
font-size: 14px;
line-height: 1.6;
pointer-events: none;
font-family: 'Noto Sans SC', system-ui, sans-serif;
}
/* @ mention tag (inserted as contentEditable=false span) */
.mention {
display: inline;
padding: 1px 6px;
margin: 0 2px;
border-radius: 4px;
background: rgba(0, 184, 230, 0.12);
color: rgba(0, 184, 230, 0.7);
font-size: 13px;
cursor: default;
user-select: none;
transition: background 0.15s;
}
.mention:hover {
background: rgba(0, 184, 230, 0.22);
color: rgba(0, 184, 230, 0.9);
}
/* Mention popup */
.mentionPopup {
position: absolute;
z-index: 100;
background: #1e1e2e;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 6px;
min-width: 200px;
max-width: 280px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: fadeIn 0.12s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.mentionHeader {
padding: 4px 8px 6px;
font-size: 11px;
color: #5a5a6a;
border-bottom: 1px solid #2a2a3a;
margin-bottom: 4px;
}
.mentionItem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 6px 8px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s;
text-align: left;
}
.mentionItem:hover {
background: rgba(255, 255, 255, 0.06);
}
.mentionThumb {
width: 36px;
height: 36px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
background: #2a2a3a;
}
.thumbMedia {
width: 100%;
height: 100%;
object-fit: cover;
}
.mentionLabel {
flex: 1;
color: var(--color-text-primary);
font-size: 13px;
}
.mentionType {
color: #5a5a6a;
font-size: 11px;
}
/* Hover preview tooltip */
.previewTooltip {
position: absolute;
z-index: 200;
transform: translate(-50%, -100%);
background: #1e1e2e;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
pointer-events: none;
animation: fadeIn 0.1s ease;
}
.previewMedia {
display: block;
width: 160px;
height: 100px;
object-fit: cover;
border-radius: 6px;
}
.previewLabel {
text-align: center;
color: #8a8a9a;
font-size: 11px;
margin-top: 4px;
}

View File

@ -1,74 +1,280 @@
import { useRef, useEffect, useCallback } from 'react';
import { useRef, useEffect, useCallback, useState } from 'react';
import { useInputBarStore } from '../store/inputBar';
import type { UploadedFile } from '../types';
import styles from './PromptInput.module.css';
const placeholders: Record<string, string> = {
universal: '上传1-5张参考图或视频输入文字自由组合图、文、音、视频多元素定义精彩互动。',
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 textareaRef = useRef<HTMLTextAreaElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
const [showMentionPopup, setShowMentionPopup] = useState(false);
const [mentionPos, setMentionPos] = useState({ top: 0, left: 0 });
const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
// Auto-focus
useEffect(() => {
textareaRef.current?.focus();
editorRef.current?.focus();
}, []);
// Auto-resize textarea
const autoResize = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 144) + 'px';
}, []);
// Sync textarea value when prompt changes externally (submit clear, reEdit restore)
// Sync editor when editorHtml resets (e.g. after submit)
useEffect(() => {
const el = textareaRef.current;
const el = editorRef.current;
if (!el) return;
if (el.value !== prompt) {
el.value = prompt;
autoResize();
if (editorHtml === '' && el.innerHTML !== '') {
el.innerHTML = '';
}
}, [prompt, autoResize]);
}, [editorHtml]);
// Handle @ button from toolbar
useEffect(() => {
if (insertAtTrigger === 0) return;
const el = textareaRef.current;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
const text = el.value;
const newValue = text.substring(0, start) + '@' + text.substring(end);
setPrompt(newValue);
// Restore cursor position after the inserted @
requestAnimationFrame(() => {
el.selectionStart = el.selectionEnd = start + 1;
el.focus();
});
}, [insertAtTrigger, setPrompt]);
if (references.length === 0) return;
openMentionPopup();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [insertAtTrigger]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.target.value);
autoResize();
}, [setPrompt, autoResize]);
const openMentionPopup = useCallback(() => {
const el = editorRef.current;
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
const editorRect = el.getBoundingClientRect();
setMentionPos({
top: rect.bottom - editorRect.top + 4,
left: Math.max(0, rect.left - editorRect.left),
});
setShowMentionPopup(true);
}, []);
const extractText = useCallback(() => {
const el = editorRef.current;
if (!el) return;
setPrompt(el.textContent || '');
setEditorHtml(el.innerHTML);
}, [setPrompt, setEditorHtml]);
const handleInput = useCallback(() => {
extractText();
// Detect if user just typed @
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const node = range.startContainer;
if (node.nodeType !== Node.TEXT_NODE) return;
const text = node.textContent || '';
const offset = range.startOffset;
if (offset > 0 && text[offset - 1] === '@' && references.length > 0) {
// Remove the typed @
const before = text.substring(0, offset - 1);
const after = text.substring(offset);
node.textContent = before + after;
// Restore cursor
const newRange = document.createRange();
newRange.setStart(node, before.length);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
openMentionPopup();
}
}, [extractText, references.length, openMentionPopup]);
const insertMention = useCallback((ref: UploadedFile) => {
setShowMentionPopup(false);
const el = editorRef.current;
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
range.deleteContents();
// Create mention span
const mention = document.createElement('span');
mention.className = styles.mention;
mention.contentEditable = 'false';
mention.dataset.refId = ref.id;
mention.dataset.refType = ref.type;
mention.textContent = `@${ref.label}`;
// Insert mention + trailing space
range.insertNode(mention);
const space = document.createTextNode('\u00A0');
mention.after(space);
// Move cursor after space
const newRange = document.createRange();
newRange.setStartAfter(space);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
extractText();
}, [extractText]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (showMentionPopup && e.key === 'Escape') {
e.preventDefault();
setShowMentionPopup(false);
}
}, [showMentionPopup]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
}, []);
// Mention hover — delegated event
const handleMouseOver = useCallback((e: React.MouseEvent) => {
const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null;
if (!target) return;
const refId = target.dataset.refId;
const ref = references.find((r) => r.id === refId);
if (!ref) return;
const rect = target.getBoundingClientRect();
const wrapperRect = editorRef.current!.parentElement!.getBoundingClientRect();
setHoverRef(ref);
setHoverPos({
top: rect.top - wrapperRect.top - 8,
left: rect.left - wrapperRect.left + rect.width / 2,
});
}, [references]);
const handleMouseOut = useCallback((e: React.MouseEvent) => {
const target = (e.target as HTMLElement).closest('[data-ref-id]');
if (target) setHoverRef(null);
}, []);
// Close mention popup on click outside
useEffect(() => {
if (!showMentionPopup) return;
const handler = (e: MouseEvent) => {
const popup = document.querySelector(`.${styles.mentionPopup}`);
if (popup && !popup.contains(e.target as Node)) {
setShowMentionPopup(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showMentionPopup]);
// Compute available (un-mentioned) references
const getMentionedIds = () => {
const ids = new Set<string>();
const el = editorRef.current;
if (el) {
el.querySelectorAll('[data-ref-id]').forEach((span) => {
const id = (span as HTMLElement).dataset.refId;
if (id) ids.add(id);
});
}
return ids;
};
const mentionedIds = getMentionedIds();
const availableRefs = references.filter((r) => !mentionedIds.has(r.id));
const isEmpty = !prompt.trim() && !editorHtml;
return (
<div className={styles.wrapper}>
<textarea
ref={textareaRef}
className={styles.textarea}
rows={1}
placeholder={placeholders[mode]}
value={prompt}
onChange={handleChange}
{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}
/>
{/* Mention popup */}
{showMentionPopup && availableRefs.length > 0 && (
<div
className={styles.mentionPopup}
style={{ top: mentionPos.top, left: mentionPos.left }}
>
<div className={styles.mentionHeader}></div>
{availableRefs.map((ref) => (
<button
key={ref.id}
className={styles.mentionItem}
onMouseDown={(e) => {
e.preventDefault();
insertMention(ref);
}}
>
<div className={styles.mentionThumb}>
{ref.type === 'video' ? (
<video src={ref.previewUrl} muted className={styles.thumbMedia} />
) : (
<img src={ref.previewUrl} alt="" className={styles.thumbMedia} />
)}
</div>
<span className={styles.mentionLabel}>{ref.label}</span>
<span className={styles.mentionType}>
{ref.type === 'video' ? '视频' : '图片'}
</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={hoverRef.previewUrl}
alt={hoverRef.label}
className={styles.previewMedia}
/>
)}
<div className={styles.previewLabel}>{hoverRef.label}</div>
</div>
)}
</div>
);
}

View File

@ -67,12 +67,10 @@ const ChevronDown = () => (
const generationTypeItems = [
{ label: '视频生成', value: 'video' as GenerationType, icon: <VideoIcon /> },
{ label: '图片生成', value: 'image' as GenerationType, icon: <ImageIcon /> },
];
const modelItems = [
{ label: 'Seedance 2.0', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> },
{ label: 'Seedance 2.0 Fast', value: 'seedance_2.0_fast' as ModelOption, icon: <LightningIcon /> },
];
const modeItems = [
@ -140,38 +138,17 @@ export function Toolbar() {
return (
<div className={styles.toolbar}>
{/* Generation type dropdown */}
<Dropdown
items={generationTypeItems}
value={generationType}
onSelect={(v) => setGenerationType(v as GenerationType)}
minWidth={150}
trigger={
<button className={`${styles.btn} ${styles.primary}`}>
<VideoIcon />
<span className={styles.label}>
{generationType === 'video' ? '视频生成' : '图片生成'}
</span>
<ChevronDown />
</button>
}
/>
{/* Generation type — fixed to video */}
<button className={`${styles.btn} ${styles.primary}`}>
<VideoIcon />
<span className={styles.label}></span>
</button>
{/* Model selector dropdown */}
<Dropdown
items={modelItems}
value={model}
onSelect={(v) => setModel(v as ModelOption)}
minWidth={190}
trigger={
<button className={styles.btn}>
<DiamondIcon />
<span className={styles.label}>
{model === 'seedance_2.0' ? 'Seedance 2.0' : 'Seedance 2.0 Fast'}
</span>
</button>
}
/>
{/* Model — fixed to Seedance 2.0 */}
<button className={styles.btn}>
<DiamondIcon />
<span className={styles.label}>Seedance 2.0</span>
</button>
{/* Mode selector */}
<Dropdown