diff --git a/web/src/components/PromptInput.module.css b/web/src/components/PromptInput.module.css index ae4d945..0100c62 100644 --- a/web/src/components/PromptInput.module.css +++ b/web/src/components/PromptInput.module.css @@ -50,7 +50,7 @@ color: rgba(0, 184, 230, 0.9); } -/* Mention popup */ +/* Mention popup — appears above cursor */ .mentionPopup { position: absolute; z-index: 100; @@ -61,12 +61,13 @@ min-width: 200px; max-width: 280px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); - animation: fadeIn 0.12s ease; + transform: translateY(-100%); + animation: fadeInUp 0.12s ease; } -@keyframes fadeIn { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(-100%) translateY(4px); } + to { opacity: 1; transform: translateY(-100%); } } .mentionHeader { diff --git a/web/src/components/PromptInput.tsx b/web/src/components/PromptInput.tsx index 1ef8560..4dc6f82 100644 --- a/web/src/components/PromptInput.tsx +++ b/web/src/components/PromptInput.tsx @@ -20,6 +20,7 @@ export function PromptInput() { const editorRef = useRef(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(null); const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 }); @@ -41,6 +42,7 @@ export function PromptInput() { useEffect(() => { if (insertAtTrigger === 0) return; if (references.length === 0) return; + typedAtRef.current = false; openMentionPopup(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [insertAtTrigger]); @@ -51,16 +53,33 @@ export function PromptInput() { 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), - }); + // 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); }, []); @@ -84,18 +103,8 @@ export function PromptInput() { 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); - + // Keep the @ visible, open popup above it + typedAtRef.current = true; openMentionPopup(); } }, [extractText, references.length, openMentionPopup]); @@ -110,6 +119,23 @@ export function PromptInput() { 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 @@ -184,21 +210,6 @@ export function PromptInput() { 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 ( @@ -219,15 +230,15 @@ export function PromptInput() { /> {/* Mention popup */} - {showMentionPopup && availableRefs.length > 0 && ( + {showMentionPopup && references.length > 0 && (
-
选择引用素材
- {availableRefs.map((ref) => ( +
可能@的内容
+ {references.map((ref, idx) => (