From 261989fca3681d3a1b3e3b0237ed17a665e4fd31 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Fri, 24 Apr 2026 13:16:59 +0800 Subject: [PATCH] feat(frontend): TipTap mention nodes with keyboard nav and sectioned dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MentionNode.tsx: custom TipTap atom node for @/#//mentions - MentionView.tsx: colored inline labels by type (user=blue, ai=indigo, special=orange) - IMEditor.tsx: register MentionNode, ↑↓/Enter/Tab/Esc keyboard nav, sectioned dropdown (@ groups Notify/AI/Members, # channels, / commands), serialize AST to @[type:id:label] on send - MessageInput.tsx: wire roomAiConfigs into mentionItems, AST serialization - MessageBubble.tsx: default expanded text (showFullText=true), AI messages never collapsed --- src/components/room/message/MessageBubble.tsx | 2 +- src/components/room/message/MessageInput.tsx | 86 ++-- .../room/message/editor/IMEditor.tsx | 432 ++++++++++++------ .../room/message/editor/MentionNode.tsx | 36 ++ .../room/message/editor/MentionView.tsx | 37 ++ 5 files changed, 430 insertions(+), 163 deletions(-) create mode 100644 src/components/room/message/editor/MentionNode.tsx create mode 100644 src/components/room/message/editor/MentionView.tsx diff --git a/src/components/room/message/MessageBubble.tsx b/src/components/room/message/MessageBubble.tsx index 9eb7237..08b4c4d 100644 --- a/src/components/room/message/MessageBubble.tsx +++ b/src/components/room/message/MessageBubble.tsx @@ -69,7 +69,7 @@ export const MessageBubble = memo(function MessageBubble({ onOpenUserCard, onOpenThread, }: MessageBubbleProps) { - const [showFullText, setShowFullText] = useState(false); + const [showFullText, setShowFullText] = useState(true); // default expanded const [isEditing, setIsEditing] = useState(false); const [editContent, setEditContent] = useState(message.content); const [isSavingEdit, setIsSavingEdit] = useState(false); diff --git a/src/components/room/message/MessageInput.tsx b/src/components/room/message/MessageInput.tsx index dca8727..f58d56d 100644 --- a/src/components/room/message/MessageInput.tsx +++ b/src/components/room/message/MessageInput.tsx @@ -8,7 +8,7 @@ import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'; import { IMEditor } from './editor/IMEditor'; import { useRoom } from '@/contexts'; -import type { MessageAST } from './editor/types'; +import type { MessageAST, EditorNode } from './editor/types'; import type { IMEditorHandle } from './editor/IMEditor'; export interface MessageInputProps { @@ -26,11 +26,52 @@ export interface MessageInputHandle { getAttachmentIds: () => string[]; } +// Slash commands available in the editor +const SLASH_COMMANDS = [ + { id: 'ai', label: '/ai', description: 'Ask AI a question', type: 'command' as const }, + { id: 'remind', label: '/remind', description: 'Set a reminder (e.g. /remind 10m Check CI)', type: 'command' as const }, + { id: 'poll', label: '/poll', description: 'Create a poll (e.g. /poll "Question?" A B C)', type: 'command' as const }, + { id: 'code-review', label: '/code-review', description: 'Request AI code review', type: 'command' as const }, +]; + +// Special mention items — @here (online), @channel (all members) +const SPECIAL_MENTIONS = [ + { + id: '__here__', + label: 'here', + description: 'Notify online members', + type: 'special_here' as const, + }, + { + id: '__channel__', + label: 'channel', + description: 'Notify all members', + type: 'special_channel' as const, + }, +]; + +/** Serialize tiptap AST to backend-parseable string format. */ +function serializeMessageAst(ast: MessageAST): string { + return ast.content.map(serializeNode).join('\n'); +} + +function serializeNode(node: EditorNode): string { + if (node.type === 'text') return node.text; + if (node.type === 'mention') return `@[${node.attrs.type}:${node.attrs.id}:${node.attrs.label}]`; + if (node.type === 'hardBreak') return '\n'; + if (node.type === 'file') return ''; // files are sent separately via attachmentIds + if (node.type === 'emoji') return `[emoji:${node.attrs.name}]`; + // Recurse into container nodes (paragraph, bulletList, etc.) + const children = (node as any).content as EditorNode[] | undefined; + if (children) return children.map(serializeNode).join(''); + return ''; +} + export const MessageInput = forwardRef(function MessageInput( { roomName, onSend, replyingTo, onCancelReply }, ref, ) { - const { members, activeRoomId } = useRoom(); + const { members, activeRoomId, roomAiConfigs } = useRoom(); // Ref passed to the inner IMEditor const innerEditorRef = useRef(null); @@ -45,30 +86,6 @@ export const MessageInput = forwardRef(fu getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [], }), []); - // Slash commands available in the editor - const SLASH_COMMANDS = [ - { id: 'ai', label: '/ai', description: 'Ask AI a question', type: 'command' as const }, - { id: 'remind', label: '/remind', description: 'Set a reminder (e.g. /remind 10m Check CI)', type: 'command' as const }, - { id: 'poll', label: '/poll', description: 'Create a poll (e.g. /poll "Question?" A B C)', type: 'command' as const }, - { id: 'code-review', label: '/code-review', description: 'Request AI code review', type: 'command' as const }, - ]; - - // Special mention items — @here (online), @channel (all members) - const SPECIAL_MENTIONS = [ - { - id: '__here__', - label: 'here', - description: 'Notify online members', - type: 'special_here' as const, - }, - { - id: '__channel__', - label: 'channel', - description: 'Notify all members', - type: 'special_channel' as const, - }, - ]; - // Transform room data into MentionItems — memoized to prevent IMEditor re-creation const mentionItems = useMemo(() => ({ users: members.map((m) => ({ @@ -78,10 +95,14 @@ export const MessageInput = forwardRef(fu avatar: m.user_info?.avatar_url ?? undefined, })), channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[], - ai: [] as { id: string; label: string; type: 'ai'; avatar?: string }[], + ai: roomAiConfigs.map((cfg) => ({ + id: cfg.model, + label: cfg.modelName ?? cfg.model, + type: 'ai' as const, + })), commands: SLASH_COMMANDS, specialMentions: SPECIAL_MENTIONS, - }), [members]); + }), [members, roomAiConfigs]); // File upload handler — POST to /rooms/{room_id}/upload const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => { @@ -94,9 +115,10 @@ export const MessageInput = forwardRef(fu return res.json(); }; - // onSend: extract plain text from MessageAST for sending - const handleSend = (text: string, _ast: MessageAST) => { - onSend(text); + // onSend: serialize AST to backend-parseable format + const handleSend = (_text: string, ast: MessageAST) => { + const serialized = serializeMessageAst(ast); + onSend(serialized); }; return ( @@ -110,4 +132,4 @@ export const MessageInput = forwardRef(fu placeholder={`Message #${roomName}`} /> ); -}); +}); \ No newline at end of file diff --git a/src/components/room/message/editor/IMEditor.tsx b/src/components/room/message/editor/IMEditor.tsx index f742e0b..12900ea 100644 --- a/src/components/room/message/editor/IMEditor.tsx +++ b/src/components/room/message/editor/IMEditor.tsx @@ -5,12 +5,13 @@ * Colors: Clean modern palette, no Discord reference */ -import {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {EditorContent, Extension, useEditor} from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Placeholder from '@tiptap/extension-placeholder'; import {CustomEmojiNode} from './EmojiNode'; -import type {MentionItem, MentionType, MessageAST} from './types'; +import {MentionNodeType} from './MentionNode'; +import type {EditorNode, MentionItem, MentionType, MessageAST} from './types'; import {Paperclip, Send, Smile, X} from 'lucide-react'; import {cn} from '@/lib/utils'; import {COMMON_EMOJIS} from '../../shared'; @@ -138,30 +139,6 @@ function EmojiPicker({onClose, onSelect, p}: { onClose: () => void; onSelect: (e ); } -// ─── Keyboard Extension ─────────────────────────────────────────────────────── - -const KeyboardSend = Extension.create({ - name: 'keyboardSend', - addKeyboardShortcuts() { - return { - Enter: ({editor}) => { - if (editor.isEmpty) return true; - const text = editor.getText().trim(); - if (!text) return true; - (editor.storage as any).keyboardSend?.onSend?.(text, editor.getJSON() as MessageAST); - return true; - }, - 'Shift-Enter': ({editor}) => { - editor.chain().focus().setHardBreak().run(); - return true; - }, - }; - }, - addStorage() { - return {onSend: null as ((t: string, a: MessageAST) => void) | null}; - }, -}); - // ─── Helpers ───────────────────────────────────────────────────────────────── function filterMentionItems(all: MentionItem[], q: string): MentionItem[] { @@ -175,24 +152,77 @@ function getBadge(type: MentionType): { label: string; cls: string } | null { return null; } -// ─── Mention Dropdown ──────────────────────────────────────────────────────── +/** Serialize tiptap AST to backend-parseable string. */ +function serializeAstForSend(ast: MessageAST): string { + return ast.content.map(serializeAstNode).join('\n'); +} + +function serializeAstNode(node: EditorNode): string { + if (node.type === 'text') return node.text; + if (node.type === 'mention') return `@[${node.attrs.type}:${node.attrs.id}:${node.attrs.label}]`; + if (node.type === 'hardBreak') return '\n'; + if (node.type === 'emoji') return `[emoji:${node.attrs.name}]`; + if (node.type === 'file') return ''; + // Recurse into container nodes (paragraph, bulletList, etc.) + const children = (node as any).content as EditorNode[] | undefined; + if (children) return children.map(serializeAstNode).join(''); + return ''; +} + +// ─── Mention Dropdown (sectioned by type) ──────────────────────────────────── + +const SECTION_ORDER = ['special_here', 'special_channel', 'ai', 'user', 'channel', 'command'] as const; +const SECTION_LABELS: Record = { + special_here: 'Notify', + special_channel: 'Notify', + ai: 'AI', + user: 'Members', + channel: 'Channels', + command: 'Commands', +}; +const SPECIAL_TYPES = ['special_here', 'special_channel']; function MentionDropdown({ - items, selectedIndex, onSelect, p, query, - }: { + items, selectedIndex, onSelect, p, query, +}: { items: MentionItem[]; selectedIndex: number; onSelect: (item: MentionItem) => void; p: Palette; query: string; }) { - const SPECIAL_TYPES = ['special_here', 'special_channel']; - const specialItems = items.filter((item) => SPECIAL_TYPES.includes(item.type)); - const regularItems = items.filter((item) => !SPECIAL_TYPES.includes(item.type)); + const scrollRef = useRef(null); + + // Auto-scroll selected item into view + useEffect(() => { + if (!scrollRef.current) return; + const selectedEl = scrollRef.current.querySelector(`[data-mention-idx="${selectedIndex}"]`); + if (selectedEl) { + selectedEl.scrollIntoView({ block: 'nearest' }); + } + }, [selectedIndex]); + // Group items by section + const sections: Map = new Map(); + for (const sectionType of SECTION_ORDER) { + const sectionItems = items.filter( + (item) => sectionType === 'special_here' || sectionType === 'special_channel' + ? item.type === sectionType + : SPECIAL_TYPES.includes(sectionType) + ? false + : item.type === sectionType, + ); + if (sectionItems.length > 0) { + sections.set(sectionType, sectionItems); + } + } + + // Build flat index map: item → its position in the overall items array + const flatIndexMap = new Map(); + items.forEach((item, i) => flatIndexMap.set(item, i)); return (
) : ( -
- {/* Special mentions section */} - {specialItems.length > 0 && regularItems.length > 0 && ( -
- Notify -
- )} - {specialItems.map((item) => { - const realIndex = items.indexOf(item); - const icon = item.type === 'special_here' ? '📍' : '📢'; - return ( - - ); - })} - {specialItems.length > 0 && regularItems.length > 0 && ( -
- )} - {/* Regular mentions section */} - {regularItems.map((item) => { - const realIndex = items.indexOf(item); - const badge = getBadge(item.type); - return ( - + ); + } + + const badge = getBadge(item.type); + return ( + - ); - })} + {item.avatar ? ( + {item.label} + ) : ( + + {item.label.charAt(0).toUpperCase()} + + )} + + {item.label} + + {badge && ( + + {badge.label} + + )} + + ); + })} + {/* Divider between sections */} + {si < sections.size - 1 && ( +
+ )} +
+ ))}
)}
@@ -298,26 +336,77 @@ export const IMEditor = forwardRef(function IMEdi const [, setMentionPos] = useState({top: 0, left: 0}); const [focused, setFocused] = useState(false); + // Refs for keyboard shortcut closures (tiptap can't read React state directly) + const mentionOpenRef = useRef(false); + const mentionIdxRef = useRef(0); + const mentionItemsRef = useRef([]); + const editorRef = useRef>(null); + + // Sync refs with state + useEffect(() => { mentionOpenRef.current = mentionOpen; }, [mentionOpen]); + useEffect(() => { mentionIdxRef.current = mentionIdx; }, [mentionIdx]); + useEffect(() => { mentionItemsRef.current = mentionItems2; }, [mentionItems2]); + const wrapRef = useRef(null); - const allItems = [ + // Candidate pools by trigger character + const atPool = useMemo(() => [ ...(mentionItems.specialMentions ?? []), - ...mentionItems.users, - ...mentionItems.channels, ...mentionItems.ai, - ...mentionItems.commands, - ]; + ...mentionItems.users, + ], [mentionItems.specialMentions, mentionItems.ai, mentionItems.users]); + + const hashPool = useMemo(() => [...mentionItems.channels], [mentionItems.channels]); + + const slashPool = useMemo(() => [...mentionItems.commands], [mentionItems.commands]); const selectMention = useCallback((item: MentionItem) => { + const editor = editorRef.current; if (!editor) return; - if (item.type === 'command') { - // Replace the / prefix with the full command label - editor.chain().focus().insertContent(item.label + ' ').run(); - } else { - // Use backend-parseable format: @[type:id:label] - const mentionStr = `@[${item.type}:${item.id}:${item.label}] `; - editor.chain().focus().insertContent(mentionStr).run(); + + // Delete the trigger + query text first, then insert the mention node + const text = editor.getText(); + const {from} = editor.state.selection; + let triggerStart = from; + for (let i = from - 1; i >= 1; i--) { + const c = text[i - 1]; + if (c === '@' || c === '#' || c === '/') { + triggerStart = i; + break; + } + if (/\s/.test(c)) break; } + + // Delete from triggerStart-1 to from (the @query / #query / /query text) + if (triggerStart < from) { + editor.chain().focus().deleteRange({from: triggerStart - 1, to: from}).run(); + } + + // Insert mention node + editor.chain().focus().insertContent({ + type: 'mention', + attrs: { id: item.id, label: item.label, type: item.type }, + }).insertContent(' ').run(); + + setMentionOpen(false); + }, []); + + const moveMentionIdx = useCallback((delta: number) => { + const len = mentionItemsRef.current.length; + if (len === 0) return; + const next = (mentionIdxRef.current + delta + len) % len; + setMentionIdx(next); + }, []); + + const selectCurrentMention = useCallback(() => { + const items = mentionItemsRef.current; + const idx = mentionIdxRef.current; + if (items[idx]) { + selectMention(items[idx]); + } + }, [selectMention]); + + const closeMention = useCallback(() => { setMentionOpen(false); }, []); @@ -326,7 +415,62 @@ export const IMEditor = forwardRef(function IMEdi StarterKit.configure({undoRedo: {depth: 100}}), Placeholder.configure({placeholder}), CustomEmojiNode, - KeyboardSend, + MentionNodeType, + Extension.create({ + name: 'mentionKeyboard', + addKeyboardShortcuts() { + return { + Enter: () => { + if (mentionOpenRef.current) { + selectCurrentMention(); + return true; + } + const ed = editorRef.current; + if (!ed) return true; + const ast = ed.getJSON() as MessageAST; + const serialized = serializeAstForSend(ast); + if (!serialized.trim()) return true; + (ed.storage as any).mentionKeyboard?.onSend?.(serialized, ast); + return true; + }, + 'Shift-Enter': ({editor: ed}) => { + ed.chain().focus().setHardBreak().run(); + return true; + }, + ArrowUp: () => { + if (mentionOpenRef.current) { + moveMentionIdx(-1); + return true; + } + return false; + }, + ArrowDown: () => { + if (mentionOpenRef.current) { + moveMentionIdx(1); + return true; + } + return false; + }, + Escape: () => { + if (mentionOpenRef.current) { + closeMention(); + return true; + } + return false; + }, + Tab: () => { + if (mentionOpenRef.current) { + selectCurrentMention(); + return true; + } + return false; + }, + }; + }, + addStorage() { + return {onSend: null as ((t: string, a: MessageAST) => void) | null}; + }, + }), ], editorProps: { handlePaste: (_v, e) => { @@ -352,23 +496,27 @@ export const IMEditor = forwardRef(function IMEdi const text = ed.getText(); const {from} = ed.state.selection; + // Backward scan from cursor to find trigger character let ts = from; + let trigger: string | null = null; for (let i = from - 1; i >= 1; i--) { const c = text[i - 1]; - if (c === '@' || c === '/') { + if (c === '@' || c === '#' || c === '/') { ts = i; + trigger = c; break; } if (/\s/.test(c)) break; } + const q = text.slice(ts - 1, from); - if (q.startsWith('@') && q.length > 1) { - const results = filterMentionItems(allItems, q.slice(1)); + if (trigger === '@' && q.length >= 1) { + const results = filterMentionItems(atPool, q.slice(1)); setMentionQuery(q.slice(1)); setMentionItems2(results); setMentionIdx(0); - setMentionOpen(true); + setMentionOpen(results.length > 0); if (wrapRef.current) { const sel = window.getSelection(); @@ -378,13 +526,27 @@ export const IMEditor = forwardRef(function IMEdi setMentionPos({top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left)}); } } - } else if (q.startsWith('/') && q.length > 1) { - // Filter commands by query (e.g. "/ai" matches "ai") - const results = filterMentionItems(mentionItems.commands, q.slice(1)); + } else if (trigger === '#' && q.length >= 1) { + const results = filterMentionItems(hashPool, q.slice(1)); setMentionQuery(q.slice(1)); setMentionItems2(results); setMentionIdx(0); - setMentionOpen(true); + setMentionOpen(results.length > 0); + + if (wrapRef.current) { + const sel = window.getSelection(); + if (sel?.rangeCount) { + const r = sel.getRangeAt(0).getBoundingClientRect(); + const cr = wrapRef.current.getBoundingClientRect(); + setMentionPos({top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left)}); + } + } + } else if (trigger === '/' && q.length >= 1) { + const results = filterMentionItems(slashPool, q.slice(1)); + setMentionQuery(q.slice(1)); + setMentionItems2(results); + setMentionIdx(0); + setMentionOpen(results.length > 0); if (wrapRef.current) { const sel = window.getSelection(); @@ -402,8 +564,13 @@ export const IMEditor = forwardRef(function IMEdi onBlur: () => setFocused(false), }); + // Store editor ref useEffect(() => { - if (editor) (editor.storage as any).keyboardSend = {onSend}; + editorRef.current = editor; + }, [editor]); + + useEffect(() => { + if (editor) (editor.storage as any).mentionKeyboard = {onSend}; }, [editor, onSend]); const doUpload = async (file: File) => { @@ -433,21 +600,26 @@ export const IMEditor = forwardRef(function IMEdi }; const send = () => { - if (!editor || editor.isEmpty) return; - const text = editor.getText().trim(); - if (!text) return; - onSend(text, editor.getJSON() as MessageAST); + if (!editor) return; + const ast = editor.getJSON() as MessageAST; + const serialized = serializeAstForSend(ast); + if (!serialized.trim()) return; + onSend(serialized, ast); editor.commands.clearContent(); }; + const hasContent = !!editor && editor.state.doc.content.size > 2; + useImperativeHandle(ref, () => ({ focus: () => editor?.commands.focus(), clearContent: () => editor?.commands.clearContent(), getContent: () => editor?.getText() ?? '', insertMention: (type: string, id: string, label: string) => { if (!editor) return; - const mentionStr = `@[${type}:${id}:${label}] `; - editor.chain().focus().insertContent(mentionStr).run(); + editor.chain().focus().insertContent({ + type: 'mention', + attrs: { id, label, type }, + }).insertContent(' ').run(); }, getAttachmentIds: () => { if (!editor) return []; @@ -466,8 +638,7 @@ export const IMEditor = forwardRef(function IMEdi }, })); - const hasContent = !!editor && !editor.isEmpty; - + // Dynamic styles const borderColor = focused ? p.borderFocus : p.border; const boxShadow = focused @@ -505,6 +676,7 @@ export const IMEditor = forwardRef(function IMEdi {/* Input area */}
editor?.commands.focus()} style={{ background: p.bg, @@ -620,4 +792,4 @@ export const IMEditor = forwardRef(function IMEdi ); }); -IMEditor.displayName = 'IMEditor'; +IMEditor.displayName = 'IMEditor'; \ No newline at end of file diff --git a/src/components/room/message/editor/MentionNode.tsx b/src/components/room/message/editor/MentionNode.tsx new file mode 100644 index 0000000..a5160a3 --- /dev/null +++ b/src/components/room/message/editor/MentionNode.tsx @@ -0,0 +1,36 @@ +/** + * TipTap Mention Node — inline atom for @mentions, #channels, /commands. + * Renders via MentionView React NodeView with type-specific coloring. + */ + +import { Node, mergeAttributes } from '@tiptap/core'; +import { ReactNodeViewRenderer } from '@tiptap/react'; +import MentionView from './MentionView'; + +export const MentionNodeType = Node.create({ + name: 'mention', + group: 'inline', + inline: true, + selectable: true, + atom: true, + + addAttributes() { + return { + id: { default: null }, + label: { default: null }, + type: { default: 'user' }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-mention]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['span', mergeAttributes({ 'data-mention': '' }, HTMLAttributes)]; + }, + + addNodeView() { + return ReactNodeViewRenderer(MentionView); + }, +}); \ No newline at end of file diff --git a/src/components/room/message/editor/MentionView.tsx b/src/components/room/message/editor/MentionView.tsx new file mode 100644 index 0000000..b1649cc --- /dev/null +++ b/src/components/room/message/editor/MentionView.tsx @@ -0,0 +1,37 @@ +/** + * React NodeView for TipTap mention nodes. + * Renders colored inline labels by mention type. + */ + +import type { ReactNodeViewProps } from '@tiptap/react'; +import { NodeViewWrapper } from '@tiptap/react'; + +const TYPE_STYLE: Record = { + user: { bg: 'bg-blue-100 dark:bg-blue-900/40', text: 'text-blue-700 dark:text-blue-300', prefix: '@' }, + ai: { bg: 'bg-indigo-100 dark:bg-indigo-900/40', text: 'text-indigo-700 dark:text-indigo-300', prefix: '@' }, + channel: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-600 dark:text-gray-400', prefix: '#' }, + special_here: { bg: 'bg-orange-100 dark:bg-orange-900/40', text: 'text-orange-700 dark:text-orange-300', prefix: '@' }, + special_channel: { bg: 'bg-orange-100 dark:bg-orange-900/40', text: 'text-orange-700 dark:text-orange-300', prefix: '@' }, + command: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', prefix: '/' }, +}; + +export default function MentionView(props: ReactNodeViewProps) { + const attrs = props.node.attrs as Record; + const label = attrs.label ?? ''; + const type = attrs.type ?? 'user'; + const style = TYPE_STYLE[type] ?? TYPE_STYLE.user; + + return ( + + + {style.prefix}{label} + + + ); +} \ No newline at end of file