fix: @ mention popup appears above cursor with correct positioning
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m47s
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:
parent
7b804df30f
commit
dd7b693c0b
@ -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 {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user