fix(room): make handleSelect read DOM directly instead of stale props

Root cause: MentionPopover's native keydown listener fires before React
state updates, so handleSelect read stale inputValue/cursorPosition props
and silently returned early.

Fix: handleSelect now reads textarea.value/selectionStart directly from
the DOM, matching the approach already used in ChatInputArea's
handleMentionSelect. No more stale closure.
This commit is contained in:
ZhenYi 2026-04-18 00:35:21 +08:00
parent 3cd5b3003c
commit b8a61b0802
2 changed files with 18 additions and 28 deletions

View File

@ -62,10 +62,6 @@ interface MentionPopoverProps {
onSelect: (newValue: string, newCursorPosition: number) => void; onSelect: (newValue: string, newCursorPosition: number) => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>; textareaRef: React.RefObject<HTMLTextAreaElement | null>;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** Mutable ref: write the confirm fn here so ChatInputArea can call it on Enter */
onMentionConfirmRef?: React.MutableRefObject<() => void>;
/** Simpler alternative to onMentionConfirmRef — parent provides a callback directly */
onMentionConfirm?: () => void;
} }
// Category configuration with icons and colors // Category configuration with icons and colors
@ -377,8 +373,6 @@ export function MentionPopover({
onSelect, onSelect,
textareaRef, textareaRef,
onOpenChange, onOpenChange,
onMentionConfirmRef,
onMentionConfirm,
}: MentionPopoverProps) { }: MentionPopoverProps) {
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 }); const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 });
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
@ -509,11 +503,20 @@ export function MentionPopover({
const handleSelect = useCallback( const handleSelect = useCallback(
(suggestion: MentionSuggestion) => { (suggestion: MentionSuggestion) => {
const ms = mentionStateRef.current; if (!textareaRef.current || suggestion.type !== 'item') return;
if (!ms || suggestion.type !== 'item') return;
const textarea = textareaRef.current;
const cursorPos = textarea.selectionStart;
const textBefore = textarea.value.substring(0, cursorPos);
const atMatch = textBefore.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = cursorPos - fullMatch.length;
const before = textarea.value.substring(0, startPos);
const after = textarea.value.substring(cursorPos);
const before = inputValue.slice(0, ms.startPos);
const after = inputValue.slice(cursorPosition);
const html = buildMentionHtml( const html = buildMentionHtml(
suggestion.category!, suggestion.category!,
suggestion.mentionId!, suggestion.mentionId!,
@ -521,23 +524,17 @@ export function MentionPopover({
); );
const spacer = ' '; const spacer = ' ';
const newValue = before + html + spacer + after; const newValue = before + html + spacer + after;
const newCursorPos = ms.startPos + html.length + spacer.length; const newCursorPos = startPos + html.length + spacer.length;
onSelect(newValue, newCursorPos); onSelect(newValue, newCursorPos);
closePopover(); closePopover();
}, },
[inputValue, cursorPosition, onSelect, closePopover], // eslint-disable-next-line react-hooks/exhaustive-deps
[onSelect, closePopover],
); );
handleSelectRef.current = handleSelect; handleSelectRef.current = handleSelect;
// Register confirm fn so ChatInputArea can call it on Enter
if (onMentionConfirmRef) {
onMentionConfirmRef.current = () => {
const item = visibleSuggestionsRef.current[selectedIndexRef.current];
if (item) handleSelectRef.current(item);
};
}
// Position calculation // Position calculation
useEffect(() => { useEffect(() => {
if (!mentionState || !textareaRef.current) return; if (!mentionState || !textareaRef.current) return;

View File

@ -31,7 +31,6 @@ import { RoomThreadPanel } from './RoomThreadPanel';
const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/; const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/;
const MENTION_POPOVER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowDown']; const MENTION_POPOVER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowDown'];
export const mentionConfirmRef = { current: (() => {}) as () => void };
export interface ChatInputAreaHandle { export interface ChatInputAreaHandle {
@ -52,8 +51,6 @@ interface ChatInputAreaProps {
draft: string; draft: string;
onDraftChange: (content: string) => void; onDraftChange: (content: string) => void;
onClearDraft: () => void; onClearDraft: () => void;
/** Called when Enter is pressed with mention popover open — parent triggers mention selection */
onMentionConfirm?: () => void;
ref?: React.Ref<ChatInputAreaHandle>; ref?: React.Ref<ChatInputAreaHandle>;
} }
@ -71,7 +68,6 @@ const ChatInputArea = memo(function ChatInputArea({
draft, draft,
onDraftChange, onDraftChange,
onClearDraft, onClearDraft,
onMentionConfirm,
ref, ref,
}: ChatInputAreaProps) { }: ChatInputAreaProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
@ -161,9 +157,7 @@ const ChatInputArea = memo(function ChatInputArea({
if (hasMention && MENTION_POPOVER_KEYS.includes(e.key) && !e.ctrlKey && !e.shiftKey) { if (hasMention && MENTION_POPOVER_KEYS.includes(e.key) && !e.ctrlKey && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
if ((e.key === 'Enter' || e.key === 'Tab') && onMentionConfirm) { // Enter/Tab is handled by MentionPopover's native keydown listener
onMentionConfirm();
}
return; return;
} }
@ -609,7 +603,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
draft={draft} draft={draft}
onDraftChange={setDraft} onDraftChange={setDraft}
onClearDraft={clearDraft} onClearDraft={clearDraft}
onMentionConfirm={() => { console.log('[RoomChatPanel] onMentionConfirm called'); mentionConfirmRef.current(); }}
/> />
</div> </div>