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,
);
// 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 ──────────────────────────────────────────────────