feat(frontend): render mentions as styled buttons in input and messages
- MentionInput: contenteditable div that renders all mention types as emoji+label pill spans (🤖 AI, 👤 User, 📦 Repo, 🔔 Notify) - Single-backspace deletes entire mention at cursor (detects caret at mention start boundary) - Ctrl+Enter sends, plain Enter swallowed, Shift+Enter inserts newline - Placeholder CSS via data-placeholder attribute - MessageMentions: emoji button rendering extended to all mention types (user, repository, notify) with click-to-insert support - Rich input synced via cursorRef (no stale-state re-renders)
This commit is contained in:
parent
b96ef0342c
commit
b7328e22f3
248
src/components/room/MentionInput.tsx
Normal file
248
src/components/room/MentionInput.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { parse, type Node } from '@/lib/mention-ast';
|
||||||
|
import { forwardRef, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface MentionInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSend?: () => void;
|
||||||
|
placeholder?: string;
|
||||||
|
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> = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
ai: '🤖',
|
||||||
|
user: '👤',
|
||||||
|
repository: '📦',
|
||||||
|
notify: '🔔',
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
ai: 'AI',
|
||||||
|
user: 'User',
|
||||||
|
repository: 'Repo',
|
||||||
|
notify: 'Notify',
|
||||||
|
};
|
||||||
|
|
||||||
|
function textToHtml(text: string): string {
|
||||||
|
const nodes = parse(text);
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
html += (node as Node & { type: 'text' }).text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br>');
|
||||||
|
} 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 += `<span class="mention-span" data-mention-id="${m.id}" data-mention-type="${m.mentionType}">`;
|
||||||
|
html += `<span class="mention-icon">${icon}</span>`;
|
||||||
|
html += `<span class="mention-label">${label}:</span>`;
|
||||||
|
html += `<span class="mention-name">${m.label}</span>`;
|
||||||
|
html += '</span>';
|
||||||
|
} else if (node.type === 'ai_action') {
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
const range = document.createRange();
|
||||||
|
let charCount = 0;
|
||||||
|
let found = false;
|
||||||
|
|
||||||
|
function traverse(node: Node) {
|
||||||
|
if (found) return;
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
const text = node.textContent ?? '';
|
||||||
|
if (charCount + text.length >= offset) {
|
||||||
|
range.setStart(node, Math.min(offset - charCount, text.length));
|
||||||
|
range.collapse(true);
|
||||||
|
found = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(container as unknown as Node);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(function MentionInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onSend,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
cursorRef,
|
||||||
|
}: MentionInputProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isComposingRef = useRef(false);
|
||||||
|
const lastValueRef = useRef(value);
|
||||||
|
const isInternalChangeRef = useRef(false);
|
||||||
|
|
||||||
|
// Keep cursorRef in sync with actual caret position
|
||||||
|
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;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const currentText = containerRef.current.textContent ?? '';
|
||||||
|
if (currentText === value) return;
|
||||||
|
|
||||||
|
const prevLen = lastValueRef.current.length;
|
||||||
|
const cursor = getCaretOffset(containerRef.current);
|
||||||
|
const ratio = prevLen > 0 ? cursor / prevLen : 0;
|
||||||
|
|
||||||
|
containerRef.current.innerHTML = textToHtml(value);
|
||||||
|
|
||||||
|
const newLen = value.length;
|
||||||
|
const newCursor = Math.round(ratio * newLen);
|
||||||
|
setCaretOffset(containerRef.current, Math.min(newCursor, newLen));
|
||||||
|
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);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
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
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (onSend) onSend();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain Enter: swallow (Shift+Enter naturally inserts newline via default behavior)
|
||||||
|
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange, onSend],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
contentEditable={!disabled}
|
||||||
|
suppressContentEditableWarning
|
||||||
|
className={cn(
|
||||||
|
'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',
|
||||||
|
'whitespace-pre-wrap break-words leading-5',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed select-none',
|
||||||
|
)}
|
||||||
|
onInput={handleInput}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onCompositionStart={() => { isComposingRef.current = true; }}
|
||||||
|
onCompositionEnd={handleInput}
|
||||||
|
data-placeholder={placeholder}
|
||||||
|
dangerouslySetInnerHTML={{ __html: textToHtml(value) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -127,42 +127,49 @@ function renderNode(
|
|||||||
}
|
}
|
||||||
if (node.type === 'mention') {
|
if (node.type === 'mention') {
|
||||||
const displayName = resolveName(node.mentionType, node.id, node.label);
|
const displayName = resolveName(node.mentionType, node.id, node.label);
|
||||||
const baseClass = mentionStyles[node.mentionType] ?? mentionStyles.user;
|
|
||||||
|
|
||||||
if (node.mentionType === 'ai') {
|
const iconMap: Record<string, string> = {
|
||||||
return (
|
ai: '🤖',
|
||||||
<button
|
user: '👤',
|
||||||
key={index}
|
repository: '📦',
|
||||||
type="button"
|
notify: '🔔',
|
||||||
className={cn(
|
};
|
||||||
baseClass,
|
const labelMap: Record<string, string> = {
|
||||||
'inline-flex items-center gap-1 cursor-pointer border-0 bg-transparent p-0',
|
ai: 'AI',
|
||||||
'hover:opacity-80 transition-opacity',
|
user: 'User',
|
||||||
)}
|
repository: 'Repo',
|
||||||
onClick={() => {
|
notify: 'Notify',
|
||||||
document.dispatchEvent(
|
};
|
||||||
new CustomEvent('mention-click', {
|
|
||||||
detail: {
|
const icon = iconMap[node.mentionType] ?? '🏷';
|
||||||
type: 'ai',
|
const label = labelMap[node.mentionType] ?? 'Mention';
|
||||||
id: node.id,
|
|
||||||
label: displayName,
|
|
||||||
},
|
|
||||||
bubbles: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-sm">🤖</span>
|
|
||||||
<span>AI:</span>
|
|
||||||
<span className="font-medium">{displayName}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span key={index} className={baseClass}>
|
<button
|
||||||
@{displayName}
|
key={index}
|
||||||
</span>
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
mentionStyles[node.mentionType] ?? mentionStyles.user,
|
||||||
|
'inline-flex items-center gap-1 cursor-pointer border-0 bg-transparent p-0',
|
||||||
|
'hover:opacity-80 transition-opacity',
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent('mention-click', {
|
||||||
|
detail: {
|
||||||
|
type: node.mentionType,
|
||||||
|
id: node.id,
|
||||||
|
label: displayName,
|
||||||
|
},
|
||||||
|
bubbles: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{icon}</span>
|
||||||
|
<span>{label}:</span>
|
||||||
|
<span className="font-medium">{displayName}</span>
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (node.type === 'ai_action') {
|
if (node.type === 'ai_action') {
|
||||||
|
|||||||
@ -3,10 +3,10 @@ import { useRoom, type MessageWithMeta } from '@/contexts';
|
|||||||
import { type RoomAiConfig } from '@/contexts/room-context';
|
import { type RoomAiConfig } from '@/contexts/room-context';
|
||||||
import { useRoomDraft } from '@/hooks/useRoomDraft';
|
import { useRoomDraft } from '@/hooks/useRoomDraft';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { buildMentionHtml } from '@/lib/mention-ast';
|
import { buildMentionHtml } from '@/lib/mention-ast';
|
||||||
import { mentionSelectedIdxRef, mentionVisibleRef } from '@/lib/mention-refs';
|
import { mentionSelectedIdxRef, mentionVisibleRef } from '@/lib/mention-refs';
|
||||||
|
import { MentionInput } from './MentionInput';
|
||||||
import { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
|
import { ChevronLeft, Hash, Send, Settings, Timer, Trash2, Users, X, Search, Bell } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
@ -30,7 +30,6 @@ import { RoomMentionPanel } from './RoomMentionPanel';
|
|||||||
import { RoomThreadPanel } from './RoomThreadPanel';
|
import { RoomThreadPanel } from './RoomThreadPanel';
|
||||||
|
|
||||||
const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/;
|
const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/;
|
||||||
const MENTION_POPOVER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowDown'];
|
|
||||||
|
|
||||||
|
|
||||||
export interface ChatInputAreaHandle {
|
export interface ChatInputAreaHandle {
|
||||||
@ -72,23 +71,23 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
onClearDraft,
|
onClearDraft,
|
||||||
ref,
|
ref,
|
||||||
}: ChatInputAreaProps) {
|
}: ChatInputAreaProps) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [cursorPosition, setCursorPosition] = useState(0);
|
const cursorRef = useRef(0);
|
||||||
const [showMentionPopover, setShowMentionPopover] = useState(false);
|
const [showMentionPopover, setShowMentionPopover] = useState(false);
|
||||||
|
|
||||||
const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => {
|
const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => {
|
||||||
if (!textareaRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const textarea = textareaRef.current;
|
const container = containerRef.current;
|
||||||
const cursorPos = textarea.selectionStart;
|
const cursorPos = cursorRef.current;
|
||||||
const textBefore = textarea.value.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;
|
||||||
|
|
||||||
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 = draft.substring(0, startPos);
|
||||||
const after = textarea.value.substring(cursorPos);
|
const after = draft.substring(cursorPos);
|
||||||
|
|
||||||
const suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
|
const suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
|
||||||
if (!suggestion || suggestion.type !== 'item') return;
|
if (!suggestion || suggestion.type !== 'item') return;
|
||||||
@ -105,158 +104,124 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
onDraftChange(newValue);
|
onDraftChange(newValue);
|
||||||
setShowMentionPopover(false);
|
setShowMentionPopover(false);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (textareaRef.current) {
|
const container = containerRef.current;
|
||||||
textareaRef.current.value = newValue;
|
if (!container) return;
|
||||||
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
container.innerHTML = newValue
|
||||||
textareaRef.current.focus();
|
.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
|
||||||
}, []); // Intentionally use refs, not state
|
}, []); // Uses draft from closure
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
insertMention: (id: string, label: string) => {
|
insertMention: (id: string, label: string) => {
|
||||||
if (!textareaRef.current) return;
|
const cursorPos = cursorRef.current;
|
||||||
const value = textareaRef.current.value;
|
|
||||||
const cursorPos = textareaRef.current.selectionStart;
|
|
||||||
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> `;
|
||||||
const before = value.substring(0, cursorPos);
|
const before = draft.substring(0, cursorPos);
|
||||||
const after = value.substring(cursorPos);
|
const after = draft.substring(cursorPos);
|
||||||
const newValue = before + mentionText + after;
|
const newValue = before + mentionText + after;
|
||||||
onDraftChange(newValue);
|
onDraftChange(newValue);
|
||||||
setShowMentionPopover(false);
|
setShowMentionPopover(false);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (textareaRef.current) {
|
containerRef.current?.focus();
|
||||||
textareaRef.current.focus();
|
|
||||||
}
|
|
||||||
}, 0);
|
}, 0);
|
||||||
},
|
},
|
||||||
insertCategory: (category: string) => {
|
insertCategory: (category: string) => {
|
||||||
// Enter a category: e.g. @ai → @ai:
|
const cursorPos = cursorRef.current;
|
||||||
if (!textareaRef.current) return;
|
const textBefore = draft.substring(0, cursorPos);
|
||||||
const textarea = textareaRef.current;
|
|
||||||
const textBefore = textarea.value.substring(0, textarea.selectionStart);
|
|
||||||
const atMatch = textBefore.match(MENTION_PATTERN);
|
const atMatch = textBefore.match(MENTION_PATTERN);
|
||||||
if (!atMatch) return;
|
if (!atMatch) return;
|
||||||
const [fullMatch] = atMatch;
|
const [fullMatch] = atMatch;
|
||||||
const startPos = textarea.selectionStart - fullMatch.length;
|
const startPos = cursorPos - fullMatch.length;
|
||||||
const before = textarea.value.substring(0, startPos);
|
const before = draft.substring(0, startPos);
|
||||||
const afterPartial = textarea.value.substring(startPos + fullMatch.length);
|
const afterPartial = draft.substring(startPos + fullMatch.length);
|
||||||
const newValue = before + '@' + category + ':' + afterPartial;
|
const newValue = before + '@' + category + ':' + afterPartial;
|
||||||
const newCursorPos = startPos + 1 + category.length + 1; // after '@ai:'
|
const newCursorPos = startPos + 1 + category.length + 1;
|
||||||
// Directly update React state to trigger mention detection
|
|
||||||
onDraftChange(newValue);
|
onDraftChange(newValue);
|
||||||
setCursorPosition(newCursorPos);
|
setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN));
|
||||||
const textBefore2 = newValue.substring(0, newCursorPos);
|
|
||||||
const match = textBefore2.match(MENTION_PATTERN);
|
|
||||||
setShowMentionPopover(!!match);
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
const cursorPos = e.target.selectionStart;
|
|
||||||
onDraftChange(value);
|
|
||||||
setCursorPosition(cursorPos);
|
|
||||||
|
|
||||||
// Detect @ mention
|
|
||||||
const textBefore = value.substring(0, cursorPos);
|
|
||||||
const match = textBefore.match(MENTION_PATTERN);
|
|
||||||
if (match) {
|
|
||||||
setShowMentionPopover(true);
|
|
||||||
} else {
|
|
||||||
setShowMentionPopover(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
// Detect mention directly from DOM — avoids stale React state between handleChange and handleKeyDown
|
|
||||||
const hasMention = textareaRef.current
|
|
||||||
? textareaRef.current.value.substring(0, textareaRef.current.selectionStart).match(MENTION_PATTERN) !== null
|
|
||||||
: false;
|
|
||||||
|
|
||||||
if (hasMention && MENTION_POPOVER_KEYS.includes(e.key) && !e.ctrlKey && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
// Enter/Tab: do mention selection directly here using module-level refs
|
|
||||||
if ((e.key === 'Enter' || e.key === 'Tab')) {
|
|
||||||
const suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current];
|
|
||||||
if (suggestion && suggestion.type === 'item') {
|
|
||||||
const textarea = textareaRef.current;
|
|
||||||
if (!textarea) return;
|
|
||||||
const cursorPos = textarea.selectionStart;
|
|
||||||
const textBefore = textarea.value.substring(0, cursorPos);
|
|
||||||
const atMatch = textBefore.match(MENTION_PATTERN);
|
|
||||||
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 html = buildMentionHtml(suggestion.category!, suggestion.mentionId!, suggestion.label);
|
|
||||||
const spacer = ' ';
|
|
||||||
const newValue = before + html + spacer + after;
|
|
||||||
const newCursorPos = startPos + html.length + spacer.length;
|
|
||||||
onDraftChange(newValue);
|
|
||||||
setShowMentionPopover(false);
|
|
||||||
setTimeout(() => {
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.value = newValue;
|
|
||||||
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
|
||||||
textareaRef.current.focus();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shift+Enter → let textarea naturally insert newline (pass through)
|
|
||||||
|
|
||||||
// Ctrl+Enter → send message
|
|
||||||
if (e.key === 'Enter' && e.ctrlKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
const content = e.currentTarget.value.trim();
|
|
||||||
if (content && !isSending) {
|
|
||||||
onSend(content);
|
|
||||||
onClearDraft();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plain Enter (no modifiers) → only trigger mention select; otherwise do nothing
|
|
||||||
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
// Do nothing — Shift+Enter falls through to let textarea insert newline
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listen for mention-click events from message content (e.g. 🤖 AI button)
|
// Listen for mention-click events from message content (e.g. 🤖 AI button)
|
||||||
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;
|
||||||
if (!textareaRef.current) return;
|
const cursorPos = cursorRef.current;
|
||||||
const textarea = textareaRef.current;
|
const textBefore = draft.substring(0, cursorPos);
|
||||||
const cursorPos = textarea.selectionStart;
|
const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label);
|
||||||
const textBefore = textarea.value.substring(0, cursorPos);
|
const spacer = ' ';
|
||||||
|
const newValue = textBefore + html + spacer + draft.substring(cursorPos);
|
||||||
if (type === 'ai') {
|
const newCursorPos = cursorPos + html.length + spacer.length;
|
||||||
// Insert @ai:mention at cursor position
|
onDraftChange(newValue);
|
||||||
const html = buildMentionHtml('ai', id, label);
|
setTimeout(() => {
|
||||||
const spacer = ' ';
|
const container = containerRef.current;
|
||||||
const newValue = textBefore + html + spacer + textarea.value.substring(cursorPos);
|
if (!container) return;
|
||||||
const newCursorPos = cursorPos + html.length + spacer.length;
|
container.focus();
|
||||||
onDraftChange(newValue);
|
const sel = window.getSelection();
|
||||||
setTimeout(() => {
|
if (!sel) return;
|
||||||
if (textareaRef.current) {
|
const range = document.createRange();
|
||||||
textareaRef.current.value = newValue;
|
let charCount = 0;
|
||||||
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
let found = false;
|
||||||
textareaRef.current.focus();
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 0);
|
}
|
||||||
}
|
walk(container);
|
||||||
|
if (!found) { range.selectNodeContents(container); range.collapse(false); }
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
}, 0);
|
||||||
};
|
};
|
||||||
document.addEventListener('mention-click', onMentionClick);
|
document.addEventListener('mention-click', onMentionClick);
|
||||||
return () => document.removeEventListener('mention-click', onMentionClick);
|
return () => document.removeEventListener('mention-click', onMentionClick);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -272,20 +237,34 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Textarea
|
<MentionInput
|
||||||
ref={textareaRef}
|
ref={containerRef}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={handleChange}
|
cursorRef={cursorRef}
|
||||||
onKeyDown={handleKeyDown}
|
onChange={(v) => {
|
||||||
|
onDraftChange(v);
|
||||||
|
const textBefore = v.substring(0, cursorRef.current);
|
||||||
|
if (textBefore.match(MENTION_PATTERN)) {
|
||||||
|
setShowMentionPopover(true);
|
||||||
|
} else {
|
||||||
|
setShowMentionPopover(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSend={() => {
|
||||||
|
const content = draft.trim();
|
||||||
|
if (content && !isSending) {
|
||||||
|
onSend(content);
|
||||||
|
onClearDraft();
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={`Message #${roomName}...`}
|
placeholder={`Message #${roomName}...`}
|
||||||
className="min-h-[80px] resize-none pr-20"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute bottom-2 right-2 flex items-center gap-1">
|
<div className="absolute bottom-2 right-2 flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const content = textareaRef.current?.value.trim();
|
const content = draft.trim();
|
||||||
if (content && !isSending) {
|
if (content && !isSending) {
|
||||||
onSend(content);
|
onSend(content);
|
||||||
onClearDraft();
|
onClearDraft();
|
||||||
@ -308,27 +287,23 @@ const ChatInputArea = memo(function ChatInputArea({
|
|||||||
aiConfigs={aiConfigs}
|
aiConfigs={aiConfigs}
|
||||||
aiConfigsLoading={aiConfigsLoading}
|
aiConfigsLoading={aiConfigsLoading}
|
||||||
inputValue={draft}
|
inputValue={draft}
|
||||||
cursorPosition={cursorPosition}
|
cursorPosition={cursorRef.current}
|
||||||
onSelect={handleMentionSelect}
|
onSelect={handleMentionSelect}
|
||||||
textareaRef={textareaRef}
|
textareaRef={containerRef}
|
||||||
onOpenChange={setShowMentionPopover}
|
onOpenChange={setShowMentionPopover}
|
||||||
onCategoryEnter={(category: string) => {
|
onCategoryEnter={(category: string) => {
|
||||||
// Enter a category: @ai → @ai: to show next-level items
|
const cursorPos = cursorRef.current;
|
||||||
if (!textareaRef.current) return;
|
const textBefore = draft.substring(0, cursorPos);
|
||||||
const textarea = textareaRef.current;
|
|
||||||
const textBefore = textarea.value.substring(0, textarea.selectionStart);
|
|
||||||
const atMatch = textBefore.match(MENTION_PATTERN);
|
const atMatch = textBefore.match(MENTION_PATTERN);
|
||||||
if (!atMatch) return;
|
if (!atMatch) return;
|
||||||
const [fullMatch] = atMatch;
|
const [fullMatch] = atMatch;
|
||||||
const startPos = textarea.selectionStart - fullMatch.length;
|
const startPos = cursorPos - fullMatch.length;
|
||||||
const before = textarea.value.substring(0, startPos);
|
const before = draft.substring(0, startPos);
|
||||||
const afterPartial = textarea.value.substring(startPos + fullMatch.length);
|
const afterPartial = draft.substring(startPos + fullMatch.length);
|
||||||
const newValue = before + '@' + category + ':' + afterPartial;
|
const newValue = before + '@' + category + ':' + afterPartial;
|
||||||
const newCursorPos = startPos + 1 + category.length + 1;
|
const newCursorPos = startPos + 1 + category.length + 1;
|
||||||
onDraftChange(newValue);
|
onDraftChange(newValue);
|
||||||
setCursorPosition(newCursorPos);
|
setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN));
|
||||||
const match2 = newValue.substring(0, newCursorPos).match(MENTION_PATTERN);
|
|
||||||
setShowMentionPopover(!!match2);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -126,4 +126,11 @@
|
|||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder support for contenteditable MentionInput */
|
||||||
|
[contenteditable][data-placeholder]:empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user