fix(frontend): repair MentionInput contenteditable implementation
Some checks are pending
CI / Rust Lint & Check (push) Waiting to run
CI / Rust Tests (push) Waiting to run
CI / Frontend Lint & Type Check (push) Waiting to run
CI / Frontend Build (push) Blocked by required conditions

- 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
This commit is contained in:
ZhenYi 2026-04-18 01:17:04 +08:00
parent b7328e22f3
commit d2935f3ddc
3 changed files with 241 additions and 209 deletions

View File

@ -8,15 +8,13 @@ interface MentionInputProps {
onSend?: () => void; onSend?: () => void;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
/** Mutable ref for cursor position — updated on every selection change without causing re-renders */
cursorRef?: React.MutableRefObject<number>;
} }
const mentionStyles: Record<string, string> = { const mentionStyles: Record<string, string> = {
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', 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', 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', 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', 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<string, string> = { const iconMap: Record<string, string> = {
@ -33,62 +31,76 @@ const labelMap: Record<string, string> = {
notify: 'Notify', notify: 'Notify',
}; };
function textToHtml(text: string): string { function escapeHtml(text: string): string {
const nodes = parse(text); return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Render mentions as styled contentEditable-free inline buttons */
function renderMentions(value: string): string {
const nodes = parse(value);
if (nodes.length === 0) { if (nodes.length === 0) {
return text return escapeHtml(value).replace(/\n/g, '<br>');
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
} }
let html = ''; let html = '';
for (const node of nodes) { for (const node of nodes) {
if (node.type === 'text') { if (node.type === 'text') {
html += (node as Node & { type: 'text' }).text html += escapeHtml((node as Node & { type: 'text' }).text).replace(/\n/g, '<br>');
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
} else if (node.type === 'mention') { } else if (node.type === 'mention') {
const m = node as Node & { type: 'mention'; mentionType: string; id: string; label: string }; const m = node as Node & { type: 'mention'; mentionType: string; id: string; label: string };
const style = mentionStyles[m.mentionType] ?? mentionStyles.user; const style = mentionStyles[m.mentionType] ?? mentionStyles.user;
const icon = iconMap[m.mentionType] ?? '🏷'; const icon = iconMap[m.mentionType] ?? '🏷';
const label = labelMap[m.mentionType] ?? 'Mention'; const label = labelMap[m.mentionType] ?? 'Mention';
html += `<span class="mention-span" data-mention-id="${m.id}" data-mention-type="${m.mentionType}">`; html += `<span class="${style}" contenteditable="false" data-mention-id="${escapeHtml(m.id)}" data-mention-type="${escapeHtml(m.mentionType)}">`;
html += `<span class="mention-icon">${icon}</span>`; html += `<span>${icon}</span><strong>${escapeHtml(label)}:</strong> ${escapeHtml(m.label)}`;
html += `<span class="mention-label">${label}:</span>`;
html += `<span class="mention-name">${m.label}</span>`;
html += '</span>'; html += '</span>';
} else if (node.type === 'ai_action') { } else if (node.type === 'ai_action') {
const a = node as Node & { type: 'ai_action'; action: string; args?: string }; const a = node as Node & { type: 'ai_action'; action: string; args?: string };
html += `<span class="ai-action-span">/${a.action}${a.args ? ' ' + a.args : ''}</span>`; html += `<span class="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-mono text-xs mx-0.5" contenteditable="false">`;
html += `/${escapeHtml(a.action)}${a.args ? ' ' + escapeHtml(a.args) : ''}`;
html += '</span>';
} }
} }
return html; return html;
} }
/** Find the character offset of the cursor inside a contenteditable div */ /** Extract plain text content */
function getCaretOffset(container: HTMLElement): number { function getContentText(el: HTMLElement): string {
const selection = window.getSelection(); let text = '';
if (!selection || selection.rangeCount === 0) return container.textContent?.length ?? 0; for (const child of Array.from(el.childNodes)) {
const range = selection.getRangeAt(0); 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(); const preRange = range.cloneRange();
preRange.selectNodeContents(container); preRange.selectNodeContents(container);
preRange.setEnd(range.startContainer, range.startOffset); preRange.setEnd(range.startContainer, range.startOffset);
return preRange.toString().length; return preRange.toString().length;
} }
/** Place caret at a given character offset inside a contenteditable div */ /** Set selection at given character offset */
function setCaretOffset(container: HTMLElement, offset: number) { function setSelectionAtOffset(container: HTMLElement, offset: number) {
const selection = window.getSelection(); const sel = window.getSelection();
if (!selection) return; if (!sel) return;
const range = document.createRange(); const range = document.createRange();
let charCount = 0; let charCount = 0;
let found = false; let found = false;
function traverse(node: Node) { function walk(node: Node) {
if (found) return; if (found) return;
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ''; const text = node.textContent ?? '';
@ -100,42 +112,32 @@ function setCaretOffset(container: HTMLElement, offset: number) {
} }
charCount += text.length; charCount += text.length;
} else if (node.nodeType === Node.ELEMENT_NODE) { } else if (node.nodeType === Node.ELEMENT_NODE) {
for (const child of Array.from(node.childNodes)) { const el = node as HTMLElement;
traverse(child as Node); if (el.getAttribute('contenteditable') === 'false') {
if (found) return; 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) { if (!found) {
range.selectNodeContents(container); range.selectNodeContents(container);
range.collapse(false); range.collapse(false);
} }
sel.removeAllRanges();
selection.removeAllRanges(); sel.addRange(range);
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;
} }
export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(function MentionInput({ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(function MentionInput({
@ -144,105 +146,89 @@ export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(functi
onSend, onSend,
placeholder, placeholder,
disabled, disabled,
cursorRef, }, ref) {
}: MentionInputProps) { const elRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const pendingSyncRef = useRef(false);
const isComposingRef = useRef(false);
const lastValueRef = useRef(value); const lastValueRef = useRef(value);
const isInternalChangeRef = useRef(false);
// Keep cursorRef in sync with actual caret position // Sync DOM when external value changes
useEffect(() => { useEffect(() => {
const container = containerRef.current; if (pendingSyncRef.current) {
if (!container) return; pendingSyncRef.current = false;
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;
return; return;
} }
if (!containerRef.current) return; const el = elRef.current;
const currentText = containerRef.current.textContent ?? ''; if (!el) return;
const currentText = getContentText(el);
if (currentText === value) return; if (currentText === value) return;
const prevLen = lastValueRef.current.length; // Save cursor ratio
const cursor = getCaretOffset(containerRef.current); const curOffset = getSelectionOffset(el);
const ratio = prevLen > 0 ? cursor / prevLen : 0; 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 newOffset = Math.round(ratio * value.length);
const newCursor = Math.round(ratio * newLen); setSelectionAtOffset(el, Math.min(newOffset, value.length));
setCaretOffset(containerRef.current, Math.min(newCursor, newLen));
lastValueRef.current = value; lastValueRef.current = value;
}, [value]); }, [value]);
const handleInput = useCallback(() => { const handleInput = useCallback(() => {
if (isComposingRef.current) return; const el = elRef.current;
const container = containerRef.current; if (!el) return;
if (!container) return; const newText = getContentText(el);
const newValue = container.textContent ?? ''; pendingSyncRef.current = true;
lastValueRef.current = newValue; lastValueRef.current = newText;
isInternalChangeRef.current = true; onChange(newText);
onChange(newValue);
}, [onChange]); }, [onChange]);
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => { (e: React.KeyboardEvent<HTMLDivElement>) => {
const container = containerRef.current; // Ctrl+Enter → send
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
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
if (onSend) onSend(); onSend?.();
return; return;
} }
// Plain Enter → swallow. Shift+Enter inserts newline naturally.
// Plain Enter: swallow (Shift+Enter naturally inserts newline via default behavior)
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) { if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
} }
}, },
[onChange, onSend], [onSend],
); );
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
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<HTMLDivElement | null>).current = el;
}
}, [ref]);
return ( return (
<div <div
ref={containerRef} ref={elRef}
contentEditable={!disabled} contentEditable={!disabled}
suppressContentEditableWarning suppressContentEditableWarning
className={cn( className={cn(
'min-h-[80px] w-full resize-none overflow-y-auto rounded-md border border-input bg-background px-3 py-2', 'min-h-[80px] w-full resize-none overflow-y-auto rounded-md border border-input bg-background px-3 py-2',
'text-sm text-foreground outline-none', 'text-sm text-foreground outline-none',
'whitespace-pre-wrap break-words leading-5', 'whitespace-pre-wrap break-words leading-6',
disabled && 'opacity-50 cursor-not-allowed select-none', disabled && 'opacity-50 cursor-not-allowed select-none',
!value && 'empty',
)} )}
onInput={handleInput} onInput={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onCompositionStart={() => { isComposingRef.current = true; }} onPaste={handlePaste}
onCompositionEnd={handleInput}
data-placeholder={placeholder} data-placeholder={placeholder}
dangerouslySetInnerHTML={{ __html: textToHtml(value) }}
/> />
); );
}); });

