diff --git a/src/components/room/MentionInput.tsx b/src/components/room/MentionInput.tsx index e6bde6d..96e7cdc 100644 --- a/src/components/room/MentionInput.tsx +++ b/src/components/room/MentionInput.tsx @@ -171,19 +171,27 @@ export const MentionInput = forwardRef(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 */ diff --git a/src/components/room/RoomChatPanel.tsx b/src/components/room/RoomChatPanel.tsx index 05af213..7a19e5b 100644 --- a/src/components/room/RoomChatPanel.tsx +++ b/src/components/room/RoomChatPanel.tsx @@ -70,7 +70,8 @@ const ChatInputArea = memo(function ChatInputArea({ ref, }: ChatInputAreaProps) { const containerRef = useRef(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}