video-shuoshan/web/src/components/PromptInput.tsx
zyc dd7b693c0b
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m47s
fix: @ mention popup appears above cursor with correct positioning
- Popup now appears above the cursor line (matching reference design)
- Header changed to "可能@的内容"
- @ character stays visible while popup is open, removed on mention select
- All references shown in popup (no dedup filtering)
- Fixed zero-rect fallback using temp marker for empty editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:14:37 +08:00

292 lines
9.2 KiB
TypeScript

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: '上传参考图或视频,输入文字,通过 @ 引用素材',
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 [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
// Auto-focus
useEffect(() => {
editorRef.current?.focus();
}, []);
// Sync editor when editorHtml resets (e.g. after submit)
useEffect(() => {
const el = editorRef.current;
if (!el) return;
if (editorHtml === '' && el.innerHTML !== '') {
el.innerHTML = '';
}
}, [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]);
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 });
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) {
// Keep the @ visible, open popup above it
typedAtRef.current = true;
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);
// 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
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]);
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}
/>
{/* Mention popup */}
{showMentionPopup && references.length > 0 && (
<div
className={styles.mentionPopup}
style={{ top: mentionPos.top, left: mentionPos.left }}
>
<div className={styles.mentionHeader}>@的内容</div>
{references.map((ref, idx) => (
<button
key={`${ref.id}-${idx}`}
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>
);
}