From f152aaeed944f71304fc9567876225c2e74b5236 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 18 Apr 2026 01:34:22 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=204=20mention=20chain=20bugs=20?= =?UTF-8?q?=E2=80=94=20cursor=20tracking,=20caret=20placement,=20undefined?= =?UTF-8?q?=20refs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix undefined setDraftAndNotify (replaced with onDraftChangeRef + ms.setValue) - Add DOM→ms.cursorOffset tracking on every render via TreeWalker - Fix mention insertion caret placement: skip MentionInput sync-effect caret restoration when parent sets value (via __mentionSkipSync window flag) - Remove unused _draftTick state Bug #1: setDraftAndNotify referenced but never defined → runtime crash Bug #2: ms.cursorOffset never updated from DOM → popover always at wrong position Bug #3: mention insertion saved pre-insertion caret, restored on longer HTML → cursor inside mention tag Bug #4: _draftTick declared but state value never read → dead code --- src/components/room/MentionInput.tsx | 26 ++++++++----- src/components/room/RoomChatPanel.tsx | 53 +++++++++++++++++++++------ 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/src/components/room/MentionInput.tsx b/src/components/room/MentionInput.tsx index e6bde6d..96e7cdc 100644 --- a/src/components/room/MentionInput.tsx +++ b/src/components/room/MentionInput.tsx @@ -171,19 +171,27 @@ export const MentionInput = forwardRef(functi if (currentText === internalValueRef.current) return; if (internalValueRef.current === value) return; - // Save cursor position ratio - const oldCaret = getCaretOffset(el); - const oldLen = internalValueRef.current.length; - const ratio = oldLen > 0 ? oldCaret / oldLen : 0; + // 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); + } // Update DOM el.innerHTML = value.trim() ? renderToHtml(value) : ''; - - // Restore cursor - const newLen = value.length; - const newCaret = Math.round(ratio * newLen); - setCaretAtOffset(el, Math.min(newCaret, newLen)); internalValueRef.current = value; + + // 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; + const newLen = value.length; + const newCaret = Math.round(ratio * newLen); + setCaretAtOffset(el, Math.min(newCaret, 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 */ diff --git a/src/components/room/RoomChatPanel.tsx b/src/components/room/RoomChatPanel.tsx index 05af213..7a19e5b 100644 --- a/src/components/room/RoomChatPanel.tsx +++ b/src/components/room/RoomChatPanel.tsx @@ -70,7 +70,8 @@ const ChatInputArea = memo(function ChatInputArea({ ref, }: ChatInputAreaProps) { const containerRef = useRef(null); - const [_draftTick, setDraftTick] = useState(0); + /** Flag: skip MentionInput's caret restoration on the next sync (used after mention insertion) */ + const skipCaretRestoreRef = useRef(false); // ─── Central mention state ─────────────────────────────────────────────── const ms = useMentionState( @@ -81,13 +82,29 @@ const ChatInputArea = memo(function ChatInputArea({ !!aiConfigsLoading, ); - // Sync external draft → mention state (with dirty flag to skip MentionInput onChange during init) + // Track DOM cursor offset → ms.cursorOffset on every render + const prevCursorRef = useRef(ms.cursorOffset); useEffect(() => { - if (ms.value === draft) return; - (window as any).__mentionSkipSync = true; - ms.setValue(draft); - setDraftTick(t => t + 1); - }, [draft, ms.value, ms.setValue]); + const el = containerRef.current; + if (!el) return; + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return; + const range = sel.getRangeAt(0); + const pre = range.cloneRange(); + pre.selectNodeContents(el); + pre.setEnd(range.startContainer, range.startOffset); + let count = 0; + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if (node === pre.startContainer) { count += pre.startOffset; break; } + count += node.length; + } + if (count !== prevCursorRef.current) { + prevCursorRef.current = count; + ms.setCursorOffset(count); + } + }); // ─── Imperative handle ────────────────────────────────────────────────── const onDraftChangeRef = useRef(onDraftChange); @@ -98,10 +115,14 @@ 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(() => containerRef.current?.focus(), 0); + setTimeout(() => { + skipCaretRestoreRef.current = false; + containerRef.current?.focus(); + }, 0); }, insertCategory: (category: string) => { const textBefore = draft.substring(0, ms.cursorOffset); @@ -128,10 +149,15 @@ 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(() => containerRef.current?.focus(), 0); + setTimeout(() => { + skipCaretRestoreRef.current = false; + containerRef.current?.focus(); + }, 0); }, [draft, ms]); // ─── mention-click handler (from message mentions) ───────────────────── @@ -141,9 +167,13 @@ 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(() => containerRef.current?.focus(), 0); + setTimeout(() => { + skipCaretRestoreRef.current = false; + containerRef.current?.focus(); + }, 0); }; document.addEventListener('mention-click', onMentionClick); return () => document.removeEventListener('mention-click', onMentionClick); @@ -220,7 +250,8 @@ const ChatInputArea = memo(function ChatInputArea({ const afterPartial = ms.value.substring(startPos + fullMatch.length); const newValue = before + '@' + category + ':' + afterPartial; const newCursorPos = startPos + 1 + category.length + 1; - setDraftAndNotify(newValue); + onDraftChangeRef.current(newValue); + ms.setValue(newValue); ms.setCursorOffset(newCursorPos); }} suggestions={ms.suggestions}