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 ──────────────────────────────────────────────────