View File

@ -60,7 +60,7 @@ interface MentionPopoverProps {
inputValue: string; inputValue: string;
cursorPosition: number; cursorPosition: number;
onSelect: (newValue: string, newCursorPosition: number) => void; onSelect: (newValue: string, newCursorPosition: number) => void;
textareaRef: React.RefObject<HTMLTextAreaElement | null>; textareaRef: React.RefObject<HTMLDivElement | null>;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
/** Called when Enter is pressed on a category — ChatInputArea handles the state update */ /** Called when Enter is pressed on a category — ChatInputArea handles the state update */
onCategoryEnter: (category: string) => void; onCategoryEnter: (category: string) => void;
@ -508,19 +508,40 @@ export function MentionPopover({
const handleSelect = useCallback( const handleSelect = useCallback(
(suggestion: MentionSuggestion) => { (suggestion: MentionSuggestion) => {
if (!textareaRef.current || suggestion.type !== 'item') return;
const textarea = textareaRef.current; const textarea = textareaRef.current;
const cursorPos = textarea.selectionStart; if (!textarea || suggestion.type !== 'item') return;
const textBefore = textarea.value.substring(0, cursorPos);
// 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<]*))?$/); const atMatch = textBefore.match(/@([^:@\s<]*)(:([^\s<]*))?$/);
if (!atMatch) return; if (!atMatch) return;
const [fullMatch] = atMatch; const [fullMatch] = atMatch;
const startPos = cursorPos - fullMatch.length; const startPos = cursorPos - fullMatch.length;
const before = textarea.value.substring(0, startPos); const before = text.substring(0, startPos);
const after = textarea.value.substring(cursorPos); const after = text.substring(cursorPos);
const html = buildMentionHtml( const html = buildMentionHtml(
suggestion.category!, suggestion.category!,
@ -558,7 +579,29 @@ export function MentionPopover({
document.body.appendChild(tempDiv); 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 = [ const styleProps = [
'font-family', 'font-family',
'font-size', 'font-size',
@ -576,7 +619,6 @@ export function MentionPopover({
'width', 'width',
] as const; ] as const;
tempDiv.style.width = styles.width;
for (const prop of styleProps) { for (const prop of styleProps) {
tempDiv.style.setProperty(prop, styles.getPropertyValue(prop)); tempDiv.style.setProperty(prop, styles.getPropertyValue(prop));
} }
@ -587,11 +629,21 @@ export function MentionPopover({
tempDiv.appendChild(span); tempDiv.appendChild(span);
const rect = textarea.getBoundingClientRect(); 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 lineHeight = Number.parseFloat(styles.lineHeight) || 20;
const borderLeft = Number.parseFloat(styles.borderLeftWidth) || 0; const borderLeft = Number.parseFloat(styles.borderLeftWidth) || 0;
const borderTop = Number.parseFloat(styles.borderTopWidth) || 0; const borderTop = Number.parseFloat(styles.borderTopWidth) || 0;
const contentTop = rect.top + borderTop + span.offsetTop - textarea.scrollTop; const contentTop = rect.top + borderTop + span.offsetTop - scrollTop;
const contentLeft = rect.left + borderLeft + span.offsetLeft - textarea.scrollLeft; const contentLeft = rect.left + borderLeft + span.offsetLeft - scrollLeft;
const popoverWidth = 320; const popoverWidth = 320;
const popoverHeight = Math.min(360, visibleSuggestions.length * 56 + 100); const popoverHeight = Math.min(360, visibleSuggestions.length * 56 + 100);
@ -608,7 +660,7 @@ export function MentionPopover({
: Math.max(vp, contentTop - popoverHeight - 8); : Math.max(vp, contentTop - popoverHeight - 8);
setPosition({ top, left }); setPosition({ top, left });
}, [mentionState, inputValue, textareaRef, cursorPosition, visibleSuggestions.length]); }, [mentionState, textareaRef, visibleSuggestions.length]);
// Keyboard navigation // Keyboard navigation
useEffect(() => { useEffect(() => {

View File

@ -72,13 +72,68 @@ const ChatInputArea = memo(function ChatInputArea({
ref, ref,
}: ChatInputAreaProps) { }: ChatInputAreaProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const cursorRef = useRef(0);
const [showMentionPopover, setShowMentionPopover] = useState(false); 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) => { const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => {
if (!containerRef.current) return; if (!containerRef.current) return;
const container = containerRef.current; const cursorPos = getCaretOffset();
const cursorPos = cursorRef.current;
const textBefore = draft.substring(0, cursorPos); const textBefore = draft.substring(0, cursorPos);
const atMatch = textBefore.match(MENTION_PATTERN); const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return; if (!atMatch) return;
@ -104,49 +159,16 @@ const ChatInputArea = memo(function ChatInputArea({
onDraftChange(newValue); onDraftChange(newValue);
setShowMentionPopover(false); setShowMentionPopover(false);
setTimeout(() => { setTimeout(() => {
const container = containerRef.current; if (!containerRef.current) return;
if (!container) return; containerRef.current.focus();
container.innerHTML = newValue setCaretOffset(newCursorPos);
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
// 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();
}, 0); }, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Uses draft from closure }, []); // Uses draft from closure
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
insertMention: (id: string, label: string) => { insertMention: (id: string, label: string) => {
const cursorPos = cursorRef.current; const cursorPos = getCaretOffset();
const escapedLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapedLabel = label.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escapedId = id.replace(/"/g, '&quot;'); const escapedId = id.replace(/"/g, '&quot;');
const mentionText = `<mention type="user" id="${escapedId}">${escapedLabel}</mention> `; const mentionText = `<mention type="user" id="${escapedId}">${escapedLabel}</mention> `;
@ -160,7 +182,7 @@ const ChatInputArea = memo(function ChatInputArea({
}, 0); }, 0);
}, },
insertCategory: (category: string) => { insertCategory: (category: string) => {
const cursorPos = cursorRef.current; const cursorPos = getCaretOffset();
const textBefore = draft.substring(0, cursorPos); const textBefore = draft.substring(0, cursorPos);
const atMatch = textBefore.match(MENTION_PATTERN); const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return; if (!atMatch) return;
@ -179,7 +201,7 @@ const ChatInputArea = memo(function ChatInputArea({
useEffect(() => { useEffect(() => {
const onMentionClick = (e: Event) => { const onMentionClick = (e: Event) => {
const { type, id, label } = (e as CustomEvent<{ type: string; id: string; label: string }>).detail; 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 textBefore = draft.substring(0, cursorPos);
const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label); const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label);
const spacer = ' '; const spacer = ' ';
@ -187,36 +209,9 @@ const ChatInputArea = memo(function ChatInputArea({
const newCursorPos = cursorPos + html.length + spacer.length; const newCursorPos = cursorPos + html.length + spacer.length;
onDraftChange(newValue); onDraftChange(newValue);
setTimeout(() => { setTimeout(() => {
const container = containerRef.current; if (!containerRef.current) return;
if (!container) return; containerRef.current.focus();
container.focus(); setCaretOffset(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);
}, 0); }, 0);
}; };
document.addEventListener('mention-click', onMentionClick); document.addEventListener('mention-click', onMentionClick);
@ -240,10 +235,9 @@ const ChatInputArea = memo(function ChatInputArea({
<MentionInput <MentionInput
ref={containerRef} ref={containerRef}
value={draft} value={draft}
cursorRef={cursorRef}
onChange={(v) => { onChange={(v) => {
onDraftChange(v); onDraftChange(v);
const textBefore = v.substring(0, cursorRef.current); const textBefore = v.substring(0, getCaretOffset());
if (textBefore.match(MENTION_PATTERN)) { if (textBefore.match(MENTION_PATTERN)) {
setShowMentionPopover(true); setShowMentionPopover(true);
} else { } else {
@ -287,12 +281,12 @@ const ChatInputArea = memo(function ChatInputArea({
aiConfigs={aiConfigs} aiConfigs={aiConfigs}
aiConfigsLoading={aiConfigsLoading} aiConfigsLoading={aiConfigsLoading}
inputValue={draft} inputValue={draft}
cursorPosition={cursorRef.current} cursorPosition={getCaretOffset()}
onSelect={handleMentionSelect} onSelect={handleMentionSelect}
textareaRef={containerRef} textareaRef={containerRef}
onOpenChange={setShowMentionPopover} onOpenChange={setShowMentionPopover}
onCategoryEnter={(category: string) => { onCategoryEnter={(category: string) => {
const cursorPos = cursorRef.current; const cursorPos = getCaretOffset();
const textBefore = draft.substring(0, cursorPos); const textBefore = draft.substring(0, cursorPos);
const atMatch = textBefore.match(MENTION_PATTERN); const atMatch = textBefore.match(MENTION_PATTERN);
if (!atMatch) return; if (!atMatch) return;