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:
parent
4ace651c6f
commit
5103d99a00
@ -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]);
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user