From b7328e22f3f39da08c16968c2866cb6a8c215355 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 18 Apr 2026 01:06:39 +0800 Subject: [PATCH] feat(frontend): render mentions as styled buttons in input and messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/components/room/MentionInput.tsx | 248 ++++++++++++++++++++++ src/components/room/MessageMentions.tsx | 73 ++++--- src/components/room/RoomChatPanel.tsx | 271 +++++++++++------------- src/index.css | 7 + 4 files changed, 418 insertions(+), 181 deletions(-) create mode 100644 src/components/room/MentionInput.tsx diff --git a/src/components/room/MentionInput.tsx b/src/components/room/MentionInput.tsx new file mode 100644 index 0000000..398d139 --- /dev/null +++ b/src/components/room/MentionInput.tsx @@ -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; +} + +const mentionStyles: Record = { + user: 'inline-flex items-center rounded bg-blue-100/80 px-1.5 py-0.5 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium text-sm leading-5', + repository: 'inline-flex items-center rounded bg-purple-100/80 px-1.5 py-0.5 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300 font-medium text-sm leading-5', + ai: 'inline-flex items-center rounded bg-green-100/80 px-1.5 py-0.5 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium text-sm leading-5', + notify: 'inline-flex items-center rounded bg-yellow-100/80 px-1.5 py-0.5 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 font-medium text-sm leading-5', +}; + +const iconMap: Record = { + ai: '🤖', + user: '👤', + repository: '📦', + notify: '🔔', +}; + +const labelMap: Record = { + 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(/\n/g, '
'); + } + + let html = ''; + for (const node of nodes) { + if (node.type === 'text') { + html += (node as Node & { type: 'text' }).text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + } else if (node.type === 'mention') { + const m = node as Node & { type: 'mention'; mentionType: string; id: string; label: string }; + const style = mentionStyles[m.mentionType] ?? mentionStyles.user; + const icon = iconMap[m.mentionType] ?? '🏷'; + const label = labelMap[m.mentionType] ?? 'Mention'; + html += ``; + html += `${icon}`; + html += `${label}:`; + html += `${m.label}`; + html += ''; + } else if (node.type === 'ai_action') { + const a = node as Node & { type: 'ai_action'; action: string; args?: string }; + html += `/${a.action}${a.args ? ' ' + a.args : ''}`; + } + } + 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(function MentionInput({ + value, + onChange, + onSend, + placeholder, + disabled, + cursorRef, +}: MentionInputProps) { + const containerRef = useRef(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) => { + 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 ( +
{ isComposingRef.current = true; }} + onCompositionEnd={handleInput} + data-placeholder={placeholder} + dangerouslySetInnerHTML={{ __html: textToHtml(value) }} + /> + ); +}); diff --git a/src/components/room/MessageMentions.tsx b/src/components/room/MessageMentions.tsx index 51d2b5f..1db37cf 100644 --- a/src/components/room/MessageMentions.tsx +++ b/src/components/room/MessageMentions.tsx @@ -127,42 +127,49 @@ function renderNode( } if (node.type === 'mention') { const displayName = resolveName(node.mentionType, node.id, node.label); - const baseClass = mentionStyles[node.mentionType] ?? mentionStyles.user; - if (node.mentionType === 'ai') { - return ( - - ); - } + const iconMap: Record = { + ai: '🤖', + user: '👤', + repository: '📦', + notify: '🔔', + }; + const labelMap: Record = { + ai: 'AI', + user: 'User', + repository: 'Repo', + notify: 'Notify', + }; + + const icon = iconMap[node.mentionType] ?? '🏷'; + const label = labelMap[node.mentionType] ?? 'Mention'; return ( - - @{displayName} - + ); } if (node.type === 'ai_action') { diff --git a/src/components/room/RoomChatPanel.tsx b/src/components/room/RoomChatPanel.tsx index 2d07658..2343425 100644 --- a/src/components/room/RoomChatPanel.tsx +++ b/src/components/room/RoomChatPanel.tsx @@ -3,10 +3,10 @@ import { useRoom, type MessageWithMeta } from '@/contexts'; import { type RoomAiConfig } from '@/contexts/room-context'; import { useRoomDraft } from '@/hooks/useRoomDraft'; import { Button } from '@/components/ui/button'; -import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; import { buildMentionHtml } from '@/lib/mention-ast'; 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 { memo, @@ -30,7 +30,6 @@ import { RoomMentionPanel } from './RoomMentionPanel'; import { RoomThreadPanel } from './RoomThreadPanel'; const MENTION_PATTERN = /@([^:@\s]*)(:([^\s]*))?$/; -const MENTION_POPOVER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowDown']; export interface ChatInputAreaHandle { @@ -72,23 +71,23 @@ const ChatInputArea = memo(function ChatInputArea({ onClearDraft, ref, }: ChatInputAreaProps) { - const textareaRef = useRef(null); - const [cursorPosition, setCursorPosition] = useState(0); + const containerRef = useRef(null); + const cursorRef = useRef(0); const [showMentionPopover, setShowMentionPopover] = useState(false); const handleMentionSelect = useCallback((_newValue: string, _newCursorPos: number) => { - if (!textareaRef.current) return; - const textarea = textareaRef.current; - const cursorPos = textarea.selectionStart; - const textBefore = textarea.value.substring(0, cursorPos); + if (!containerRef.current) return; + const container = containerRef.current; + const cursorPos = cursorRef.current; + const textBefore = draft.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 before = draft.substring(0, startPos); + const after = draft.substring(cursorPos); const suggestion = mentionVisibleRef.current[mentionSelectedIdxRef.current]; if (!suggestion || suggestion.type !== 'item') return; @@ -105,158 +104,124 @@ const ChatInputArea = memo(function ChatInputArea({ onDraftChange(newValue); setShowMentionPopover(false); setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.value = newValue; - textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); - textareaRef.current.focus(); + const container = containerRef.current; + if (!container) return; + container.innerHTML = newValue + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + // Place cursor at newCursorPos + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + let charCount = 0; + let found = false; + function walk(node: Node) { + if (found) return; + if (node.nodeType === Node.TEXT_NODE) { + const t = node.textContent ?? ''; + if (charCount + t.length >= newCursorPos) { + range.setStart(node, Math.min(newCursorPos - charCount, t.length)); + range.collapse(true); + found = true; + return; + } + charCount += t.length; + } else if (node.nodeType === Node.ELEMENT_NODE) { + for (const c of Array.from(node.childNodes)) { + walk(c); + if (found) return; + } + } } + walk(container); + if (!found) { range.selectNodeContents(container); range.collapse(false); } + sel.removeAllRanges(); + sel.addRange(range); + container.focus(); }, 0); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Intentionally use refs, not state + }, []); // Uses draft from closure useImperativeHandle(ref, () => ({ insertMention: (id: string, label: string) => { - if (!textareaRef.current) return; - const value = textareaRef.current.value; - const cursorPos = textareaRef.current.selectionStart; + const cursorPos = cursorRef.current; const escapedLabel = label.replace(//g, '>'); const escapedId = id.replace(/"/g, '"'); const mentionText = `${escapedLabel} `; - const before = value.substring(0, cursorPos); - const after = value.substring(cursorPos); + const before = draft.substring(0, cursorPos); + const after = draft.substring(cursorPos); const newValue = before + mentionText + after; onDraftChange(newValue); setShowMentionPopover(false); setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.focus(); - } + containerRef.current?.focus(); }, 0); }, insertCategory: (category: string) => { - // Enter a category: e.g. @ai → @ai: - if (!textareaRef.current) return; - const textarea = textareaRef.current; - const textBefore = textarea.value.substring(0, textarea.selectionStart); + const cursorPos = cursorRef.current; + const textBefore = draft.substring(0, cursorPos); const atMatch = textBefore.match(MENTION_PATTERN); if (!atMatch) return; const [fullMatch] = atMatch; - const startPos = textarea.selectionStart - fullMatch.length; - const before = textarea.value.substring(0, startPos); - const afterPartial = textarea.value.substring(startPos + fullMatch.length); + const startPos = cursorPos - fullMatch.length; + const before = draft.substring(0, startPos); + const afterPartial = draft.substring(startPos + fullMatch.length); const newValue = before + '@' + category + ':' + afterPartial; - const newCursorPos = startPos + 1 + category.length + 1; // after '@ai:' - // Directly update React state to trigger mention detection + const newCursorPos = startPos + 1 + category.length + 1; onDraftChange(newValue); - setCursorPosition(newCursorPos); - const textBefore2 = newValue.substring(0, newCursorPos); - const match = textBefore2.match(MENTION_PATTERN); - setShowMentionPopover(!!match); + setShowMentionPopover(!!newValue.substring(0, newCursorPos).match(MENTION_PATTERN)); }, })); - const handleChange = (e: React.ChangeEvent) => { - 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) => { - // 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) useEffect(() => { const onMentionClick = (e: Event) => { const { type, id, label } = (e as CustomEvent<{ type: string; id: string; label: string }>).detail; - if (!textareaRef.current) return; - const textarea = textareaRef.current; - const cursorPos = textarea.selectionStart; - const textBefore = textarea.value.substring(0, cursorPos); - - if (type === 'ai') { - // Insert @ai:mention at cursor position - const html = buildMentionHtml('ai', id, label); - const spacer = ' '; - const newValue = textBefore + html + spacer + textarea.value.substring(cursorPos); - const newCursorPos = cursorPos + html.length + spacer.length; - onDraftChange(newValue); - setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.value = newValue; - textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); - textareaRef.current.focus(); + const cursorPos = cursorRef.current; + const textBefore = draft.substring(0, cursorPos); + const html = buildMentionHtml(type as 'user' | 'repository' | 'ai', id, label); + const spacer = ' '; + const newValue = textBefore + html + spacer + draft.substring(cursorPos); + const newCursorPos = cursorPos + html.length + spacer.length; + onDraftChange(newValue); + setTimeout(() => { + const container = containerRef.current; + if (!container) return; + container.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + let charCount = 0; + let found = false; + function walk(node: Node) { + if (found) return; + if (node.nodeType === Node.TEXT_NODE) { + const t = node.textContent ?? ''; + if (charCount + t.length >= newCursorPos) { + range.setStart(node, Math.min(newCursorPos - charCount, t.length)); + range.collapse(true); + found = true; + return; + } + charCount += t.length; + } else if (node.nodeType === Node.ELEMENT_NODE) { + for (const c of Array.from(node.childNodes)) { + walk(c); + if (found) return; + } } - }, 0); - } + } + walk(container); + if (!found) { range.selectNodeContents(container); range.collapse(false); } + sel.removeAllRanges(); + sel.addRange(range); + }, 0); }; document.addEventListener('mention-click', onMentionClick); return () => document.removeEventListener('mention-click', onMentionClick); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( @@ -272,20 +237,34 @@ const ChatInputArea = memo(function ChatInputArea({ )}
-