Fix @-mention tab completion cursor positioning
After tab-completing a mention, the cursor was placed before the
completion instead of after it. The root cause: Lexical's DOM
reconciliation after document.execCommand("insertText") would lose
the browser-set cursor position. Added requestAnimationFrame-based
cursor repositioning that first tries the original text node, then
falls back to searching for the mention text in the DOM.
Also normalizes editor content padding for consistent mention dropdown
positioning.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -231,18 +231,77 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
// update state between the last render and this callback firing).
|
||||
const state = mentionStateRef.current;
|
||||
if (!state) return;
|
||||
const current = latestValueRef.current;
|
||||
const next = applyMention(current, state.query, option);
|
||||
if (next !== current) {
|
||||
latestValueRef.current = next;
|
||||
ref.current?.setMarkdown(next);
|
||||
onChange(next);
|
||||
|
||||
const replacement = `@${option.name} `;
|
||||
|
||||
// Replace @query directly via DOM selection so the cursor naturally
|
||||
// lands after the inserted text. Lexical picks up the change through
|
||||
// its normal input-event handling.
|
||||
const sel = window.getSelection();
|
||||
if (sel && state.textNode.isConnected) {
|
||||
const range = document.createRange();
|
||||
range.setStart(state.textNode, state.atPos);
|
||||
range.setEnd(state.textNode, state.endPos);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
document.execCommand("insertText", false, replacement);
|
||||
|
||||
// After Lexical reconciles the DOM, the cursor position set by
|
||||
// execCommand may be lost. Explicitly reposition it after the
|
||||
// inserted mention text.
|
||||
const cursorTarget = state.atPos + replacement.length;
|
||||
requestAnimationFrame(() => {
|
||||
const newSel = window.getSelection();
|
||||
if (!newSel) return;
|
||||
// Try the original text node first (it may still be valid)
|
||||
if (state.textNode.isConnected) {
|
||||
const len = state.textNode.textContent?.length ?? 0;
|
||||
if (cursorTarget <= len) {
|
||||
const r = document.createRange();
|
||||
r.setStart(state.textNode, cursorTarget);
|
||||
r.collapse(true);
|
||||
newSel.removeAllRanges();
|
||||
newSel.addRange(r);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback: search for the replacement in text nodes
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
if (!editable) return;
|
||||
const walker = document.createTreeWalker(editable, NodeFilter.SHOW_TEXT);
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
const text = node.textContent ?? "";
|
||||
const idx = text.indexOf(replacement);
|
||||
if (idx !== -1) {
|
||||
const pos = idx + replacement.length;
|
||||
if (pos <= text.length) {
|
||||
const r = document.createRange();
|
||||
r.setStart(node, pos);
|
||||
r.collapse(true);
|
||||
newSel.removeAllRanges();
|
||||
newSel.addRange(r);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback: full markdown replacement when DOM node is stale
|
||||
const current = latestValueRef.current;
|
||||
const next = applyMention(current, state.query, option);
|
||||
if (next !== current) {
|
||||
latestValueRef.current = next;
|
||||
ref.current?.setMarkdown(next);
|
||||
onChange(next);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
});
|
||||
}
|
||||
|
||||
mentionStateRef.current = null;
|
||||
setMentionState(null);
|
||||
requestAnimationFrame(() => {
|
||||
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user