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:
parent
168fdc4da8
commit
f152aaeed9
@ -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) : '';
|
||||
|
||||
// Restore cursor
|
||||
const newLen = value.length;
|
||||
const newCaret = Math.round(ratio * newLen);
|
||||
setCaretAtOffset(el, Math.min(newCaret, newLen));
|
||||
internalValueRef.current = value;
|
||||
|
||||
// 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));
|
||||
}
|
||||
// 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 */
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user