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 type { ProjectRepositoryItem, RoomMemberResponse } from '@/client';
|
||||||
import { buildMentionHtml, type MentionMentionType } from '@/lib/mention-ast';
|
import { buildMentionHtml, type MentionMentionType } from '@/lib/mention-ast';
|
||||||
|
import { mentionSelectedIdxRef, mentionVisibleRef } from '@/lib/mention-refs';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
@ -63,6 +64,8 @@ interface MentionPopoverProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
/** Mutable ref: write the confirm fn here so ChatInputArea can call it on Enter */
|
/** Mutable ref: write the confirm fn here so ChatInputArea can call it on Enter */
|
||||||
onMentionConfirmRef?: React.MutableRefObject<() => void>;
|
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
|
||||||
@ -375,6 +378,7 @@ export function MentionPopover({
|
|||||||
textareaRef,
|
textareaRef,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
onMentionConfirmRef,
|
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);
|
||||||
@ -480,6 +484,8 @@ export function MentionPopover({
|
|||||||
|
|
||||||
const visibleSuggestions = suggestions;
|
const visibleSuggestions = suggestions;
|
||||||
visibleSuggestionsRef.current = visibleSuggestions;
|
visibleSuggestionsRef.current = visibleSuggestions;
|
||||||
|
mentionSelectedIdxRef.current = selectedIndex;
|
||||||
|
mentionVisibleRef.current = visibleSuggestions;
|
||||||
|
|
||||||
// Auto-select first item
|
// Auto-select first item
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { useRoomDraft } from '@/hooks/useRoomDraft';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { cn } from '@/lib/utils';
|
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 { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
@ -29,8 +31,8 @@ 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'];
|
||||||
/** Module-level ref shared between ChatInputArea (writes) and RoomChatPanel (reads) */
|
export const mentionConfirmRef = { current: (() => {}) as () => void };
|
||||||
const mentionConfirmRef = { current: (() => {}) as () => void };
|
|
||||||
|
|
||||||
export interface ChatInputAreaHandle {
|
export interface ChatInputAreaHandle {
|
||||||
insertMention: (id: string, label: string, type: 'user' | 'ai') => void;
|
insertMention: (id: string, label: string, type: 'user' | 'ai') => void;
|
||||||
@ -76,7 +78,32 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const [cursorPosition, setCursorPosition] = useState(0);
|
||||||
const [showMentionPopover, setShowMentionPopover] = useState(false);
|
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);
|
onDraftChange(newValue);
|
||||||
setShowMentionPopover(false);
|
setShowMentionPopover(false);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -86,7 +113,8 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
textareaRef.current.focus();
|
textareaRef.current.focus();
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [onDraftChange]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // Intentionally use refs, not state
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
insertMention: (id: string, label: string) => {
|
insertMention: (id: string, label: string) => {
|
||||||
@ -126,7 +154,12 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
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();
|
e.preventDefault();
|
||||||
if ((e.key === 'Enter' || e.key === 'Tab') && onMentionConfirm) {
|
if ((e.key === 'Enter' || e.key === 'Tab') && onMentionConfirm) {
|
||||||
onMentionConfirm();
|
onMentionConfirm();
|
||||||
@ -206,7 +239,7 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
onSelect={handleMentionSelect}
|
onSelect={handleMentionSelect}
|
||||||
textareaRef={textareaRef}
|
textareaRef={textareaRef}
|
||||||
onOpenChange={setShowMentionPopover}
|
onOpenChange={setShowMentionPopover}
|
||||||
onMentionConfirmRef={mentionConfirmRef}
|
onMentionConfirm={handleMentionSelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -576,7 +609,7 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
|
|||||||
draft={draft}
|
draft={draft}
|
||||||
onDraftChange={setDraft}
|
onDraftChange={setDraft}
|
||||||
onClearDraft={clearDraft}
|
onClearDraft={clearDraft}
|
||||||
onMentionConfirm={() => mentionConfirmRef.current()}
|
onMentionConfirm={() => { console.log('[RoomChatPanel] onMentionConfirm called'); mentionConfirmRef.current(); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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