fix: @ mention popup appears above cursor with correct positioning
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m47s

- 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>
This commit is contained in:
zyc 2026-03-13 11:14:37 +08:00
parent 7b804df30f
commit dd7b693c0b
2 changed files with 56 additions and 44 deletions

View File

@ -50,7 +50,7 @@
color: rgba(0, 184, 230, 0.9); color: rgba(0, 184, 230, 0.9);
} }
/* Mention popup */ /* Mention popup — appears above cursor */
.mentionPopup { .mentionPopup {
position: absolute; position: absolute;
z-index: 100; z-index: 100;
@ -61,12 +61,13 @@
min-width: 200px; min-width: 200px;
max-width: 280px; max-width: 280px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); 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 { @keyframes fadeInUp {
from { opacity: 0; transform: translateY(-4px); } from { opacity: 0; transform: translateY(-100%) translateY(4px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(-100%); }
} }
.mentionHeader { .mentionHeader {

View File

@ -20,6 +20,7 @@ export function PromptInput() {
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const [showMentionPopup, setShowMentionPopup] = useState(false); const [showMentionPopup, setShowMentionPopup] = useState(false);
const [mentionPos, setMentionPos] = useState({ top: 0, left: 0 }); 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 [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 }); const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
@ -41,6 +42,7 @@ export function PromptInput() {
useEffect(() => { useEffect(() => {
if (insertAtTrigger === 0) return; if (insertAtTrigger === 0) return;
if (references.length === 0) return; if (references.length === 0) return;
typedAtRef.current = false;
openMentionPopup(); openMentionPopup();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [insertAtTrigger]); }, [insertAtTrigger]);
@ -51,16 +53,33 @@ export function PromptInput() {
el.focus(); el.focus();
const sel = window.getSelection(); const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
const editorRect = el.getBoundingClientRect(); const editorRect = el.getBoundingClientRect();
setMentionPos({ // Position popup above cursor line
top: rect.bottom - editorRect.top + 4, let top = 0; // fallback: top of editor
left: Math.max(0, rect.left - editorRect.left), 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); setShowMentionPopup(true);
}, []); }, []);
@ -84,18 +103,8 @@ export function PromptInput() {
const text = node.textContent || ''; const text = node.textContent || '';
const offset = range.startOffset; const offset = range.startOffset;
if (offset > 0 && text[offset - 1] === '@' && references.length > 0) { if (offset > 0 && text[offset - 1] === '@' && references.length > 0) {
// Remove the typed @ // Keep the @ visible, open popup above it
const before = text.substring(0, offset - 1); typedAtRef.current = true;
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(); openMentionPopup();
} }
}, [extractText, references.length, openMentionPopup]); }, [extractText, references.length, openMentionPopup]);
@ -110,6 +119,23 @@ export function PromptInput() {
if (!sel || sel.rangeCount === 0) return; if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0); 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(); range.deleteContents();
// Create mention span // Create mention span
@ -184,21 +210,6 @@ export function PromptInput() {
return () => document.removeEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler);
}, [showMentionPopup]); }, [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; const isEmpty = !prompt.trim() && !editorHtml;
return ( return (
@ -219,15 +230,15 @@ export function PromptInput() {
/> />
{/* Mention popup */} {/* Mention popup */}
{showMentionPopup && availableRefs.length > 0 && ( {showMentionPopup && references.length > 0 && (
<div <div
className={styles.mentionPopup} className={styles.mentionPopup}
style={{ top: mentionPos.top, left: mentionPos.left }} style={{ top: mentionPos.top, left: mentionPos.left }}
> >
<div className={styles.mentionHeader}></div> <div className={styles.mentionHeader}>@的内容</div>
{availableRefs.map((ref) => ( {references.map((ref, idx) => (
<button <button
key={ref.id} key={`${ref.id}-${idx}`}
className={styles.mentionItem} className={styles.mentionItem}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();