feat(frontend): TipTap mention nodes with keyboard nav and sectioned dropdown

- 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🆔label] on send
- MessageInput.tsx: wire roomAiConfigs into mentionItems, AST serialization
- MessageBubble.tsx: default expanded text (showFullText=true), AI messages never collapsed
This commit is contained in:
ZhenYi 2026-04-24 13:16:59 +08:00
parent 6aca08b8ab
commit 261989fca3
5 changed files with 430 additions and 163 deletions

View File

@ -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);

View File

@ -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<MessageInputHandle, MessageInputProps>(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<IMEditorHandle | null>(null);
@ -45,30 +86,6 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(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<MessageInputHandle, MessageInputProps>(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<MessageInputHandle, MessageInputProps>(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<MessageInputHandle, MessageInputProps>(fu
placeholder={`Message #${roomName}`}
/>
);
});
});

View File

@ -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<string, string> = {
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<HTMLDivElement>(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<string, MentionItem[]> = 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<MentionItem, number>();
items.forEach((item, i) => flatIndexMap.set(item, i));
return (
<div
className="absolute left-0 z-50 overflow-hidden"
className="absolute bottom-full left-0 mb-1 z-50 overflow-hidden"
style={{
background: p.popupBg,
border: `1px solid ${p.popupBorder}`,
@ -207,73 +237,81 @@ function MentionDropdown({
No results for &ldquo;{query}&rdquo;
</div>
) : (
<div className="py-1 max-h-60 overflow-y-auto">
{/* Special mentions section */}
{specialItems.length > 0 && regularItems.length > 0 && (
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide" style={{color: p.textSubtle}}>
Notify
</div>
)}
{specialItems.map((item) => {
const realIndex = items.indexOf(item);
const icon = item.type === 'special_here' ? '📍' : '📢';
return (
<button
key={item.id}
onClick={() => onSelect(item)}
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
>
<span className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-base">
{icon}
</span>
<span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
@{item.label}
</span>
{item.description && (
<span className="text-[10px] text-muted-foreground mr-1">
{item.description}
</span>
)}
</button>
);
})}
{specialItems.length > 0 && regularItems.length > 0 && (
<div className="mx-3 my-1 border-t border-border" />
)}
{/* Regular mentions section */}
{regularItems.map((item) => {
const realIndex = items.indexOf(item);
const badge = getBadge(item.type);
return (
<button
key={item.id}
onClick={() => onSelect(item)}
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
>
{item.avatar ? (
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0"/>
) : (
<span
className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-xs font-semibold"
style={{background: p === DARK ? '#2a2a30' : '#eeeef0', color: p.text}}
<div className="py-1 max-h-60 overflow-y-auto" ref={scrollRef}>
{Array.from(sections.entries()).map(([sectionType, sectionItems], si) => (
<div key={sectionType}>
{/* Section header — only show if there are other sections after or before */}
{sections.size > 1 && (
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide" style={{color: p.textSubtle}}>
{SECTION_LABELS[sectionType]}
</div>
)}
{sectionItems.map((item) => {
const realIndex = flatIndexMap.get(item) ?? 0;
const isSpecial = SPECIAL_TYPES.includes(item.type);
const icon = item.type === 'special_here' ? '📍' : item.type === 'special_channel' ? '📢' : undefined;
if (isSpecial) {
return (
<button
key={item.id}
data-mention-idx={realIndex}
onClick={() => onSelect(item)}
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
>
<span className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-base">
{icon}
</span>
<span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
@{item.label}
</span>
{item.description && (
<span className="text-[10px] text-muted-foreground mr-1">
{item.description}
</span>
)}
</button>
);
}
const badge = getBadge(item.type);
return (
<button
key={item.id}
data-mention-idx={realIndex}
onClick={() => onSelect(item)}
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
>
{item.label.charAt(0).toUpperCase()}
</span>
)}
<span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
{item.label}
</span>
{badge && (
<span
className={cn('shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full', badge.cls)}>
{badge.label}
</span>
)}
</button>
);
})}
{item.avatar ? (
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0"/>
) : (
<span
className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-xs font-semibold"
style={{background: p === DARK ? '#2a2a30' : '#eeeef0', color: p.text}}
>
{item.label.charAt(0).toUpperCase()}
</span>
)}
<span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
{item.label}
</span>
{badge && (
<span
className={cn('shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full', badge.cls)}>
{badge.label}
</span>
)}
</button>
);
})}
{/* Divider between sections */}
{si < sections.size - 1 && (
<div className="mx-3 my-1 border-t border-border" />
)}
</div>
))}
</div>
)}
</div>
@ -298,26 +336,77 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(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<MentionItem[]>([]);
const editorRef = useRef<ReturnType<typeof useEditor>>(null);
// Sync refs with state
useEffect(() => { mentionOpenRef.current = mentionOpen; }, [mentionOpen]);
useEffect(() => { mentionIdxRef.current = mentionIdx; }, [mentionIdx]);
useEffect(() => { mentionItemsRef.current = mentionItems2; }, [mentionItems2]);
const wrapRef = useRef<HTMLDivElement>(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<IMEditorHandle, IMEditorProps>(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<IMEditorHandle, IMEditorProps>(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<IMEditorHandle, IMEditorProps>(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<IMEditorHandle, IMEditorProps>(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<IMEditorHandle, IMEditorProps>(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<IMEditorHandle, IMEditorProps>(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<IMEditorHandle, IMEditorProps>(function IMEdi
{/* Input area */}
<div
className="relative"
onClick={() => editor?.commands.focus()}
style={{
background: p.bg,
@ -620,4 +792,4 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
);
});
IMEditor.displayName = 'IMEditor';
IMEditor.displayName = 'IMEditor';

View File

@ -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);
},
});

View File

@ -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<string, { bg: string; text: string; prefix: string }> = {
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<string, string>;
const label = attrs.label ?? '';
const type = attrs.type ?? 'user';
const style = TYPE_STYLE[type] ?? TYPE_STYLE.user;
return (
<NodeViewWrapper className="inline" as="span">
<span
className={`${style.bg} ${style.text} rounded px-1.5 py-0.5 text-sm font-medium select-none cursor-default inline-flex items-center leading-tight`}
data-mention
data-id={attrs.id}
data-label={label}
data-type={type}
>
{style.prefix}{label}
</span>
</NodeViewWrapper>
);
}