From 5103d99a00c3739ca2898cc3e4d00398dcf257ef Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 18 Apr 2026 11:20:48 +0800 Subject: [PATCH] fix(frontend): rewrite MentionInput sync to prevent onChange overwriting mention draft The root cause: handleMentionSelect set draft to mention HTML, but the subsequent MentionInput.onInput event fired with plain text (after the programmatic DOM insert) and overwrote the draft. Solution: replace pendingSyncRef trick with a clean isUserInputRef flag. - useEffect: if getPlainText(el) === value, DOM already correct (skip). Mark isUserInputRef = false so next useEffect skips caret restore. - handleInput: always set isUserInputRef = true before calling onChange. This eliminates the pendingSyncRef/__mentionSkipSync global flag mess entirely. --- src/components/room/MentionInput.tsx | 41 ++++++++++++--------------- src/components/room/RoomChatPanel.tsx | 21 ++------------ 2 files changed, 21 insertions(+), 41 deletions(-) diff --git a/src/components/room/MentionInput.tsx b/src/components/room/MentionInput.tsx index 96e7cdc..e12681f 100644 --- a/src/components/room/MentionInput.tsx +++ b/src/components/room/MentionInput.tsx @@ -147,7 +147,6 @@ export const MentionInput = forwardRef(functi onSend, }, ref) { const containerRef = useRef(null); - const pendingSyncRef = useRef(false); const internalValueRef = useRef(value); // Merge forwarded ref with internal ref @@ -158,40 +157,36 @@ export const MentionInput = forwardRef(functi } }, [ref]); + /** Track whether the last DOM update was from user input (vs programmatic) */ + const isUserInputRef = useRef(true); + /** Sync external value changes into the DOM */ useEffect(() => { - if (pendingSyncRef.current) { - pendingSyncRef.current = false; - return; - } const el = containerRef.current; if (!el) return; - const currentText = getPlainText(el); - if (currentText === internalValueRef.current) return; - if (internalValueRef.current === value) return; - - // Save cursor position ratio (skip if this is a mention-insertion update from parent) - const skipCaretRestore = (window as any).__mentionSkipSync; - let oldCaret = 0; - if (!skipCaretRestore) { - oldCaret = getCaretOffset(el); + // If the DOM already contains what we want, nothing to do. + // (Programmatic updates via onChange already set the DOM correctly.) + if (getPlainText(el) === value) { + isUserInputRef.current = false; + return; } + // Save cursor position ratio for non-user-input updates + const oldCaret = isUserInputRef.current ? getCaretOffset(el) : 0; + const oldLen = internalValueRef.current.length; + // Update DOM el.innerHTML = value.trim() ? renderToHtml(value) : ''; internalValueRef.current = value; + isUserInputRef.current = false; - // Restore cursor only for non-mention updates - if (!skipCaretRestore) { - const oldLen = internalValueRef.current.length - (value.length - currentText.length); // approximate - const ratio = oldLen > 0 ? oldCaret / oldLen : 0; + // Restore caret for non-mention user updates + if (oldLen > 0) { + const ratio = oldCaret / oldLen; const newLen = value.length; - const newCaret = Math.round(ratio * newLen); - setCaretAtOffset(el, Math.min(newCaret, newLen)); + setCaretAtOffset(el, Math.round(ratio * newLen)); } - // Clean up the global flag - if (skipCaretRestore) (window as any).__mentionSkipSync = false; }, [value]); /** Handle input changes — extracts plain text from DOM and sends to parent */ @@ -199,8 +194,8 @@ export const MentionInput = forwardRef(functi const el = containerRef.current; if (!el) return; const newText = getPlainText(el); - pendingSyncRef.current = true; internalValueRef.current = newText; + isUserInputRef.current = true; onChange(newText); }, [onChange]); diff --git a/src/components/room/RoomChatPanel.tsx b/src/components/room/RoomChatPanel.tsx index fcfe7e4..972b477 100644 --- a/src/components/room/RoomChatPanel.tsx +++ b/src/components/room/RoomChatPanel.tsx @@ -71,8 +71,6 @@ const ChatInputArea = memo(function ChatInputArea({ ref, }: ChatInputAreaProps) { const containerRef = useRef(null); - /** Flag: skip MentionInput's caret restoration on the next sync (used after mention insertion) */ - const skipCaretRestoreRef = useRef(false); // ─── Central mention state ─────────────────────────────────────────────── const ms = useMentionState( @@ -116,14 +114,10 @@ const ChatInputArea = memo(function ChatInputArea({ const insertion = ms.buildInsertionAt('user', id, label); const before = draft.substring(0, insertion.startPos); const newValue = before + insertion.html + ' ' + draft.substring(insertion.startPos); - skipCaretRestoreRef.current = true; onDraftChangeRef.current(newValue); ms.setValue(newValue); ms.setShowMentionPopover(false); - setTimeout(() => { - skipCaretRestoreRef.current = false; - containerRef.current?.focus(); - }, 0); + setTimeout(() => containerRef.current?.focus(), 0); }, insertCategory: (category: string) => { const textBefore = draft.substring(0, ms.cursorOffset); @@ -150,15 +144,10 @@ const ChatInputArea = memo(function ChatInputArea({ const insertion = ms.buildInsertionAt(suggestion.category, suggestion.mentionId, suggestion.label); const before = draft.substring(0, insertion.startPos); const newValue = before + insertion.html + ' ' + draft.substring(ms.cursorOffset); - // Skip MentionInput's caret restoration — DOM is already at the right place - skipCaretRestoreRef.current = true; onDraftChangeRef.current(newValue); ms.setValue(newValue); ms.setShowMentionPopover(false); - setTimeout(() => { - skipCaretRestoreRef.current = false; - containerRef.current?.focus(); - }, 0); + setTimeout(() => containerRef.current?.focus(), 0); }, [draft, ms]); // ─── Auto-show/hide popover when @ context appears ──────────────────── @@ -177,13 +166,9 @@ const ChatInputArea = memo(function ChatInputArea({ const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label); const spacer = ' '; const newValue = draft.substring(0, ms.cursorOffset) + html + spacer + draft.substring(ms.cursorOffset); - skipCaretRestoreRef.current = true; onDraftChangeRef.current(newValue); ms.setValue(newValue); - setTimeout(() => { - skipCaretRestoreRef.current = false; - containerRef.current?.focus(); - }, 0); + setTimeout(() => containerRef.current?.focus(), 0); }; document.addEventListener('mention-click', onMentionClick); return () => document.removeEventListener('mention-click', onMentionClick);