diff --git a/web/src/components/PromptInput.module.css b/web/src/components/PromptInput.module.css index a2a25e9..ae4d945 100644 --- a/web/src/components/PromptInput.module.css +++ b/web/src/components/PromptInput.module.css @@ -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; } diff --git a/web/src/components/PromptInput.tsx b/web/src/components/PromptInput.tsx index 88bc7ec..1ef8560 100644 --- a/web/src/components/PromptInput.tsx +++ b/web/src/components/PromptInput.tsx @@ -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 = { - 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(null); + + const editorRef = useRef(null); + const [showMentionPopup, setShowMentionPopup] = useState(false); + const [mentionPos, setMentionPos] = useState({ top: 0, left: 0 }); + const [hoverRef, setHoverRef] = useState(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) => { - 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(); + 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 (
-