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 (currentText === internalValueRef.current) return;
|
||||||
if (internalValueRef.current === value) return;
|
if (internalValueRef.current === value) return;
|
||||||
|
|
||||||
// Save cursor position ratio
|
// Save cursor position ratio (skip if this is a mention-insertion update from parent)
|
||||||
const oldCaret = getCaretOffset(el);
|
const skipCaretRestore = (window as any).__mentionSkipSync;
|
||||||
const oldLen = internalValueRef.current.length;
|
let oldCaret = 0;
|
||||||
const ratio = oldLen > 0 ? oldCaret / oldLen : 0;
|
if (!skipCaretRestore) {
|
||||||
|
oldCaret = getCaretOffset(el);
|
||||||
|
}
|
||||||
|
|
||||||
// Update DOM
|
// Update DOM
|
||||||
el.innerHTML = value.trim() ? renderToHtml(value) : '';
|
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 newLen = value.length;
|
||||||
const newCaret = Math.round(ratio * newLen);
|
const newCaret = Math.round(ratio * newLen);
|
||||||
setCaretAtOffset(el, Math.min(newCaret, newLen));
|
setCaretAtOffset(el, Math.min(newCaret, newLen));
|
||||||
internalValueRef.current = value;
|
}
|
||||||
|
// 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 */
|
||||||
|
|||||||
@ -70,7 +70,8 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
ref,
|
ref,
|
||||||
}: ChatInputAreaProps) {
|
}: ChatInputAreaProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
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 ───────────────────────────────────────────────
|
// ─── Central mention state ───────────────────────────────────────────────
|
||||||
const ms = useMentionState(
|
const ms = useMentionState(
|
||||||
@ -81,13 +82,29 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
!!aiConfigsLoading,
|
!!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(() => {
|
useEffect(() => {
|
||||||
if (ms.value === draft) return;
|
const el = containerRef.current;
|
||||||
(window as any).__mentionSkipSync = true;
|
if (!el) return;
|
||||||
ms.setValue(draft);
|
const sel = window.getSelection();
|
||||||
setDraftTick(t => t + 1);
|
if (!sel || sel.rangeCount === 0) return;
|
||||||
}, [draft, ms.value, ms.setValue]);
|
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 ──────────────────────────────────────────────────
|
// ─── Imperative handle ──────────────────────────────────────────────────
|
||||||
const onDraftChangeRef = useRef(onDraftChange);
|
const onDraftChangeRef = useRef(onDraftChange);
|
||||||
@ -98,10 +115,14 @@ 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(() => containerRef.current?.focus(), 0);
|
setTimeout(() => {
|
||||||
|
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);
|
||||||
@ -128,10 +149,15 @@ 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(() => containerRef.current?.focus(), 0);
|
setTimeout(() => {
|
||||||
|
skipCaretRestoreRef.current = false;
|
||||||
|
containerRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
}, [draft, ms]);
|
}, [draft, ms]);
|
||||||
|
|
||||||
// ─── mention-click handler (from message mentions) ─────────────────────
|
// ─── 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 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(() => containerRef.current?.focus(), 0);
|
setTimeout(() => {
|
||||||
|
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);
|
||||||
@ -220,7 +250,8 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
const afterPartial = ms.value.substring(startPos + fullMatch.length);
|
const afterPartial = ms.value.substring(startPos + fullMatch.length);
|
||||||
const newValue = before + '@' + category + ':' + afterPartial;
|
const newValue = before + '@' + category + ':' + afterPartial;
|
||||||
const newCursorPos = startPos + 1 + category.length + 1;
|
const newCursorPos = startPos + 1 + category.length + 1;
|
||||||
setDraftAndNotify(newValue);
|
onDraftChangeRef.current(newValue);
|
||||||
|
ms.setValue(newValue);
|
||||||
ms.setCursorOffset(newCursorPos);
|
ms.setCursorOffset(newCursorPos);
|
||||||
}}
|
}}
|
||||||
suggestions={ms.suggestions}
|
suggestions={ms.suggestions}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user