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);
}
/* 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 {

View File

@ -20,6 +20,7 @@ export function PromptInput() {
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 });
@ -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<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 (
@ -219,15 +230,15 @@ export function PromptInput() {
/>
{/* Mention popup */}
{showMentionPopup && availableRefs.length > 0 && (
{showMentionPopup && references.length > 0 && (
<div
className={styles.mentionPopup}
style={{ top: mentionPos.top, left: mentionPos.left }}
>
<div className={styles.mentionHeader}></div>
{availableRefs.map((ref) => (
<div className={styles.mentionHeader}>@的内容</div>
{references.map((ref, idx) => (
<button
key={ref.id}
key={`${ref.id}-${idx}`}
className={styles.mentionItem}
onMouseDown={(e) => {
e.preventDefault();