fix(frontend): repair MentionInput contenteditable implementation
- 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:
parent
b7328e22f3
commit
d2935f3ddc
@ -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, '&').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) {
|
if (nodes.length === 0) {
|
||||||
return text
|
return escapeHtml(value).replace(/\n/g, '<br>');
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.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) }}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.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, '<').replace(/>/g, '>');
|
const escapedLabel = label.replace(/</g, '<').replace(/>/g, '>');
|
||||||
const escapedId = id.replace(/"/g, '"');
|
const escapedId = id.replace(/"/g, '"');
|
||||||
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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user