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.
This commit is contained in:
ZhenYi 2026-04-18 11:42:43 +08:00
parent 4330325bfc
commit 53b0b03716

View File

@ -81,37 +81,39 @@ const ChatInputArea = memo(function ChatInputArea({
!!aiConfigsLoading, !!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); 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); const skipCursorTrackingRef = useRef(false);
useEffect(() => { useEffect(() => {
if (skipCursorTrackingRef.current) return; const readCursor = () => {
const el = containerRef.current; if (skipCursorTrackingRef.current) return;
if (!el) return; const el = containerRef.current;
const sel = window.getSelection(); if (!el) return;
if (!sel || sel.rangeCount === 0) return; const sel = window.getSelection();
const range = sel.getRangeAt(0); if (!sel || sel.rangeCount === 0 || !el.contains(sel.focusNode)) return;
const pre = range.cloneRange(); const range = sel.getRangeAt(0);
pre.selectNodeContents(el); // Build a range from start of contenteditable to caret
pre.setEnd(range.startContainer, range.startOffset); const pre = document.createRange();
let count = 0; pre.selectNodeContents(el);
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); pre.setEnd(sel.focusNode, sel.focusOffset);
while (walker.nextNode()) { let count = 0;
const node = walker.currentNode as Text; const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
if (node === pre.startContainer) { count += pre.startOffset; break; } let found = false;
count += node.length; while (walker.nextNode()) {
} const node = walker.currentNode as Text;
if (count !== prevCursorRef.current) { if (node === sel.focusNode) { count += sel.focusOffset; found = true; break; }
// Skip update when caret is at end of text (programmatic value change count += node.length;
// that already positioned the caret — the caret hasn't moved from the }
// user's perspective). Only update on real user-initiated cursor movement. if (!found) return;
if (count !== ms.value.length || prevCursorRef.current !== ms.value.length) { if (count !== prevCursorRef.current) {
prevCursorRef.current = count; prevCursorRef.current = count;
ms.setCursorOffset(count); ms.setCursorOffset(count);
} }
} };
document.addEventListener('selectionchange', readCursor);
return () => document.removeEventListener('selectionchange', readCursor);
}); });
// ─── Imperative handle ────────────────────────────────────────────────── // ─── Imperative handle ──────────────────────────────────────────────────