fix(room): fix Enter-on-category via React state update instead of DOM manipulation

Problem: dispatchEvent('input') doesn't trigger React's onChange in React 17+.

Solution: pass onCategoryEnter callback from ChatInputArea to MentionPopover.
When Enter is pressed on a category, MentionPopover calls onCategoryEnter(category)
which directly calls React setState (onDraftChange, setCursorPosition,
setShowMentionPopover) inside ChatInputArea, properly triggering re-render.
This commit is contained in:
ZhenYi 2026-04-18 00:51:14 +08:00
parent 14de80b24b
commit 17e878c8b8
2 changed files with 47 additions and 17 deletions

View File

@ -62,6 +62,8 @@ interface MentionPopoverProps {
onSelect: (newValue: string, newCursorPosition: number) => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
onOpenChange: (open: boolean) => void;
/** Called when Enter is pressed on a category — ChatInputArea handles the state update */
onCategoryEnter: (category: string) => void;
}
// Category configuration with icons and colors
@ -373,6 +375,7 @@ export function MentionPopover({
onSelect,
textareaRef,
onOpenChange,
onCategoryEnter,
}: MentionPopoverProps) {
const [position, setPosition] = useState<PopoverPosition>({ top: 0, left: 0 });
const [selectedIndex, setSelectedIndex] = useState(0);
@ -385,6 +388,8 @@ export function MentionPopover({
const selectedIndexRef = useRef(selectedIndex);
const handleSelectRef = useRef<(s: MentionSuggestion) => void>(() => {});
const closePopoverRef = useRef<() => void>(() => {});
const onCategoryEnterRef = useRef(onCategoryEnter);
onCategoryEnterRef.current = onCategoryEnter;
// Parse mention state
const mentionState = useMemo<MentionState | null>(() => {
@ -632,23 +637,9 @@ export function MentionPopover({
if (!item) return;
if (item.type === 'category') {
// Enter a category: append ':' to textarea to trigger next-level list
// Enter a category: let ChatInputArea handle the state update
e.preventDefault();
const category = item.category;
const textarea = textareaRef.current;
if (!textarea) return;
const curPos = textarea.selectionStart;
const val = textarea.value;
// Replace @xxx with @xxx: to enter the category
const textBefore = val.substring(0, ms.startPos);
const afterPartial = val.substring(ms.startPos + ms.category.length + 1); // skip '@' + category
const newVal = textBefore + '@' + category + ':' + afterPartial;
const newPos = ms.startPos + 1 + category.length + 1; // after '@ai:'
textarea.value = newVal;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
// Dispatch input event so React picks up the change
textarea.dispatchEvent(new Event('input', { bubbles: true }));
onCategoryEnterRef.current(item.category!);
} else if (item.type === 'item') {
// Insert the mention
e.preventDefault();

View File

@ -35,6 +35,8 @@ const MENTION_POPOVER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowDown'];
export interface ChatInputAreaHandle {
insertMention: (id: string, label: string, type: 'user' | 'ai') => void;
/** Insert a category prefix (e.g. 'ai') into the textarea and trigger React onChange */
insertCategory: (category: string) => void;
}
interface ChatInputAreaProps {
@ -131,6 +133,26 @@ const ChatInputArea = memo(function ChatInputArea({
}
}, 0);
},
insertCategory: (category: string) => {
// Enter a category: e.g. @ai → @ai:
if (!textareaRef.current) return;
const textarea = textareaRef.current;
const textBefore = textarea.value.substring(0, textarea.selectionStart);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = textarea.selectionStart - fullMatch.length;
const before = textarea.value.substring(0, startPos);
const afterPartial = textarea.value.substring(startPos + fullMatch.length);
const newValue = before + '@' + category + ':' + afterPartial;
const newCursorPos = startPos + 1 + category.length + 1; // after '@ai:'
// Directly update React state to trigger mention detection
onDraftChange(newValue);
setCursorPosition(newCursorPos);
const textBefore2 = newValue.substring(0, newCursorPos);
const match = textBefore2.match(MENTION_PATTERN);
setShowMentionPopover(!!match);
},
}));
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
@ -261,7 +283,24 @@ const ChatInputArea = memo(function ChatInputArea({
onSelect={handleMentionSelect}
textareaRef={textareaRef}
onOpenChange={setShowMentionPopover}
onMentionConfirm={handleMentionSelect}
onCategoryEnter={(category: string) => {
// Enter a category: @ai → @ai: to show next-level items
if (!textareaRef.current) return;
const textarea = textareaRef.current;
const textBefore = textarea.value.substring(0, textarea.selectionStart);
const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return;
const [fullMatch] = atMatch;
const startPos = textarea.selectionStart - fullMatch.length;
const before = textarea.value.substring(0, startPos);
const afterPartial = textarea.value.substring(startPos + fullMatch.length);
const newValue = before + '@' + category + ':' + afterPartial;
const newCursorPos = startPos + 1 + category.length + 1;
onDraftChange(newValue);
setCursorPosition(newCursorPos);
const match2 = newValue.substring(0, newCursorPos).match(MENTION_PATTERN);
setShowMentionPopover(!!match2);
}}
/>
)}
</div>