fix: @-mention autocomplete — Enter/Tab/click now insert correctly

The old approach used document.execCommand("insertText") to directly
manipulate the contentEditable DOM, but MDXEditor (Lexical) reverted
these changes during reconciliation causing the "blink" bug.

Fix: work at the markdown string level instead — find the @query in
the markdown, replace it with @Name, and update via setMarkdown().
Also add an input event listener alongside selectionchange for more
reliable mention detection (fixes space-to-dismiss).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-20 14:53:46 -06:00
parent 2d2906f23f
commit 735bea5dee

View File

@@ -108,17 +108,13 @@ function detectMention(container: HTMLElement): MentionState | null {
}; };
} }
function insertMention(state: MentionState, option: MentionOption) { /** Replace `@<query>` in the markdown string with `@<Name> `. */
const range = document.createRange(); function applyMention(markdown: string, query: string, option: MentionOption): string {
range.setStart(state.textNode, state.atPos); const search = `@${query}`;
range.setEnd(state.textNode, state.endPos); const replacement = `@${option.name} `;
const idx = markdown.lastIndexOf(search);
const sel = window.getSelection(); if (idx === -1) return markdown;
sel?.removeAllRanges(); return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length);
sel?.addRange(range);
// insertText preserves undo stack and triggers editor onChange
document.execCommand("insertText", false, `@${option.name} `);
} }
/* ---- Component ---- */ /* ---- Component ---- */
@@ -213,17 +209,35 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
useEffect(() => { useEffect(() => {
if (!mentions || mentions.length === 0) return; if (!mentions || mentions.length === 0) return;
const el = containerRef.current;
// Listen for input events on the container so mention detection
// also fires after typing (e.g. space to dismiss).
const onInput = () => requestAnimationFrame(checkMention);
document.addEventListener("selectionchange", checkMention); document.addEventListener("selectionchange", checkMention);
return () => document.removeEventListener("selectionchange", checkMention); el?.addEventListener("input", onInput, true);
return () => {
document.removeEventListener("selectionchange", checkMention);
el?.removeEventListener("input", onInput, true);
};
}, [checkMention, mentions]); }, [checkMention, mentions]);
const selectMention = useCallback( const selectMention = useCallback(
(option: MentionOption) => { (option: MentionOption) => {
if (!mentionState) return; if (!mentionState) return;
insertMention(mentionState, option); const current = latestValueRef.current;
const next = applyMention(current, mentionState.query, option);
if (next !== current) {
latestValueRef.current = next;
ref.current?.setMarkdown(next);
onChange(next);
}
setMentionState(null); setMentionState(null);
requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
});
}, },
[mentionState], [mentionState, onChange],
); );
function hasFilePayload(evt: DragEvent<HTMLDivElement>) { function hasFilePayload(evt: DragEvent<HTMLDivElement>) {