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:
parent
6aca08b8ab
commit
261989fca3
@ -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);
|
||||
|
||||
@ -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}`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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 “{query}”
|
||||
</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';
|
||||
36
src/components/room/message/editor/MentionNode.tsx
Normal file
36
src/components/room/message/editor/MentionNode.tsx
Normal 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);
|
||||
},
|
||||
});
|
||||
37
src/components/room/message/editor/MentionView.tsx
Normal file
37
src/components/room/message/editor/MentionView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user