fix(frontend): rewrite MentionInput sync to prevent onChange overwriting mention draft

The root cause: handleMentionSelect set draft to mention HTML, but the
subsequent MentionInput.onInput event fired with plain text (after the
programmatic DOM insert) and overwrote the draft.

Solution: replace pendingSyncRef trick with a clean isUserInputRef flag.
- useEffect: if getPlainText(el) === value, DOM already correct (skip).
  Mark isUserInputRef = false so next useEffect skips caret restore.
- handleInput: always set isUserInputRef = true before calling onChange.

This eliminates the pendingSyncRef/__mentionSkipSync global flag mess
entirely.
This commit is contained in:
ZhenYi 2026-04-18 11:20:48 +08:00
parent 4ace651c6f
commit 5103d99a00
2 changed files with 21 additions and 41 deletions

View File

@ -147,7 +147,6 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
onSend,
}, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const pendingSyncRef = useRef(false);
const internalValueRef = useRef(value);
// Merge forwarded ref with internal ref
@ -158,40 +157,36 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
}
}, [ref]);
/** Track whether the last DOM update was from user input (vs programmatic) */
const isUserInputRef = useRef(true);
/** Sync external value changes into the DOM */
useEffect(() => {
if (pendingSyncRef.current) {
pendingSyncRef.current = false;
return;
}
const el = containerRef.current;
if (!el) return;
const currentText = getPlainText(el);
if (currentText === internalValueRef.current) return;
if (internalValueRef.current === value) return;
// 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);
// If the DOM already contains what we want, nothing to do.
// (Programmatic updates via onChange already set the DOM correctly.)
if (getPlainText(el) === value) {
isUserInputRef.current = false;
return;
}
// Save cursor position ratio for non-user-input updates
const oldCaret = isUserInputRef.current ? getCaretOffset(el) : 0;
const oldLen = internalValueRef.current.length;
// Update DOM
el.innerHTML = value.trim() ? renderToHtml(value) : '';
internalValueRef.current = value;
isUserInputRef.current = false;
// 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;
// Restore caret for non-mention user updates
if (oldLen > 0) {
const ratio = oldCaret / oldLen;
const newLen = value.length;
const newCaret = Math.round(ratio * newLen);
setCaretAtOffset(el, Math.min(newCaret, newLen));
setCaretAtOffset(el, Math.round(ratio * 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 */
@ -199,8 +194,8 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
const el = containerRef.current;
if (!el) return;
const newText = getPlainText(el);
pendingSyncRef.current = true;
internalValueRef.current = newText;
isUserInputRef.current = true;
onChange(newText);
}, [onChange]);

View File

@ -71,8 +71,6 @@ const ChatInputArea = memo(function ChatInputArea({
ref,
}: ChatInputAreaProps) {
const containerRef = useRef<HTMLDivElement>(null);
/** Flag: skip MentionInput's caret restoration on the next sync (used after mention insertion) */
const skipCaretRestoreRef = useRef(false);
// ─── Central mention state ───────────────────────────────────────────────
const ms = useMentionState(
@ -116,14 +114,10 @@ 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(() => {
skipCaretRestoreRef.current = false;
containerRef.current?.focus();
}, 0);
setTimeout(() => containerRef.current?.focus(), 0);
},
insertCategory: (category: string) => {
const textBefore = draft.substring(0, ms.cursorOffset);
@ -150,15 +144,10 @@ 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(() => {
skipCaretRestoreRef.current = false;
containerRef.current?.focus();
}, 0);
setTimeout(() => containerRef.current?.focus(), 0);
}, [draft, ms]);
// ─── Auto-show/hide popover when @ context appears ────────────────────
@ -177,13 +166,9 @@ 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(() => {
skipCaretRestoreRef.current = false;
containerRef.current?.focus();
}, 0);
setTimeout(() => containerRef.current?.focus(), 0);
};
document.addEventListener('mention-click', onMentionClick);
return () => document.removeEventListener('mention-click', onMentionClick);