fix(room): fix mention Enter key by reading textarea DOM directly
Problem: showMentionPopover state is stale when handleKeyDown fires immediately after handleChange (both in same event loop), causing Enter to be silently swallowed. Solution: - Read textarea.value directly in handleKeyDown to detect @ mentions - Module-level refs (mentionSelectedIdxRef, mentionVisibleRef) share selection state between MentionPopover and ChatInputArea - handleMentionSelect reads DOM instead of relying on props state
This commit is contained in:
parent
13f5ff328c
commit
3cd5b3003c
@ -1,5 +1,6 @@
|
||||
import type { ProjectRepositoryItem, RoomMemberResponse } from '@/client';
|
||||
import { buildMentionHtml, type MentionMentionType } from '@/lib/mention-ast';
|
||||
import { mentionSelectedIdxRef, mentionVisibleRef } from '@/lib/mention-refs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@ -63,6 +64,8 @@ interface MentionPopoverProps {
|
||||
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
|
||||
@ -375,6 +378,7 @@ export function MentionPopover({
|
||||
textareaRef,
|
||||
onOpenChange,
|
||||
onMentionConfirmRef,
|
||||
onMentionConfirm,
|
||||
}: MentionPopoverProps) {
|
||||
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 });
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
@ -480,6 +484,8 @@ export function MentionPopover({
|
||||
|
||||
const visibleSuggestions = suggestions;
|
||||
visibleSuggestionsRef.current = visibleSuggestions;
|
||||
mentionSelectedIdxRef.current = selectedIndex;
|
||||
mentionVisibleRef.current = visibleSuggestions;
|
||||
|
||||
// Auto-select first item
|
||||
useEffect(() => {
|
||||
|
||||
@ -5,6 +5,8 @@ import { useRoomDraft } from '@/hooks/useRoomDraft';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildMentionHtml } from '@/lib/mention-ast';
|
||||
import { mentionSelectedIdxRef, mentionVisibleRef } from '@/lib/mention-refs';
|
||||
import { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
|
||||
import {
|
||||
memo,
|
||||
@ -29,8 +31,8 @@ import { RoomThreadPanel } from './RoomThreadPanel';
|
||||
|
||||
const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/;
|
||||
const MENTION_POPOVER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowDown'];
|
||||
/** Module-level ref shared between ChatInputArea (writes) and RoomChatPanel (reads) */
|
||||
const mentionConfirmRef = { current: (() => {}) as () => void };
|
||||
export const mentionConfirmRef = { current: (() => {}) as () => void };
|
||||
|
||||
|
||||
export interface ChatInputAreaHandle {
|
||||
insertMention: (id: string, label: string, type: 'user' | 'ai') => void;
|
||||
@ -76,7 +78,32 @@ const ChatInputArea = memo(function ChatInputArea({
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
const [showMentionPopover, setShowMentionPopover] = useState(false);
|
||||
|
||||
const handleMentionSelect = useCallback((newValue: string, newCursorPos: number) => {
|
||||
const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => {
|
||||
if (!textareaRef.current) return;
|
||||
const textarea = textareaRef.current;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const textBefore = textarea.value.substring(0, cursorPos);
|
||||
const atMatch = textBefore.match(MENTION_PATTERN);
|
||||
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 suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
|
||||
if (!suggestion || suggestion.type !== 'item') return;
|
||||
|
||||
const html = buildMentionHtml(
|
||||
suggestion.category!,
|
||||
suggestion.mentionId!,
|
||||
suggestion.label,
|
||||
);
|
||||
const spacer = ' ';
|
||||
const newValue = before + html + spacer + after;
|
||||
const newCursorPos = startPos + html.length + spacer.length;
|
||||
|
||||
onDraftChange(newValue);
|
||||
setShowMentionPopover(false);
|
||||
setTimeout(() => {
|
||||
@ -86,7 +113,8 @@ const ChatInputArea = memo(function ChatInputArea({
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, 0);
|
||||
}, [onDraftChange]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Intentionally use refs, not state
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
insertMention: (id: string, label: string) => {
|
||||
@ -126,7 +154,12 @@ const ChatInputArea = memo(function ChatInputArea({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showMentionPopover && MENTION_POPOVER_KEYS.includes(e.key) && !e.ctrlKey && !e.shiftKey) {
|
||||
// Detect mention directly from DOM — avoids stale React state between handleChange and handleKeyDown
|
||||
const hasMention = textareaRef.current
|
||||
? textareaRef.current.value.substring(0, textareaRef.current.selectionStart).match(MENTION_PATTERN) !== null
|
||||
: false;
|
||||
|
||||
if (hasMention && MENTION_POPOVER_KEYS.includes(e.key) && !e.ctrlKey && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if ((e.key === 'Enter' || e.key === 'Tab') && onMentionConfirm) {
|
||||
onMentionConfirm();
|
||||
@ -206,7 +239,7 @@ const ChatInputArea = memo(function ChatInputArea({
|
||||
onSelect={handleMentionSelect}
|
||||
textareaRef={textareaRef}
|
||||
onOpenChange={setShowMentionPopover}
|
||||
onMentionConfirmRef={mentionConfirmRef}
|
||||
onMentionConfirm={handleMentionSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -576,7 +609,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
onClearDraft={clearDraft}
|
||||
onMentionConfirm={() => mentionConfirmRef.current()}
|
||||
onMentionConfirm={() => { console.log('[RoomChatPanel] onMentionConfirm called'); mentionConfirmRef.current(); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
9
src/lib/mention-refs.ts
Normal file
9
src/lib/mention-refs.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { MentionSuggestion } from '@/components/room/MentionPopover';
|
||||
|
||||
/**
|
||||
* Module-level refs shared between ChatInputArea and MentionPopover.
|
||||
* Avoids stale closure / TDZ issues when components are defined in different
|
||||
* parts of the same file.
|
||||
*/
|
||||
export const mentionSelectedIdxRef = { current: 0 };
|
||||
export const mentionVisibleRef = { current: [] as MentionSuggestion[] };
|
||||
Loading…
Reference in New Issue
Block a user