fix(frontend): 4 mention chain bugs — cursor tracking, caret placement, undefined refs

- Fix undefined setDraftAndNotify (replaced with onDraftChangeRef + ms.setValue)
- Add DOM→ms.cursorOffset tracking on every render via TreeWalker
- Fix mention insertion caret placement: skip MentionInput sync-effect caret
  restoration when parent sets value (via __mentionSkipSync window flag)
- Remove unused _draftTick state

Bug #1: setDraftAndNotify referenced but never defined → runtime crash
Bug #2: ms.cursorOffset never updated from DOM → popover always at wrong position
Bug #3: mention insertion saved pre-insertion caret, restored on longer HTML → cursor inside mention tag
Bug #4: _draftTick declared but state value never read → dead code
This commit is contained in:
ZhenYi 2026-04-18 01:34:22 +08:00
parent 168fdc4da8
commit f152aaeed9
2 changed files with 59 additions and 20 deletions

View File

@ -171,19 +171,27 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
if (currentText === internalValueRef.current) return;
if (internalValueRef.current === value) return;
// Save cursor position ratio
const oldCaret = getCaretOffset(el);
const oldLen = internalValueRef.current.length;
const ratio = oldLen > 0 ? oldCaret / oldLen : 0;
// 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);
}
// Update DOM
el.innerHTML = value.trim() ? renderToHtml(value) : '';
internalValueRef.current = value;
// Restore cursor
// 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;
const newLen = value.length;
const newCaret = Math.round(ratio * newLen);
setCaretAtOffset(el, Math.min(newCaret, newLen));
internalValueRef.current = value;
}
// 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 */

View File

@ -70,7 +70,8 @@ const ChatInputArea = memo(function ChatInputArea({
ref,
}: ChatInputAreaProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [_draftTick, setDraftTick] = useState(0);
/** Flag: skip MentionInput's caret restoration on the next sync (used after mention insertion) */
const skipCaretRestoreRef = useRef(false);
// ─── Central mention state ───────────────────────────────────────────────
const ms = useMentionState(
@ -81,13 +82,29 @@ const ChatInputArea = memo(function ChatInputArea({
!!aiConfigsLoading,
);
// Sync external draft → mention state (with dirty flag to skip MentionInput onChange during init)
// Track DOM cursor offset → ms.cursorOffset on every render
const prevCursorRef = useRef(ms.cursorOffset);
useEffect(() => {
if (ms.value === draft) return;
(window as any).__mentionSkipSync = true;
ms.setValue(draft);
setDraftTick(t => t + 1);
}, [draft, ms.value, ms.setValue]);
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) {
prevCursorRef.current = count;
ms.setCursorOffset(count);
}
});
// ─── Imperative handle ──────────────────────────────────────────────────
const onDraftChangeRef = useRef(onDraftChange);
@ -98,10 +115,14 @@ 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(() => containerRef.current?.focus(), 0);
setTimeout(() => {
skipCaretRestoreRef.current = false;
containerRef.current?.focus();
}, 0);
},
insertCategory: (category: string) => {
const textBefore = draft.substring(0, ms.cursorOffset);
@ -128,10 +149,15 @@ 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(() => containerRef.current?.focus(), 0);
setTimeout(() => {
skipCaretRestoreRef.current = false;
containerRef.current?.focus();
}, 0);
}, [draft, ms]);
// ─── mention-click handler (from message mentions) ─────────────────────
@ -141,9 +167,13 @@ 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(() => containerRef.current?.focus(), 0);
setTimeout(() => {
skipCaretRestoreRef.current = false;
containerRef.current?.focus();
}, 0);
};
document.addEventListener('mention-click', onMentionClick);
return () => document.removeEventListener('mention-click', onMentionClick);
@ -220,7 +250,8 @@ const ChatInputArea = memo(function ChatInputArea({
const afterPartial = ms.value.substring(startPos + fullMatch.length);
const newValue = before + '@' + category + ':' + afterPartial;
const newCursorPos = startPos + 1 + category.length + 1;
setDraftAndNotify(newValue);
onDraftChangeRef.current(newValue);
ms.setValue(newValue);
ms.setCursorOffset(newCursorPos);
}}
suggestions={ms.suggestions}