From 53b0b037163aa2f591d4bf5822c914d72b1b96c3 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 18 Apr 2026 11:42:43 +0800 Subject: [PATCH] fix(frontend): use selectionchange event for cursor tracking instead of per-render The per-render tracking effect was reading DOM cursor position before the browser had finished positioning the caret after innerHTML updates, causing ms.cursorOffset to be set to the old position and overwriting the deferred correct value. Switch to document.selectionchange event which: - Fires only when the user actually moves the cursor (arrow keys, mouse clicks) - Does NOT fire during programmatic DOM updates - Uses focusNode/focusOffset directly from the selection API This completely eliminates the timing race between render effects and programmatic cursor updates. --- src/components/room/RoomChatPanel.tsx | 52 ++++++++++++++------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/src/components/room/RoomChatPanel.tsx b/src/components/room/RoomChatPanel.tsx index da82307..9b3152d 100644 --- a/src/components/room/RoomChatPanel.tsx +++ b/src/components/room/RoomChatPanel.tsx @@ -81,37 +81,39 @@ const ChatInputArea = memo(function ChatInputArea({ !!aiConfigsLoading, ); - // Track DOM cursor offset → ms.cursorOffset on every render + // ─── Track DOM cursor offset → ms.cursorOffset on user navigation ────── + // Uses selectionchange event which fires when caret moves via arrows/clicks. + // Does NOT fire during programmatic updates, avoiding cursor jumps. const prevCursorRef = useRef(ms.cursorOffset); - // Set when a deferred cursor update is scheduled; cleared when it fires. - // Prevents the tracking effect from overwriting a programmatically-set cursor. const skipCursorTrackingRef = useRef(false); useEffect(() => { - if (skipCursorTrackingRef.current) return; - 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) { - // Skip update when caret is at end of text (programmatic value change - // that already positioned the caret — the caret hasn't moved from the - // user's perspective). Only update on real user-initiated cursor movement. - if (count !== ms.value.length || prevCursorRef.current !== ms.value.length) { + const readCursor = () => { + if (skipCursorTrackingRef.current) return; + const el = containerRef.current; + if (!el) return; + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || !el.contains(sel.focusNode)) return; + const range = sel.getRangeAt(0); + // Build a range from start of contenteditable to caret + const pre = document.createRange(); + pre.selectNodeContents(el); + pre.setEnd(sel.focusNode, sel.focusOffset); + let count = 0; + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + let found = false; + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if (node === sel.focusNode) { count += sel.focusOffset; found = true; break; } + count += node.length; + } + if (!found) return; + if (count !== prevCursorRef.current) { prevCursorRef.current = count; ms.setCursorOffset(count); } - } + }; + document.addEventListener('selectionchange', readCursor); + return () => document.removeEventListener('selectionchange', readCursor); }); // ─── Imperative handle ──────────────────────────────────────────────────