fix: @-mention autocomplete selection and dismissal

- Use a ref for mentionState so selectMention always reads the latest
  value (prevents stale-closure "blink" on click/Enter/Tab)
- Add explicit space key handling to dismiss the popup immediately
- Move Escape handler outside filteredMentions check so it always works
- Sync mentionStateRef on all state transitions

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

View File

@@ -138,8 +138,9 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const dragDepthRef = useRef(0); const dragDepthRef = useRef(0);
// Mention state // Mention state (ref kept in sync so callbacks always see the latest value)
const [mentionState, setMentionState] = useState<MentionState | null>(null); const [mentionState, setMentionState] = useState<MentionState | null>(null);
const mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0); const [mentionIndex, setMentionIndex] = useState(0);
const mentionActive = mentionState !== null && mentions && mentions.length > 0; const mentionActive = mentionState !== null && mentions && mentions.length > 0;
@@ -194,10 +195,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// Mention detection: listen for selection changes and input events // Mention detection: listen for selection changes and input events
const checkMention = useCallback(() => { const checkMention = useCallback(() => {
if (!mentions || mentions.length === 0 || !containerRef.current) { if (!mentions || mentions.length === 0 || !containerRef.current) {
mentionStateRef.current = null;
setMentionState(null); setMentionState(null);
return; return;
} }
const result = detectMention(containerRef.current); const result = detectMention(containerRef.current);
mentionStateRef.current = result;
if (result) { if (result) {
setMentionState(result); setMentionState(result);
setMentionIndex(0); setMentionIndex(0);
@@ -224,20 +227,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const selectMention = useCallback( const selectMention = useCallback(
(option: MentionOption) => { (option: MentionOption) => {
if (!mentionState) return; // Read from ref to avoid stale-closure issues (selectionchange can
// update state between the last render and this callback firing).
const state = mentionStateRef.current;
if (!state) return;
const current = latestValueRef.current; const current = latestValueRef.current;
const next = applyMention(current, mentionState.query, option); const next = applyMention(current, state.query, option);
if (next !== current) { if (next !== current) {
latestValueRef.current = next; latestValueRef.current = next;
ref.current?.setMarkdown(next); ref.current?.setMarkdown(next);
onChange(next); onChange(next);
} }
mentionStateRef.current = null;
setMentionState(null); setMentionState(null);
requestAnimationFrame(() => { requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" }); ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
}); });
}, },
[mentionState, onChange], [onChange],
); );
function hasFilePayload(evt: DragEvent<HTMLDivElement>) { function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
@@ -264,8 +271,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
return; return;
} }
// Mention keyboard navigation // Mention keyboard handling
if (mentionActive && filteredMentions.length > 0) { if (mentionActive) {
// Space dismisses the popup (let the character be typed normally)
if (e.key === " ") {
mentionStateRef.current = null;
setMentionState(null);
return;
}
// Escape always dismisses
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
mentionStateRef.current = null;
setMentionState(null);
return;
}
// Arrow / Enter / Tab only when there are filtered results
if (filteredMentions.length > 0) {
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -284,11 +307,6 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
selectMention(filteredMentions[mentionIndex]); selectMention(filteredMentions[mentionIndex]);
return; return;
} }
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setMentionState(null);
return;
} }
} }
}} }}