From d2935f3ddc68946ca6c885914559d8a55ffd82e7 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 18 Apr 2026 01:17:04 +0800 Subject: [PATCH] fix(frontend): repair MentionInput contenteditable implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MentionInput: use forwarded ref, internal state ref, pendingSyncRef flag to prevent value↔DOM sync cycles; getContentText walks both text nodes and non-editable mention spans to preserve mention length - RoomChatPanel: replace cursorRef with getCaretOffset()/setCaretOffset() helpers, remove stale cursorRef.current references throughout - MentionPopover: update textareaRef type to HTMLDivElement, handleSelect uses TreeWalker to read from contenteditable div, positioning effect always uses TreeWalker for text measurement --- src/components/room/MentionInput.tsx | 228 ++++++++++++------------- src/components/room/MentionPopover.tsx | 76 +++++++-- src/components/room/RoomChatPanel.tsx | 146 ++++++++-------- 3 files changed, 241 insertions(+), 209 deletions(-) diff --git a/src/components/room/MentionInput.tsx b/src/components/room/MentionInput.tsx index 398d139..6febf47 100644 --- a/src/components/room/MentionInput.tsx +++ b/src/components/room/MentionInput.tsx @@ -8,15 +8,13 @@ interface MentionInputProps { onSend?: () => void; placeholder?: string; disabled?: boolean; - /** Mutable ref for cursor position — updated on every selection change without causing re-renders */ - cursorRef?: React.MutableRefObject; } const mentionStyles: Record = { - user: 'inline-flex items-center rounded bg-blue-100/80 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium text-sm leading-5', - repository: 'inline-flex items-center rounded bg-purple-100/80 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-medium text-sm leading-5', - ai: 'inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium text-sm leading-5', - notify: 'inline-flex items-center rounded bg-yellow-100/80 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 font-medium text-sm leading-5', + user: 'inline-flex items-center rounded bg-blue-100/80 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium text-sm leading-5 mx-0.5', + repository: 'inline-flex items-center rounded bg-purple-100/80 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-medium text-sm leading-5 mx-0.5', + ai: 'inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium text-sm leading-5 mx-0.5', + notify: 'inline-flex items-center rounded bg-yellow-100/80 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 font-medium text-sm leading-5 mx-0.5', }; const iconMap: Record = { @@ -33,62 +31,76 @@ const labelMap: Record = { notify: 'Notify', }; -function textToHtml(text: string): string { - const nodes = parse(text); +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +/** Render mentions as styled contentEditable-free inline buttons */ +function renderMentions(value: string): string { + const nodes = parse(value); if (nodes.length === 0) { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\n/g, '
'); + return escapeHtml(value).replace(/\n/g, '
'); } let html = ''; for (const node of nodes) { if (node.type === 'text') { - html += (node as Node & { type: 'text' }).text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\n/g, '
'); + html += escapeHtml((node as Node & { type: 'text' }).text).replace(/\n/g, '
'); } else if (node.type === 'mention') { const m = node as Node & { type: 'mention'; mentionType: string; id: string; label: string }; const style = mentionStyles[m.mentionType] ?? mentionStyles.user; const icon = iconMap[m.mentionType] ?? '🏷'; const label = labelMap[m.mentionType] ?? 'Mention'; - html += ``; - html += `${icon}`; - html += `${label}:`; - html += `${m.label}`; + html += ``; + html += `${icon}${escapeHtml(label)}: ${escapeHtml(m.label)}`; html += ''; } else if (node.type === 'ai_action') { const a = node as Node & { type: 'ai_action'; action: string; args?: string }; - html += `/${a.action}${a.args ? ' ' + a.args : ''}`; + html += ``; + html += `/${escapeHtml(a.action)}${a.args ? ' ' + escapeHtml(a.args) : ''}`; + html += ''; } } return html; } -/** Find the character offset of the cursor inside a contenteditable div */ -function getCaretOffset(container: HTMLElement): number { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return container.textContent?.length ?? 0; - const range = selection.getRangeAt(0); +/** Extract plain text content */ +function getContentText(el: HTMLElement): string { + let text = ''; + for (const child of Array.from(el.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + text += child.textContent ?? ''; + } else if (child.nodeType === Node.ELEMENT_NODE) { + const elNode = child as HTMLElement; + if (elNode.tagName === 'BR') text += '\n'; + else if (elNode.tagName === 'DIV' || elNode.tagName === 'P') text += getContentText(elNode) + '\n'; + else if (elNode.getAttribute('contenteditable') === 'false') text += elNode.textContent ?? ''; + else text += getContentText(elNode); + } + } + return text; +} + +/** Get character offset of selection within contenteditable */ +function getSelectionOffset(container: HTMLElement): number { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return 0; + const range = sel.getRangeAt(0); const preRange = range.cloneRange(); preRange.selectNodeContents(container); preRange.setEnd(range.startContainer, range.startOffset); return preRange.toString().length; } -/** Place caret at a given character offset inside a contenteditable div */ -function setCaretOffset(container: HTMLElement, offset: number) { - const selection = window.getSelection(); - if (!selection) return; +/** Set selection at given character offset */ +function setSelectionAtOffset(container: HTMLElement, offset: number) { + const sel = window.getSelection(); + if (!sel) return; const range = document.createRange(); let charCount = 0; let found = false; - function traverse(node: Node) { + function walk(node: Node) { if (found) return; if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent ?? ''; @@ -100,42 +112,32 @@ function setCaretOffset(container: HTMLElement, offset: number) { } charCount += text.length; } else if (node.nodeType === Node.ELEMENT_NODE) { - for (const child of Array.from(node.childNodes)) { - traverse(child as Node); - if (found) return; + const el = node as HTMLElement; + if (el.getAttribute('contenteditable') === 'false') { + const nodeLen = el.textContent?.length ?? 0; + if (charCount + nodeLen >= offset) { + range.selectNodeContents(el); + range.collapse(false); + found = true; + return; + } + charCount += nodeLen; + } else { + for (const child of Array.from(el.childNodes)) { + walk(child as Node); + if (found) return; + } } } } - traverse(container as unknown as Node); + walk(container); if (!found) { range.selectNodeContents(container); range.collapse(false); } - - selection.removeAllRanges(); - selection.addRange(range); -} - -/** Check if cursor is at the START of a mention span */ -function isCursorAtMentionStart(container: HTMLElement): HTMLElement | null { - const selection = window.getSelection(); - if (!selection || selection.rangeCount === 0) return null; - const range = selection.getRangeAt(0); - const node = range.startContainer; - - if (node.nodeType === Node.TEXT_NODE) { - const parent = node.parentElement; - if ((parent?.classList.contains('mention-span') || parent?.classList.contains('ai-action-span')) && range.startOffset === 0) { - return parent; - } - } else if (node.nodeType === Node.ELEMENT_NODE) { - const prevSibling = node.childNodes[range.startOffset - 1] as HTMLElement; - if (prevSibling?.classList?.contains('mention-span') || prevSibling?.classList?.contains('ai-action-span')) { - return prevSibling; - } - } - return null; + sel.removeAllRanges(); + sel.addRange(range); } export const MentionInput = forwardRef(function MentionInput({ @@ -144,105 +146,89 @@ export const MentionInput = forwardRef(functi onSend, placeholder, disabled, - cursorRef, -}: MentionInputProps) { - const containerRef = useRef(null); - const isComposingRef = useRef(false); +}, ref) { + const elRef = useRef(null); + const pendingSyncRef = useRef(false); const lastValueRef = useRef(value); - const isInternalChangeRef = useRef(false); - // Keep cursorRef in sync with actual caret position + // Sync DOM when external value changes useEffect(() => { - const container = containerRef.current; - if (!container) return; - const update = () => { - if (cursorRef) cursorRef.current = getCaretOffset(container); - }; - container.addEventListener('selectionchange', update); - return () => container.removeEventListener('selectionchange', update); - }, [cursorRef]); - useEffect(() => { - if (isInternalChangeRef.current) { - isInternalChangeRef.current = false; + if (pendingSyncRef.current) { + pendingSyncRef.current = false; return; } - if (!containerRef.current) return; - const currentText = containerRef.current.textContent ?? ''; + const el = elRef.current; + if (!el) return; + + const currentText = getContentText(el); if (currentText === value) return; - const prevLen = lastValueRef.current.length; - const cursor = getCaretOffset(containerRef.current); - const ratio = prevLen > 0 ? cursor / prevLen : 0; + // Save cursor ratio + const curOffset = getSelectionOffset(el); + const ratio = currentText.length > 0 ? curOffset / currentText.length : 0; - containerRef.current.innerHTML = textToHtml(value); + el.innerHTML = value.trim() ? renderMentions(value) : ''; - const newLen = value.length; - const newCursor = Math.round(ratio * newLen); - setCaretOffset(containerRef.current, Math.min(newCursor, newLen)); + const newOffset = Math.round(ratio * value.length); + setSelectionAtOffset(el, Math.min(newOffset, value.length)); lastValueRef.current = value; }, [value]); const handleInput = useCallback(() => { - if (isComposingRef.current) return; - const container = containerRef.current; - if (!container) return; - const newValue = container.textContent ?? ''; - lastValueRef.current = newValue; - isInternalChangeRef.current = true; - onChange(newValue); + const el = elRef.current; + if (!el) return; + const newText = getContentText(el); + pendingSyncRef.current = true; + lastValueRef.current = newText; + onChange(newText); }, [onChange]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - const container = containerRef.current; - if (!container) return; - - // Backspace: delete entire mention span at cursor - if (e.key === 'Backspace') { - const span = isCursorAtMentionStart(container); - if (span) { - e.preventDefault(); - span.remove(); - const newValue = container.textContent ?? ''; - lastValueRef.current = newValue; - isInternalChangeRef.current = true; - onChange(newValue); - return; - } - } - - // Ctrl+Enter: send + // Ctrl+Enter → send if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); - if (onSend) onSend(); + onSend?.(); return; } - - // Plain Enter: swallow (Shift+Enter naturally inserts newline via default behavior) + // Plain Enter → swallow. Shift+Enter inserts newline naturally. if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) { e.preventDefault(); } }, - [onChange, onSend], + [onSend], ); + const handlePaste = useCallback((e: React.ClipboardEvent) => { + e.preventDefault(); + document.execCommand('insertText', false, e.clipboardData.getData('text/plain')); + }, []); + + // Merge forwarded ref with internal ref using effect + useEffect(() => { + const el = elRef.current; + if (ref) { + if (typeof ref === 'function') ref(el); + else (ref as React.MutableRefObject).current = el; + } + }, [ref]); + return (
{ isComposingRef.current = true; }} - onCompositionEnd={handleInput} + onPaste={handlePaste} data-placeholder={placeholder} - dangerouslySetInnerHTML={{ __html: textToHtml(value) }} /> ); }); diff --git a/src/components/room/MentionPopover.tsx b/src/components/room/MentionPopover.tsx index c4dd9ea..054e950 100644 --- a/src/components/room/MentionPopover.tsx +++ b/src/components/room/MentionPopover.tsx @@ -60,7 +60,7 @@ interface MentionPopoverProps { inputValue: string; cursorPosition: number; onSelect: (newValue: string, newCursorPosition: number) => void; - textareaRef: React.RefObject; + textareaRef: React.RefObject; onOpenChange: (open: boolean) => void; /** Called when Enter is pressed on a category — ChatInputArea handles the state update */ onCategoryEnter: (category: string) => void; @@ -508,19 +508,40 @@ export function MentionPopover({ const handleSelect = useCallback( (suggestion: MentionSuggestion) => { - if (!textareaRef.current || suggestion.type !== 'item') return; - const textarea = textareaRef.current; - const cursorPos = textarea.selectionStart; - const textBefore = textarea.value.substring(0, cursorPos); + if (!textarea || suggestion.type !== 'item') return; + + // Get current state from contenteditable div + const text = textarea.textContent ?? ''; + let cursorPos = 0; + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const preRange = range.cloneRange(); + preRange.selectNodeContents(textarea); + preRange.setEnd(range.startContainer, range.startOffset); + let count = 0; + const walker = document.createTreeWalker(textarea, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if (node === preRange.startContainer) { + count += preRange.startOffset; + break; + } + count += node.length; + } + cursorPos = count; + } + + const textBefore = text.substring(0, cursorPos); const atMatch = textBefore.match(/@([^:@\s<]*)(:([^\s<]*))?$/); if (!atMatch) return; const [fullMatch] = atMatch; const startPos = cursorPos - fullMatch.length; - const before = textarea.value.substring(0, startPos); - const after = textarea.value.substring(cursorPos); + const before = text.substring(0, startPos); + const after = text.substring(cursorPos); const html = buildMentionHtml( suggestion.category!, @@ -558,7 +579,29 @@ export function MentionPopover({ document.body.appendChild(tempDiv); } - const text = inputValue.slice(0, cursorPosition); + // For contenteditable, use TreeWalker to get text before cursor + let text = ''; + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const preRange = range.cloneRange(); + preRange.selectNodeContents(textarea); + preRange.setEnd(range.startContainer, range.startOffset); + let count = 0; + const walker = document.createTreeWalker(textarea, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if (node === preRange.startContainer) { + text += node.textContent?.substring(0, preRange.startOffset) ?? ''; + break; + } + text += node.textContent ?? ''; + } + } else { + // Fallback: just use inputValue + text = inputValue.slice(0, cursorPosition); + } + const styleProps = [ 'font-family', 'font-size', @@ -576,7 +619,6 @@ export function MentionPopover({ 'width', ] as const; - tempDiv.style.width = styles.width; for (const prop of styleProps) { tempDiv.style.setProperty(prop, styles.getPropertyValue(prop)); } @@ -587,11 +629,21 @@ export function MentionPopover({ tempDiv.appendChild(span); const rect = textarea.getBoundingClientRect(); + const scrollRect = { top: 0, left: 0 }; + // Scroll offset for contenteditable divs + try { + Object.assign(scrollRect, textarea.getScrollMetrics?.() ?? {}); + } catch (_e) { + // Fallback: no getScrollMetrics + } + const scrollTop = textarea.scrollTop || scrollRect.topY || 0; + const scrollLeft = textarea.scrollLeft || scrollRect.leftX || 0; + const lineHeight = Number.parseFloat(styles.lineHeight) || 20; const borderLeft = Number.parseFloat(styles.borderLeftWidth) || 0; const borderTop = Number.parseFloat(styles.borderTopWidth) || 0; - const contentTop = rect.top + borderTop + span.offsetTop - textarea.scrollTop; - const contentLeft = rect.left + borderLeft + span.offsetLeft - textarea.scrollLeft; + const contentTop = rect.top + borderTop + span.offsetTop - scrollTop; + const contentLeft = rect.left + borderLeft + span.offsetLeft - scrollLeft; const popoverWidth = 320; const popoverHeight = Math.min(360, visibleSuggestions.length * 56 + 100); @@ -608,7 +660,7 @@ export function MentionPopover({ : Math.max(vp, contentTop - popoverHeight - 8); setPosition({ top, left }); - }, [mentionState, inputValue, textareaRef, cursorPosition, visibleSuggestions.length]); + }, [mentionState, textareaRef, visibleSuggestions.length]); // Keyboard navigation useEffect(() => { diff --git a/src/components/room/RoomChatPanel.tsx b/src/components/room/RoomChatPanel.tsx index 2343425..4a89791 100644 --- a/src/components/room/RoomChatPanel.tsx +++ b/src/components/room/RoomChatPanel.tsx @@ -72,13 +72,68 @@ const ChatInputArea = memo(function ChatInputArea({ ref, }: ChatInputAreaProps) { const containerRef = useRef(null); - const cursorRef = useRef(0); const [showMentionPopover, setShowMentionPopover] = useState(false); + /** Get caret offset in the contenteditable div */ + function getCaretOffset(): number { + const container = containerRef.current; + if (!container) return 0; + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return 0; + const range = selection.getRangeAt(0); + const preRange = range.cloneRange(); + preRange.selectNodeContents(container); + preRange.setEnd(range.startContainer, range.startOffset); + // Count text nodes only + let count = 0; + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if (node === preRange.startContainer) { + count += preRange.startOffset; + break; + } + count += node.length; + } + return count; + } + + /** Set caret at given character offset in the contenteditable div */ + function setCaretOffset(offset: number) { + const container = containerRef.current; + if (!container) return; + const selection = window.getSelection(); + if (!selection) return; + const range = document.createRange(); + let charCount = 0; + let found = false; + function walk(node: Node) { + if (found) return; + if (node.nodeType === Node.TEXT_NODE) { + const t = node.textContent ?? ''; + if (charCount + t.length >= offset) { + range.setStart(node, Math.min(offset - charCount, t.length)); + range.collapse(true); + found = true; + return; + } + charCount += t.length; + } else if (node.nodeType === Node.ELEMENT_NODE) { + for (const c of Array.from(node.childNodes)) { + walk(c); + if (found) return; + } + } + } + walk(container); + if (!found) { range.selectNodeContents(container); range.collapse(false); } + selection.removeAllRanges(); + selection.addRange(range); + } + const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => { if (!containerRef.current) return; - const container = containerRef.current; - const cursorPos = cursorRef.current; + const cursorPos = getCaretOffset(); const textBefore = draft.substring(0, cursorPos); const atMatch = textBefore.match(MENTION_PATTERN); if (!atMatch) return; @@ -104,49 +159,16 @@ const ChatInputArea = memo(function ChatInputArea({ onDraftChange(newValue); setShowMentionPopover(false); setTimeout(() => { - const container = containerRef.current; - if (!container) return; - container.innerHTML = newValue - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\n/g, '
'); - // Place cursor at newCursorPos - const sel = window.getSelection(); - if (!sel) return; - const range = document.createRange(); - let charCount = 0; - let found = false; - function walk(node: Node) { - if (found) return; - if (node.nodeType === Node.TEXT_NODE) { - const t = node.textContent ?? ''; - if (charCount + t.length >= newCursorPos) { - range.setStart(node, Math.min(newCursorPos - charCount, t.length)); - range.collapse(true); - found = true; - return; - } - charCount += t.length; - } else if (node.nodeType === Node.ELEMENT_NODE) { - for (const c of Array.from(node.childNodes)) { - walk(c); - if (found) return; - } - } - } - walk(container); - if (!found) { range.selectNodeContents(container); range.collapse(false); } - sel.removeAllRanges(); - sel.addRange(range); - container.focus(); + if (!containerRef.current) return; + containerRef.current.focus(); + setCaretOffset(newCursorPos); }, 0); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Uses draft from closure useImperativeHandle(ref, () => ({ insertMention: (id: string, label: string) => { - const cursorPos = cursorRef.current; + const cursorPos = getCaretOffset(); const escapedLabel = label.replace(//g, '>'); const escapedId = id.replace(/"/g, '"'); const mentionText = `${escapedLabel} `; @@ -160,7 +182,7 @@ const ChatInputArea = memo(function ChatInputArea({ }, 0); }, insertCategory: (category: string) => { - const cursorPos = cursorRef.current; + const cursorPos = getCaretOffset(); const textBefore = draft.substring(0, cursorPos); const atMatch = textBefore.match(MENTION_PATTERN); if (!atMatch) return; @@ -179,7 +201,7 @@ const ChatInputArea = memo(function ChatInputArea({ useEffect(() => { const onMentionClick = (e: Event) => { const { type, id, label } = (e as CustomEvent<{ type: string; id: string; label: string }>).detail; - const cursorPos = cursorRef.current; + const cursorPos = getCaretOffset(); const textBefore = draft.substring(0, cursorPos); const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label); const spacer = ' '; @@ -187,36 +209,9 @@ const ChatInputArea = memo(function ChatInputArea({ const newCursorPos = cursorPos + html.length + spacer.length; onDraftChange(newValue); setTimeout(() => { - const container = containerRef.current; - if (!container) return; - container.focus(); - const sel = window.getSelection(); - if (!sel) return; - const range = document.createRange(); - let charCount = 0; - let found = false; - function walk(node: Node) { - if (found) return; - if (node.nodeType === Node.TEXT_NODE) { - const t = node.textContent ?? ''; - if (charCount + t.length >= newCursorPos) { - range.setStart(node, Math.min(newCursorPos - charCount, t.length)); - range.collapse(true); - found = true; - return; - } - charCount += t.length; - } else if (node.nodeType === Node.ELEMENT_NODE) { - for (const c of Array.from(node.childNodes)) { - walk(c); - if (found) return; - } - } - } - walk(container); - if (!found) { range.selectNodeContents(container); range.collapse(false); } - sel.removeAllRanges(); - sel.addRange(range); + if (!containerRef.current) return; + containerRef.current.focus(); + setCaretOffset(newCursorPos); }, 0); }; document.addEventListener('mention-click', onMentionClick); @@ -240,10 +235,9 @@ const ChatInputArea = memo(function ChatInputArea({ { onDraftChange(v); - const textBefore = v.substring(0, cursorRef.current); + const textBefore = v.substring(0, getCaretOffset()); if (textBefore.match(MENTION_PATTERN)) { setShowMentionPopover(true); } else { @@ -287,12 +281,12 @@ const ChatInputArea = memo(function ChatInputArea({ aiConfigs={aiConfigs} aiConfigsLoading={aiConfigsLoading} inputValue={draft} - cursorPosition={cursorRef.current} + cursorPosition={getCaretOffset()} onSelect={handleMentionSelect} textareaRef={containerRef} onOpenChange={setShowMentionPopover} onCategoryEnter={(category: string) => { - const cursorPos = cursorRef.current; + const cursorPos = getCaretOffset(); const textBefore = draft.substring(0, cursorPos); const atMatch = textBefore.match(MENTION_PATTERN); if (!atMatch) return;