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,
|
onOpenUserCard,
|
||||||
onOpenThread,
|
onOpenThread,
|
||||||
}: MessageBubbleProps) {
|
}: MessageBubbleProps) {
|
||||||
const [showFullText, setShowFullText] = useState(false);
|
const [showFullText, setShowFullText] = useState(true); // default expanded
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editContent, setEditContent] = useState(message.content);
|
const [editContent, setEditContent] = useState(message.content);
|
||||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
|
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
|
||||||
import { IMEditor } from './editor/IMEditor';
|
import { IMEditor } from './editor/IMEditor';
|
||||||
import { useRoom } from '@/contexts';
|
import { useRoom } from '@/contexts';
|
||||||
import type { MessageAST } from './editor/types';
|
import type { MessageAST, EditorNode } from './editor/types';
|
||||||
import type { IMEditorHandle } from './editor/IMEditor';
|
import type { IMEditorHandle } from './editor/IMEditor';
|
||||||
|
|
||||||
export interface MessageInputProps {
|
export interface MessageInputProps {
|
||||||
@ -26,11 +26,52 @@ export interface MessageInputHandle {
|
|||||||
getAttachmentIds: () => string[];
|
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(
|
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
|
||||||
{ roomName, onSend, replyingTo, onCancelReply },
|
{ roomName, onSend, replyingTo, onCancelReply },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const { members, activeRoomId } = useRoom();
|
const { members, activeRoomId, roomAiConfigs } = useRoom();
|
||||||
|
|
||||||
// Ref passed to the inner IMEditor
|
// Ref passed to the inner IMEditor
|
||||||
const innerEditorRef = useRef<IMEditorHandle | null>(null);
|
const innerEditorRef = useRef<IMEditorHandle | null>(null);
|
||||||
@ -45,30 +86,6 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
getAttachmentIds: () => innerEditorRef.current?.getAttachmentIds() ?? [],
|
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
|
// Transform room data into MentionItems — memoized to prevent IMEditor re-creation
|
||||||
const mentionItems = useMemo(() => ({
|
const mentionItems = useMemo(() => ({
|
||||||
users: members.map((m) => ({
|
users: members.map((m) => ({
|
||||||
@ -78,10 +95,14 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
avatar: m.user_info?.avatar_url ?? undefined,
|
avatar: m.user_info?.avatar_url ?? undefined,
|
||||||
})),
|
})),
|
||||||
channels: [] as { id: string; label: string; type: 'channel'; avatar?: string }[],
|
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,
|
commands: SLASH_COMMANDS,
|
||||||
specialMentions: SPECIAL_MENTIONS,
|
specialMentions: SPECIAL_MENTIONS,
|
||||||
}), [members]);
|
}), [members, roomAiConfigs]);
|
||||||
|
|
||||||
// File upload handler — POST to /rooms/{room_id}/upload
|
// File upload handler — POST to /rooms/{room_id}/upload
|
||||||
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
|
const handleUploadFile = async (file: File): Promise<{ id: string; url: string }> => {
|
||||||
@ -94,9 +115,10 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
// onSend: extract plain text from MessageAST for sending
|
// onSend: serialize AST to backend-parseable format
|
||||||
const handleSend = (text: string, _ast: MessageAST) => {
|
const handleSend = (_text: string, ast: MessageAST) => {
|
||||||
onSend(text);
|
const serialized = serializeMessageAst(ast);
|
||||||
|
onSend(serialized);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -110,4 +132,4 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
|||||||
placeholder={`Message #${roomName}`}
|
placeholder={`Message #${roomName}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -5,12 +5,13 @@
|
|||||||
* Colors: Clean modern palette, no Discord reference
|
* 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 {EditorContent, Extension, useEditor} from '@tiptap/react';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
import {CustomEmojiNode} from './EmojiNode';
|
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 {Paperclip, Send, Smile, X} from 'lucide-react';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import {COMMON_EMOJIS} from '../../shared';
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function filterMentionItems(all: MentionItem[], q: string): MentionItem[] {
|
function filterMentionItems(all: MentionItem[], q: string): MentionItem[] {
|
||||||
@ -175,24 +152,77 @@ function getBadge(type: MentionType): { label: string; cls: string } | null {
|
|||||||
return 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({
|
function MentionDropdown({
|
||||||
items, selectedIndex, onSelect, p, query,
|
items, selectedIndex, onSelect, p, query,
|
||||||
}: {
|
}: {
|
||||||
items: MentionItem[];
|
items: MentionItem[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
onSelect: (item: MentionItem) => void;
|
onSelect: (item: MentionItem) => void;
|
||||||
p: Palette;
|
p: Palette;
|
||||||
query: string;
|
query: string;
|
||||||
}) {
|
}) {
|
||||||
const SPECIAL_TYPES = ['special_here', 'special_channel'];
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const specialItems = items.filter((item) => SPECIAL_TYPES.includes(item.type));
|
|
||||||
const regularItems = items.filter((item) => !SPECIAL_TYPES.includes(item.type));
|
// 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 z-50 overflow-hidden"
|
className="absolute bottom-full left-0 mb-1 z-50 overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
background: p.popupBg,
|
background: p.popupBg,
|
||||||
border: `1px solid ${p.popupBorder}`,
|
border: `1px solid ${p.popupBorder}`,
|
||||||
@ -207,73 +237,81 @@ function MentionDropdown({
|
|||||||
No results for “{query}”
|
No results for “{query}”
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1 max-h-60 overflow-y-auto">
|
<div className="py-1 max-h-60 overflow-y-auto" ref={scrollRef}>
|
||||||
{/* Special mentions section */}
|
{Array.from(sections.entries()).map(([sectionType, sectionItems], si) => (
|
||||||
{specialItems.length > 0 && regularItems.length > 0 && (
|
<div key={sectionType}>
|
||||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide" style={{color: p.textSubtle}}>
|
{/* Section header — only show if there are other sections after or before */}
|
||||||
Notify
|
{sections.size > 1 && (
|
||||||
</div>
|
<div className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide" style={{color: p.textSubtle}}>
|
||||||
)}
|
{SECTION_LABELS[sectionType]}
|
||||||
{specialItems.map((item) => {
|
</div>
|
||||||
const realIndex = items.indexOf(item);
|
)}
|
||||||
const icon = item.type === 'special_here' ? '📍' : '📢';
|
{sectionItems.map((item) => {
|
||||||
return (
|
const realIndex = flatIndexMap.get(item) ?? 0;
|
||||||
<button
|
const isSpecial = SPECIAL_TYPES.includes(item.type);
|
||||||
key={item.id}
|
const icon = item.type === 'special_here' ? '📍' : item.type === 'special_channel' ? '📢' : undefined;
|
||||||
onClick={() => onSelect(item)}
|
|
||||||
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
|
if (isSpecial) {
|
||||||
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
|
return (
|
||||||
>
|
<button
|
||||||
<span className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-base">
|
key={item.id}
|
||||||
{icon}
|
data-mention-idx={realIndex}
|
||||||
</span>
|
onClick={() => onSelect(item)}
|
||||||
<span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
|
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
|
||||||
@{item.label}
|
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
|
||||||
</span>
|
>
|
||||||
{item.description && (
|
<span className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-base">
|
||||||
<span className="text-[10px] text-muted-foreground mr-1">
|
{icon}
|
||||||
{item.description}
|
</span>
|
||||||
</span>
|
<span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
|
||||||
)}
|
@{item.label}
|
||||||
</button>
|
</span>
|
||||||
);
|
{item.description && (
|
||||||
})}
|
<span className="text-[10px] text-muted-foreground mr-1">
|
||||||
{specialItems.length > 0 && regularItems.length > 0 && (
|
{item.description}
|
||||||
<div className="mx-3 my-1 border-t border-border" />
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Regular mentions section */}
|
</button>
|
||||||
{regularItems.map((item) => {
|
);
|
||||||
const realIndex = items.indexOf(item);
|
}
|
||||||
const badge = getBadge(item.type);
|
|
||||||
return (
|
const badge = getBadge(item.type);
|
||||||
<button
|
return (
|
||||||
key={item.id}
|
<button
|
||||||
onClick={() => onSelect(item)}
|
key={item.id}
|
||||||
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
|
data-mention-idx={realIndex}
|
||||||
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
|
onClick={() => onSelect(item)}
|
||||||
>
|
className="w-full flex items-center gap-3 px-3 py-2.5 transition-colors text-left cursor-pointer"
|
||||||
{item.avatar ? (
|
style={{background: realIndex === selectedIndex ? p.popupSelected : 'transparent'}}
|
||||||
<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()}
|
{item.avatar ? (
|
||||||
</span>
|
<img src={item.avatar} alt={item.label} className="w-7 h-7 rounded-full shrink-0"/>
|
||||||
)}
|
) : (
|
||||||
<span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
|
<span
|
||||||
{item.label}
|
className="w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-xs font-semibold"
|
||||||
</span>
|
style={{background: p === DARK ? '#2a2a30' : '#eeeef0', color: p.text}}
|
||||||
{badge && (
|
>
|
||||||
<span
|
{item.label.charAt(0).toUpperCase()}
|
||||||
className={cn('shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full', badge.cls)}>
|
</span>
|
||||||
{badge.label}
|
)}
|
||||||
</span>
|
<span className="flex-1 truncate text-sm font-medium" style={{color: p.text}}>
|
||||||
)}
|
{item.label}
|
||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -298,26 +336,77 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
const [, setMentionPos] = useState({top: 0, left: 0});
|
const [, setMentionPos] = useState({top: 0, left: 0});
|
||||||
const [focused, setFocused] = useState(false);
|
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 wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const allItems = [
|
// Candidate pools by trigger character
|
||||||
|
const atPool = useMemo(() => [
|
||||||
...(mentionItems.specialMentions ?? []),
|
...(mentionItems.specialMentions ?? []),
|
||||||
...mentionItems.users,
|
|
||||||
...mentionItems.channels,
|
|
||||||
...mentionItems.ai,
|
...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 selectMention = useCallback((item: MentionItem) => {
|
||||||
|
const editor = editorRef.current;
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
if (item.type === 'command') {
|
|
||||||
// Replace the / prefix with the full command label
|
// Delete the trigger + query text first, then insert the mention node
|
||||||
editor.chain().focus().insertContent(item.label + ' ').run();
|
const text = editor.getText();
|
||||||
} else {
|
const {from} = editor.state.selection;
|
||||||
// Use backend-parseable format: @[type:id:label]
|
let triggerStart = from;
|
||||||
const mentionStr = `@[${item.type}:${item.id}:${item.label}] `;
|
for (let i = from - 1; i >= 1; i--) {
|
||||||
editor.chain().focus().insertContent(mentionStr).run();
|
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);
|
setMentionOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -326,7 +415,62 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
StarterKit.configure({undoRedo: {depth: 100}}),
|
StarterKit.configure({undoRedo: {depth: 100}}),
|
||||||
Placeholder.configure({placeholder}),
|
Placeholder.configure({placeholder}),
|
||||||
CustomEmojiNode,
|
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: {
|
editorProps: {
|
||||||
handlePaste: (_v, e) => {
|
handlePaste: (_v, e) => {
|
||||||
@ -352,23 +496,27 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
const text = ed.getText();
|
const text = ed.getText();
|
||||||
const {from} = ed.state.selection;
|
const {from} = ed.state.selection;
|
||||||
|
|
||||||
|
// Backward scan from cursor to find trigger character
|
||||||
let ts = from;
|
let ts = from;
|
||||||
|
let trigger: string | null = null;
|
||||||
for (let i = from - 1; i >= 1; i--) {
|
for (let i = from - 1; i >= 1; i--) {
|
||||||
const c = text[i - 1];
|
const c = text[i - 1];
|
||||||
if (c === '@' || c === '/') {
|
if (c === '@' || c === '#' || c === '/') {
|
||||||
ts = i;
|
ts = i;
|
||||||
|
trigger = c;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (/\s/.test(c)) break;
|
if (/\s/.test(c)) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = text.slice(ts - 1, from);
|
const q = text.slice(ts - 1, from);
|
||||||
|
|
||||||
if (q.startsWith('@') && q.length > 1) {
|
if (trigger === '@' && q.length >= 1) {
|
||||||
const results = filterMentionItems(allItems, q.slice(1));
|
const results = filterMentionItems(atPool, q.slice(1));
|
||||||
setMentionQuery(q.slice(1));
|
setMentionQuery(q.slice(1));
|
||||||
setMentionItems2(results);
|
setMentionItems2(results);
|
||||||
setMentionIdx(0);
|
setMentionIdx(0);
|
||||||
setMentionOpen(true);
|
setMentionOpen(results.length > 0);
|
||||||
|
|
||||||
if (wrapRef.current) {
|
if (wrapRef.current) {
|
||||||
const sel = window.getSelection();
|
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)});
|
setMentionPos({top: r.bottom - cr.top + 6, left: Math.max(0, r.left - cr.left)});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (q.startsWith('/') && q.length > 1) {
|
} else if (trigger === '#' && q.length >= 1) {
|
||||||
// Filter commands by query (e.g. "/ai" matches "ai")
|
const results = filterMentionItems(hashPool, q.slice(1));
|
||||||
const results = filterMentionItems(mentionItems.commands, q.slice(1));
|
|
||||||
setMentionQuery(q.slice(1));
|
setMentionQuery(q.slice(1));
|
||||||
setMentionItems2(results);
|
setMentionItems2(results);
|
||||||
setMentionIdx(0);
|
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) {
|
if (wrapRef.current) {
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
@ -402,8 +564,13 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
onBlur: () => setFocused(false),
|
onBlur: () => setFocused(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store editor ref
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editor) (editor.storage as any).keyboardSend = {onSend};
|
editorRef.current = editor;
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor) (editor.storage as any).mentionKeyboard = {onSend};
|
||||||
}, [editor, onSend]);
|
}, [editor, onSend]);
|
||||||
|
|
||||||
const doUpload = async (file: File) => {
|
const doUpload = async (file: File) => {
|
||||||
@ -433,21 +600,26 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
};
|
};
|
||||||
|
|
||||||
const send = () => {
|
const send = () => {
|
||||||
if (!editor || editor.isEmpty) return;
|
if (!editor) return;
|
||||||
const text = editor.getText().trim();
|
const ast = editor.getJSON() as MessageAST;
|
||||||
if (!text) return;
|
const serialized = serializeAstForSend(ast);
|
||||||
onSend(text, editor.getJSON() as MessageAST);
|
if (!serialized.trim()) return;
|
||||||
|
onSend(serialized, ast);
|
||||||
editor.commands.clearContent();
|
editor.commands.clearContent();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasContent = !!editor && editor.state.doc.content.size > 2;
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => editor?.commands.focus(),
|
focus: () => editor?.commands.focus(),
|
||||||
clearContent: () => editor?.commands.clearContent(),
|
clearContent: () => editor?.commands.clearContent(),
|
||||||
getContent: () => editor?.getText() ?? '',
|
getContent: () => editor?.getText() ?? '',
|
||||||
insertMention: (type: string, id: string, label: string) => {
|
insertMention: (type: string, id: string, label: string) => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
const mentionStr = `@[${type}:${id}:${label}] `;
|
editor.chain().focus().insertContent({
|
||||||
editor.chain().focus().insertContent(mentionStr).run();
|
type: 'mention',
|
||||||
|
attrs: { id, label, type },
|
||||||
|
}).insertContent(' ').run();
|
||||||
},
|
},
|
||||||
getAttachmentIds: () => {
|
getAttachmentIds: () => {
|
||||||
if (!editor) return [];
|
if (!editor) return [];
|
||||||
@ -466,8 +638,7 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const hasContent = !!editor && !editor.isEmpty;
|
|
||||||
|
|
||||||
// Dynamic styles
|
// Dynamic styles
|
||||||
const borderColor = focused ? p.borderFocus : p.border;
|
const borderColor = focused ? p.borderFocus : p.border;
|
||||||
const boxShadow = focused
|
const boxShadow = focused
|
||||||
@ -505,6 +676,7 @@ export const IMEditor = forwardRef<IMEditorHandle, IMEditorProps>(function IMEdi
|
|||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div
|
<div
|
||||||
|
className="relative"
|
||||||
onClick={() => editor?.commands.focus()}
|
onClick={() => editor?.commands.focus()}
|
||||||
style={{
|
style={{
|
||||||
background: p.bg,
|
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