IM dual-mode input editor based on Tiptap - supports plain text with @mentions and rich text with Markdown
A dual-mode input editor component for IM (Instant Messaging) applications, built with Tiptap and React.
@ to trigger member suggestions with async searchbold, italic, etc.``bash`
npm install @openim/im-composeror
pnpm add @openim/im-composeror
yarn add @openim/im-composer
`tsx
import { useRef } from 'react';
import { IMComposer, type IMComposerRef, type PlainMessagePayload } from '@openim/im-composer';
function ChatInput() {
const composerRef = useRef
const handleSend = (payload: PlainMessagePayload) => {
console.log('Message:', payload.plainText);
console.log('Mentions:', payload.mentions);
console.log('Attachments:', payload.attachments);
};
return (
mode="plain"
onSend={handleSend}
enableMention={true}
mentionProvider={async (query) => {
// Return filtered members based on query
const response = await fetch(/api/members?q=${query});`
return response.json();
}}
enableAttachments={true}
placeholder="Type a message..."
/>
);
}
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| mode | 'plain' \| 'rich' | - | Controlled mode |defaultMode
| | 'plain' \| 'rich' | 'plain' | Initial mode (uncontrolled) |
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| enableMention | boolean | true | Enable @mention feature |mentionProvider
| | (query: string) => Promise | - | Async search handler |maxMentions
| | number | - | Maximum mentions allowed |renderMentionItem
| | (props) => ReactNode | - | Custom mention list item |
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| enableAttachments | boolean | true | Enable file attachments |maxAttachments
| | number | 10 | Maximum attachments |maxFileSize
| | number | - | Max file size in bytes |allowedMimeTypes
| | string[] | - | Allowed MIME types (supports wildcards) |attachmentPreviewPlacement
| | 'top' \| 'bottom' | 'bottom' | Preview bar position |onAttachmentLimitExceeded
| | (reason, file) => void | - | Called when limit exceeded |onFilesChange
| | (attachments) => void | - | Called when attachments change |
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| uploadImage | (file: File) => Promise<{url, alt?}> | - | Image upload handler |
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| keymap.send | 'enter' \| 'ctrlEnter' \| 'cmdEnter' | 'enter' | Send key configuration |
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| placeholder | string \| {plain?, rich?} | - | Placeholder text |disabled
| | boolean | false | Disable the editor |className
| | string | - | Additional CSS class |locale
| | IMComposerLocale | - | i18n strings |onSend
| | (payload) => void | - | Called on send |onChange
| | () => void | - | Called on content change |onQuoteRemoved
| | () => void | - | Called when quote is removed |
`tsx
interface IMComposerRef {
focus: () => void;
clear: () => void;
exportPayload: () => MessagePayload | null;
// Rich mode
importMarkdown: (markdown: string) => void;
// Attachments (plain mode)
getAttachments: () => Attachment[];
setAttachments: (attachments: Attachment[]) => void;
addFiles: (files: FileList | File[]) => void;
removeAttachment: (id: string) => void;
clearAttachments: () => void;
// Quote (plain mode)
insertQuote: (title: string, content: string) => void;
// Mention (plain mode)
insertMention: (userId: string, display: string) => void;
// Draft
getDraft: () => ComposerDraft;
setDraft: (draft: ComposerDraft) => void;
// Text
setText: (text: string) => void;
insertText: (text: string) => void;
}
`
`typescript
interface PlainMessagePayload {
type: 'text';
plainText: string; // Text with mentions as @userId
mentions: MentionInfo[]; // Mention positions (UTF-16 indices)
attachments: Attachment[];
quote?: QuoteInfo;
}
interface MentionInfo {
userId: string;
display: string;
start: number; // UTF-16 index, inclusive
end: number; // UTF-16 index, exclusive
}
`
`typescript`
interface MarkdownMessagePayload {
type: 'markdown';
markdown: string; // Markdown content
}
`typescript/api/members/search?q=${encodeURIComponent(query)}
const mentionProvider = async (query: string): Promise
const response = await fetch();
if (!response.ok) {
throw new Error('Search failed');
}
return response.json();
};
// Member type
interface Member {
userId: string;
display: string;
avatarUrl?: string;
}
`
The provider is called whenever the user types after @. Handle errors gracefully - they will be displayed in the mention list.
`typescript
const uploadImage = async (file: File): Promise<{ url: string; alt?: string }> => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const { url } = await response.json();
return { url, alt: file.name };
};
`
While uploading, exportPayload() returns null and send is disabled.
Mention indices use UTF-16 code units (JavaScript string indices) with half-open intervals [start, end):
`typescript
const plainText = '@alice hello @bob';
// ^ ^ ^ ^
// 0 6 13 17
const mentions = [
{ userId: 'alice', display: 'Alice', start: 0, end: 6 }, // "@alice"
{ userId: 'bob', display: 'Bob', start: 13, end: 17 }, // "@bob"
];
// Verify:
plainText.slice(0, 6); // "@alice"
plainText.slice(13, 17); // "@bob"
`
The component properly handles IME (Input Method Editor) input for CJK languages:
- Mention suggestion is not triggered during composition
- Markdown shortcuts are not triggered during composition
- Send key is not triggered during composition
This prevents unexpected behavior when typing Chinese, Japanese, or Korean.
Use controlled mode with the mode prop:
`tsx
const [mode, setMode] = useState
`
`tsxdraft:${chatId}
// Save
const draft = composerRef.current?.getDraft();
localStorage.setItem(, JSON.stringify(draft));
// Restore
const savedDraft = localStorage.getItem(draft:${chatId});`
if (savedDraft) {
composerRef.current?.setDraft(JSON.parse(savedDraft));
}
exportPayload() returns null when:
- The editor is empty (no text and no attachments)
- Image upload is in progress (rich mode)
This helps prevent sending empty or incomplete messages.
The component uses CSS Modules internally. You can override styles using the className prop and targeting the internal class names with higher specificity.
`bashInstall dependencies
pnpm install
MIT