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 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 mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(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
const checkMention = useCallback(() => {
if (!mentions || mentions.length === 0 || !containerRef.current) {
mentionStateRef.current = null;
setMentionState(null);
return;
}
const result = detectMention(containerRef.current);
mentionStateRef.current = result;
if (result) {
setMentionState(result);
setMentionIndex(0);
@@ -224,20 +227,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const selectMention = useCallback(
(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 next = applyMention(current, mentionState.query, option);
const next = applyMention(current, state.query, option);
if (next !== current) {
latestValueRef.current = next;
ref.current?.setMarkdown(next);
onChange(next);
}
mentionStateRef.current = null;
setMentionState(null);
requestAnimationFrame(() => {
ref.current?.focus(undefined, { defaultSelection: "rootEnd" });
});
},
[mentionState, onChange],
[onChange],
);
function hasFilePayload(evt: DragEvent<HTMLDivElement>) {
@@ -264,32 +271,43 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
return;
}
// Mention keyboard navigation
if (mentionActive && filteredMentions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
e.stopPropagation();
setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
setMentionIndex((prev) => Math.max(prev - 1, 0));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
e.stopPropagation();
selectMention(filteredMentions[mentionIndex]);
// Mention keyboard handling
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") {
e.preventDefault();
e.stopPropagation();
setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
setMentionIndex((prev) => Math.max(prev - 1, 0));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
e.stopPropagation();
selectMention(filteredMentions[mentionIndex]);
return;
}
}
}
}}
onDragEnter={(evt) => {