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,
|
onSend,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const pendingSyncRef = useRef(false);
|
|
||||||
const internalValueRef = useRef(value);
|
const internalValueRef = useRef(value);
|
||||||
|
|
||||||
// Merge forwarded ref with internal ref
|
// Merge forwarded ref with internal ref
|
||||||
@ -158,40 +157,36 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
|
|||||||
}
|
}
|
||||||
}, [ref]);
|
}, [ref]);
|
||||||
|
|
||||||
|
/** Track whether the last DOM update was from user input (vs programmatic) */
|
||||||
|
const isUserInputRef = useRef(true);
|
||||||
|
|
||||||
/** Sync external value changes into the DOM */
|
/** Sync external value changes into the DOM */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingSyncRef.current) {
|
|
||||||
pendingSyncRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const currentText = getPlainText(el);
|
// If the DOM already contains what we want, nothing to do.
|
||||||
if (currentText === internalValueRef.current) return;
|
// (Programmatic updates via onChange already set the DOM correctly.)
|
||||||
if (internalValueRef.current === value) return;
|
if (getPlainText(el) === value) {
|
||||||
|
isUserInputRef.current = false;
|
||||||
// Save cursor position ratio (skip if this is a mention-insertion update from parent)
|
return;
|
||||||
const skipCaretRestore = (window as any).__mentionSkipSync;
|
|
||||||
let oldCaret = 0;
|
|
||||||
if (!skipCaretRestore) {
|
|
||||||
oldCaret = getCaretOffset(el);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save cursor position ratio for non-user-input updates
|
||||||
|
const oldCaret = isUserInputRef.current ? getCaretOffset(el) : 0;
|
||||||
|
const oldLen = internalValueRef.current.length;
|
||||||
|
|
||||||
// Update DOM
|
// Update DOM
|
||||||
el.innerHTML = value.trim() ? renderToHtml(value) : '';
|
el.innerHTML = value.trim() ? renderToHtml(value) : '';
|
||||||
internalValueRef.current = value;
|
internalValueRef.current = value;
|
||||||
|
isUserInputRef.current = false;
|
||||||
|
|
||||||
// Restore cursor only for non-mention updates
|
// Restore caret for non-mention user updates
|
||||||
if (!skipCaretRestore) {
|
if (oldLen > 0) {
|
||||||
const oldLen = internalValueRef.current.length - (value.length - currentText.length); // approximate
|
const ratio = oldCaret / oldLen;
|
||||||
const ratio = oldLen > 0 ? oldCaret / oldLen : 0;
|
|
||||||
const newLen = value.length;
|
const newLen = value.length;
|
||||||
const newCaret = Math.round(ratio * newLen);
|
setCaretAtOffset(el, Math.round(ratio * newLen));
|
||||||
setCaretAtOffset(el, Math.min(newCaret, newLen));
|
|
||||||
}
|
}
|
||||||
// Clean up the global flag
|
|
||||||
if (skipCaretRestore) (window as any).__mentionSkipSync = false;
|
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
/** Handle input changes — extracts plain text from DOM and sends to parent */
|
/** 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;
|
const el = containerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const newText = getPlainText(el);
|
const newText = getPlainText(el);
|
||||||
pendingSyncRef.current = true;
|
|
||||||
internalValueRef.current = newText;
|
internalValueRef.current = newText;
|
||||||
|
isUserInputRef.current = true;
|
||||||
onChange(newText);
|
onChange(newText);
|
||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
|
|
||||||
|
|||||||
@ -71,8 +71,6 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
ref,
|
ref,
|
||||||
}: ChatInputAreaProps) {
|
}: ChatInputAreaProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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 ───────────────────────────────────────────────
|
// ─── Central mention state ───────────────────────────────────────────────
|
||||||
const ms = useMentionState(
|
const ms = useMentionState(
|
||||||
@ -116,14 +114,10 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
const insertion = ms.buildInsertionAt('user', id, label);
|
const insertion = ms.buildInsertionAt('user', id, label);
|
||||||
const before = draft.substring(0, insertion.startPos);
|
const before = draft.substring(0, insertion.startPos);
|
||||||
const newValue = before + insertion.html + ' ' + draft.substring(insertion.startPos);
|
const newValue = before + insertion.html + ' ' + draft.substring(insertion.startPos);
|
||||||
skipCaretRestoreRef.current = true;
|
|
||||||
onDraftChangeRef.current(newValue);
|
onDraftChangeRef.current(newValue);
|
||||||
ms.setValue(newValue);
|
ms.setValue(newValue);
|
||||||
ms.setShowMentionPopover(false);
|
ms.setShowMentionPopover(false);
|
||||||
setTimeout(() => {
|
setTimeout(() => containerRef.current?.focus(), 0);
|
||||||
skipCaretRestoreRef.current = false;
|
|
||||||
containerRef.current?.focus();
|
|
||||||
}, 0);
|
|
||||||
},
|
},
|
||||||
insertCategory: (category: string) => {
|
insertCategory: (category: string) => {
|
||||||
const textBefore = draft.substring(0, ms.cursorOffset);
|
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 insertion = ms.buildInsertionAt(suggestion.category, suggestion.mentionId, suggestion.label);
|
||||||
const before = draft.substring(0, insertion.startPos);
|
const before = draft.substring(0, insertion.startPos);
|
||||||
const newValue = before + insertion.html + ' ' + draft.substring(ms.cursorOffset);
|
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);
|
onDraftChangeRef.current(newValue);
|
||||||
ms.setValue(newValue);
|
ms.setValue(newValue);
|
||||||
ms.setShowMentionPopover(false);
|
ms.setShowMentionPopover(false);
|
||||||
setTimeout(() => {
|
setTimeout(() => containerRef.current?.focus(), 0);
|
||||||
skipCaretRestoreRef.current = false;
|
|
||||||
containerRef.current?.focus();
|
|
||||||
}, 0);
|
|
||||||
}, [draft, ms]);
|
}, [draft, ms]);
|
||||||
|
|
||||||
// ─── Auto-show/hide popover when @ context appears ────────────────────
|
// ─── 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 html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label);
|
||||||
const spacer = ' ';
|
const spacer = ' ';
|
||||||
const newValue = draft.substring(0, ms.cursorOffset) + html + spacer + draft.substring(ms.cursorOffset);
|
const newValue = draft.substring(0, ms.cursorOffset) + html + spacer + draft.substring(ms.cursorOffset);
|
||||||
skipCaretRestoreRef.current = true;
|
|
||||||
onDraftChangeRef.current(newValue);
|
onDraftChangeRef.current(newValue);
|
||||||
ms.setValue(newValue);
|
ms.setValue(newValue);
|
||||||
setTimeout(() => {
|
setTimeout(() => containerRef.current?.focus(), 0);
|
||||||
skipCaretRestoreRef.current = false;
|
|
||||||
containerRef.current?.focus();
|
|
||||||
}, 0);
|
|
||||||
};
|
};
|
||||||
document.addEventListener('mention-click', onMentionClick);
|
document.addEventListener('mention-click', onMentionClick);
|
||||||
return () => document.removeEventListener('mention-click', onMentionClick);
|
return () => document.removeEventListener('mention-click', onMentionClick);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user