@openim/im-composer 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2024 im-composer
3
+ Copyright (c) 2024 OpenIM
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,8 +1,27 @@
1
- # @openim/im-composer
1
+ # IM Composer
2
2
 
3
- A React IM composer component with dual mode (plain/rich) supporting @mentions, emoji, attachments, and markdown.
3
+ A dual-mode input editor component for IM (Instant Messaging) applications, built with Tiptap and React.
4
4
 
5
- Built with [Lexical](https://lexical.dev/) editor framework.
5
+ ## Features
6
+
7
+ ### Plain Text Mode
8
+ - **@Mention**: Type `@` to trigger member suggestions with async search
9
+ - **File Attachments**: Paste or drag files to attach (with preview)
10
+ - **Quote Messages**: Insert quoted replies that appear above the editor
11
+ - **Atomic Mention Tokens**: Mention tokens cannot be edited - backspace deletes the whole token
12
+
13
+ ### Rich Text Mode
14
+ - **Markdown Shortcuts**: Type `**bold**`, `*italic*`, etc.
15
+ - **Toolbar**: Bold, italic, headings, lists, blockquote, code block, links
16
+ - **Image Upload**: Paste or select images to upload via external handler
17
+ - **Markdown Import/Export**: Programmatically set or get Markdown content
18
+
19
+ ### Common Features
20
+ - **Mode Isolation**: Plain and rich modes maintain completely separate editor states
21
+ - **Configurable Keymap**: Enter/Ctrl+Enter/Cmd+Enter for send
22
+ - **IME Support**: Proper handling of CJK input composition
23
+ - **Draft Support**: Save and restore editor state
24
+ - **i18n Ready**: Customizable locale strings
6
25
 
7
26
  ## Installation
8
27
 
@@ -10,299 +29,291 @@ Built with [Lexical](https://lexical.dev/) editor framework.
10
29
  npm install @openim/im-composer
11
30
  # or
12
31
  pnpm add @openim/im-composer
32
+ # or
33
+ yarn add @openim/im-composer
13
34
  ```
14
35
 
15
- **Note:** Requires React 18+ or React 19+ as peer dependencies.
16
-
17
36
  ## Quick Start
18
37
 
19
38
  ```tsx
20
- import { IMComposer, IMComposerRef } from '@openim/im-composer';
21
- import '@openim/im-composer/styles.css';
39
+ import { useRef } from 'react';
40
+ import { IMComposer, type IMComposerRef, type PlainMessagePayload } from '@openim/im-composer';
22
41
 
23
- function Chat() {
42
+ function ChatInput() {
24
43
  const composerRef = useRef<IMComposerRef>(null);
25
44
 
45
+ const handleSend = (payload: PlainMessagePayload) => {
46
+ console.log('Message:', payload.plainText);
47
+ console.log('Mentions:', payload.mentions);
48
+ console.log('Attachments:', payload.attachments);
49
+ };
50
+
26
51
  return (
27
52
  <IMComposer
28
53
  ref={composerRef}
29
54
  mode="plain"
30
- placeholder="Type a message..."
31
- onSend={(payload) => {
32
- console.log('Sent:', payload);
33
- }}
55
+ onSend={handleSend}
56
+ enableMention={true}
34
57
  mentionProvider={async (query) => {
35
- // Return list of users matching query
36
- return [{ userId: '1', display: 'John', avatarUrl: '...' }];
58
+ // Return filtered members based on query
59
+ const response = await fetch(`/api/members?q=${query}`);
60
+ return response.json();
37
61
  }}
62
+ enableAttachments={true}
63
+ placeholder="Type a message..."
38
64
  />
39
65
  );
40
66
  }
41
67
  ```
42
68
 
43
- ## Features
44
-
45
- ### Plain Text Mode
46
- - **@Mentions** - Type `@` to search and mention users
47
- - **Attachments** - Paste or drag files with preview
48
- - **Quote Messages** - Reply to messages with quote blocks
49
- - **Emoji** - Full Unicode emoji support
50
- - **Draft Support** - Save and restore editor state
51
-
52
- ### Rich Text Mode (Markdown)
53
- - **Formatting Toolbar** - Bold, italic, strikethrough, code
54
- - **Headings** - H1, H2, H3
55
- - **Lists** - Bullet and ordered lists
56
- - **Code Blocks** - Inline code and code blocks
57
- - **Block Quotes** - Quote formatting
58
- - **Links** - Insert and edit links with popup
59
- - **Images** - Upload and embed images
60
-
61
69
  ## Props
62
70
 
63
- ### Basic Props
71
+ ### Mode Control
64
72
 
65
73
  | Prop | Type | Default | Description |
66
74
  |------|------|---------|-------------|
67
75
  | `mode` | `'plain' \| 'rich'` | - | Controlled mode |
68
- | `defaultMode` | `'plain' \| 'rich'` | `'plain'` | Default mode (uncontrolled) |
69
- | `onSend` | `(payload) => void` | - | Callback when user sends message |
70
- | `placeholder` | `string \| { plain?: string; rich?: string }` | - | Placeholder text |
71
- | `disabled` | `boolean` | `false` | Disable the editor |
72
- | `className` | `string` | - | Custom class name |
73
- | `onChange` | `() => void` | - | Callback on content change |
76
+ | `defaultMode` | `'plain' \| 'rich'` | `'plain'` | Initial mode (uncontrolled) |
74
77
 
75
- ### Mention Options (Plain Mode)
78
+ ### Plain Mode - Mentions
76
79
 
77
80
  | Prop | Type | Default | Description |
78
81
  |------|------|---------|-------------|
79
82
  | `enableMention` | `boolean` | `true` | Enable @mention feature |
80
- | `mentionProvider` | `(query: string) => Promise<Member[]>` | - | Async function to search members |
83
+ | `mentionProvider` | `(query: string) => Promise<Member[]>` | - | Async search handler |
81
84
  | `maxMentions` | `number` | - | Maximum mentions allowed |
82
- | `renderMentionItem` | `(props) => ReactNode` | - | Custom render for mention dropdown items |
85
+ | `renderMentionItem` | `(props) => ReactNode` | - | Custom mention list item |
83
86
 
84
- ### Attachment Options (Plain Mode)
87
+ ### Plain Mode - Attachments
85
88
 
86
89
  | Prop | Type | Default | Description |
87
90
  |------|------|---------|-------------|
88
91
  | `enableAttachments` | `boolean` | `true` | Enable file attachments |
89
- | `showAttachmentPreview` | `boolean` | `true` | Show built-in attachment preview bar |
90
- | `attachmentPreviewPlacement` | `'top' \| 'bottom'` | `'bottom'` | Position of preview bar |
91
- | `maxAttachments` | `number` | `10` | Maximum attachments allowed |
92
- | `maxFileSize` | `number` | - | Maximum file size in bytes |
93
- | `allowedMimeTypes` | `string[]` | - | Allowed MIME types |
94
- | `onAttachmentLimitExceeded` | `(reason, file) => void` | - | Callback when limit exceeded |
95
- | `onFilesChange` | `(attachments) => void` | - | Callback when attachments change |
92
+ | `maxAttachments` | `number` | `10` | Maximum attachments |
93
+ | `maxFileSize` | `number` | - | Max file size in bytes |
94
+ | `allowedMimeTypes` | `string[]` | - | Allowed MIME types (supports wildcards) |
95
+ | `attachmentPreviewPlacement` | `'top' \| 'bottom'` | `'bottom'` | Preview bar position |
96
+ | `onAttachmentLimitExceeded` | `(reason, file) => void` | - | Called when limit exceeded |
97
+ | `onFilesChange` | `(attachments) => void` | - | Called when attachments change |
96
98
 
97
- ### Rich Mode Options
99
+ ### Rich Mode
98
100
 
99
101
  | Prop | Type | Default | Description |
100
102
  |------|------|---------|-------------|
101
- | `uploadImage` | `(file: File) => Promise<{ url: string; alt?: string }>` | - | Image upload function |
102
- | `markdownOptions.enabledSyntax` | `MarkdownSyntaxOptions` | All enabled | Enable/disable specific markdown features |
103
+ | `uploadImage` | `(file: File) => Promise<{url, alt?}>` | - | Image upload handler |
103
104
 
104
- ### Keymap Options
105
+ ### Keymap
105
106
 
106
107
  | Prop | Type | Default | Description |
107
108
  |------|------|---------|-------------|
108
- | `keymap.send` | `'enter' \| 'ctrlEnter' \| 'cmdEnter'` | `'enter'` | Key to send message |
109
+ | `keymap.send` | `'enter' \| 'ctrlEnter' \| 'cmdEnter'` | `'enter'` | Send key configuration |
109
110
 
110
- ### Other Props
111
+ ### Common
111
112
 
112
- | Prop | Type | Description |
113
- |------|------|-------------|
114
- | `onContextMenu` | `MouseEventHandler` | Right-click handler |
115
- | `onQuoteRemoved` | `() => void` | Callback when quote is removed |
116
- | `locale` | `IMComposerLocale` | i18n configuration |
113
+ | Prop | Type | Default | Description |
114
+ |------|------|---------|-------------|
115
+ | `placeholder` | `string \| {plain?, rich?}` | - | Placeholder text |
116
+ | `disabled` | `boolean` | `false` | Disable the editor |
117
+ | `className` | `string` | - | Additional CSS class |
118
+ | `locale` | `IMComposerLocale` | - | i18n strings |
119
+ | `onSend` | `(payload) => void` | - | Called on send |
120
+ | `onChange` | `() => void` | - | Called on content change |
121
+ | `onQuoteRemoved` | `() => void` | - | Called when quote is removed |
117
122
 
118
123
  ## Ref Methods
119
124
 
120
- Access via `useRef<IMComposerRef>()`:
121
-
122
125
  ```tsx
123
- const composerRef = useRef<IMComposerRef>(null);
124
-
125
- // Focus the editor
126
- composerRef.current?.focus();
127
-
128
- // Clear content
129
- composerRef.current?.clear();
130
-
131
- // Export payload without sending
132
- const payload = composerRef.current?.exportPayload();
133
-
134
- // Insert text at cursor
135
- composerRef.current?.insertText('Hello 👋');
126
+ interface IMComposerRef {
127
+ focus: () => void;
128
+ clear: () => void;
129
+ exportPayload: () => MessagePayload | null;
130
+
131
+ // Rich mode
132
+ importMarkdown: (markdown: string) => void;
133
+
134
+ // Attachments (plain mode)
135
+ getAttachments: () => Attachment[];
136
+ setAttachments: (attachments: Attachment[]) => void;
137
+ addFiles: (files: FileList | File[]) => void;
138
+ removeAttachment: (id: string) => void;
139
+ clearAttachments: () => void;
140
+
141
+ // Quote (plain mode)
142
+ insertQuote: (title: string, content: string) => void;
143
+
144
+ // Mention (plain mode)
145
+ insertMention: (userId: string, display: string) => void;
146
+
147
+ // Draft
148
+ getDraft: () => ComposerDraft;
149
+ setDraft: (draft: ComposerDraft) => void;
150
+
151
+ // Text
152
+ setText: (text: string) => void;
153
+ insertText: (text: string) => void;
154
+ }
136
155
  ```
137
156
 
138
- ### All Methods
139
-
140
- | Method | Description |
141
- |--------|-------------|
142
- | `focus()` | Focus the editor |
143
- | `clear()` | Clear editor content and attachments |
144
- | `exportPayload()` | Export current payload without sending |
145
- | `importMarkdown(markdown)` | Import markdown content (rich mode) |
146
- | `getAttachments()` | Get current attachments (plain mode) |
147
- | `setAttachments(attachments)` | Set attachments (plain mode) |
148
- | `addFiles(files)` | Add files to attachments (plain mode) |
149
- | `removeAttachment(id)` | Remove attachment by ID (plain mode) |
150
- | `clearAttachments()` | Clear all attachments (plain mode) |
151
- | `insertQuote(title, content)` | Insert a quote message (plain mode) |
152
- | `insertMention(userId, display)` | Insert a mention (plain mode) |
153
- | `getDraft()` | Get draft state for saving |
154
- | `setDraft(draft)` | Restore draft state |
155
- | `setText(text, mentions?)` | Set editor content from plain text |
156
- | `insertText(text)` | Insert text at cursor position |
157
-
158
157
  ## Payload Types
159
158
 
160
- ### Plain Text Payload
159
+ ### Plain Message Payload
161
160
 
162
161
  ```typescript
163
- type PlainMessagePayload = {
162
+ interface PlainMessagePayload {
164
163
  type: 'text';
165
- plainText: string; // Text with @mentions as @{display}
166
- mentions: MentionInfo[]; // Position info for each mention
164
+ plainText: string; // Text with mentions as @userId
165
+ mentions: MentionInfo[]; // Mention positions (UTF-16 indices)
167
166
  attachments: Attachment[];
168
167
  quote?: QuoteInfo;
169
- };
168
+ }
169
+
170
+ interface MentionInfo {
171
+ userId: string;
172
+ display: string;
173
+ start: number; // UTF-16 index, inclusive
174
+ end: number; // UTF-16 index, exclusive
175
+ }
170
176
  ```
171
177
 
172
- ### Markdown Payload
178
+ ### Markdown Message Payload
173
179
 
174
180
  ```typescript
175
- type MarkdownMessagePayload = {
181
+ interface MarkdownMessagePayload {
176
182
  type: 'markdown';
177
- markdown: string; // Full markdown content
178
- };
183
+ markdown: string; // Markdown content
184
+ }
179
185
  ```
180
186
 
181
- ## i18n / Localization
187
+ ## Implementing mentionProvider
188
+
189
+ ```typescript
190
+ const mentionProvider = async (query: string): Promise<Member[]> => {
191
+ const response = await fetch(`/api/members/search?q=${encodeURIComponent(query)}`);
182
192
 
183
- Customize text labels with the `locale` prop:
193
+ if (!response.ok) {
194
+ throw new Error('Search failed');
195
+ }
184
196
 
185
- ```tsx
186
- <IMComposer
187
- locale={{
188
- linkDialog: {
189
- textLabel: '文本',
190
- urlLabel: '链接',
191
- cancelButton: '取消',
192
- insertButton: '插入',
193
- saveButton: '保存',
194
- },
195
- toolbar: {
196
- bold: '粗体',
197
- italic: '斜体',
198
- link: '插入链接',
199
- image: '插入图片',
200
- // ... more toolbar labels
201
- },
202
- }}
203
- />
197
+ return response.json();
198
+ };
199
+
200
+ // Member type
201
+ interface Member {
202
+ userId: string;
203
+ display: string;
204
+ avatarUrl?: string;
205
+ }
204
206
  ```
205
207
 
206
- ## Draft Support
208
+ The provider is called whenever the user types after `@`. Handle errors gracefully - they will be displayed in the mention list.
207
209
 
208
- Save and restore editor state for drafts:
210
+ ## Implementing uploadImage
209
211
 
210
- ```tsx
211
- // Save draft
212
- const draft = composerRef.current?.getDraft();
213
- localStorage.setItem('draft', JSON.stringify(draft));
212
+ ```typescript
213
+ const uploadImage = async (file: File): Promise<{ url: string; alt?: string }> => {
214
+ const formData = new FormData();
215
+ formData.append('file', file);
214
216
 
215
- // Restore draft
216
- const saved = localStorage.getItem('draft');
217
- if (saved) {
218
- composerRef.current?.setDraft(JSON.parse(saved));
219
- }
217
+ const response = await fetch('/api/upload', {
218
+ method: 'POST',
219
+ body: formData,
220
+ });
221
+
222
+ if (!response.ok) {
223
+ throw new Error('Upload failed');
224
+ }
225
+
226
+ const { url } = await response.json();
227
+ return { url, alt: file.name };
228
+ };
220
229
  ```
221
230
 
222
- ## Markdown Syntax Control
231
+ While uploading, `exportPayload()` returns `null` and send is disabled.
223
232
 
224
- Enable/disable specific markdown features:
233
+ ## Mention Index Calculation
225
234
 
226
- ```tsx
227
- <IMComposer
228
- mode="rich"
229
- markdownOptions={{
230
- enabledSyntax: {
231
- heading: true,
232
- bold: true,
233
- italic: true,
234
- strike: true,
235
- codeInline: true,
236
- codeBlock: true,
237
- quote: true,
238
- list: true,
239
- link: true,
240
- image: true, // Requires uploadImage prop
241
- },
242
- }}
243
- />
235
+ Mention indices use UTF-16 code units (JavaScript string indices) with half-open intervals `[start, end)`:
236
+
237
+ ```typescript
238
+ const plainText = '@alice hello @bob';
239
+ // ^ ^ ^ ^
240
+ // 0 6 13 17
241
+
242
+ const mentions = [
243
+ { userId: 'alice', display: 'Alice', start: 0, end: 6 }, // "@alice"
244
+ { userId: 'bob', display: 'Bob', start: 13, end: 17 }, // "@bob"
245
+ ];
246
+
247
+ // Verify:
248
+ plainText.slice(0, 6); // "@alice"
249
+ plainText.slice(13, 17); // "@bob"
244
250
  ```
245
251
 
246
- ## Custom Mention Item Rendering
252
+ ## IME Handling
247
253
 
248
- ```tsx
249
- <IMComposer
250
- mentionProvider={searchUsers}
251
- renderMentionItem={({ member, isSelected }) => (
252
- <div className={`mention-item ${isSelected ? 'selected' : ''}`}>
253
- <img src={member.avatarUrl} alt="" />
254
- <span>{member.display}</span>
255
- </div>
256
- )}
257
- />
258
- ```
254
+ The component properly handles IME (Input Method Editor) input for CJK languages:
255
+
256
+ - Mention suggestion is **not** triggered during composition
257
+ - Markdown shortcuts are **not** triggered during composition
258
+ - Send key is **not** triggered during composition
259
+
260
+ This prevents unexpected behavior when typing Chinese, Japanese, or Korean.
259
261
 
260
- ## Styling
262
+ ## FAQ
261
263
 
262
- Import the default styles:
264
+ ### How do I switch between modes programmatically?
265
+
266
+ Use controlled mode with the `mode` prop:
263
267
 
264
268
  ```tsx
265
- import 'im-composer/styles.css';
269
+ const [mode, setMode] = useState<EditorMode>('plain');
270
+
271
+ <IMComposer mode={mode} />
272
+ <button onClick={() => setMode('rich')}>Switch to Rich</button>
266
273
  ```
267
274
 
268
- Customize with CSS variables or override the classes:
275
+ ### How do I save and restore drafts?
276
+
277
+ ```tsx
278
+ // Save
279
+ const draft = composerRef.current?.getDraft();
280
+ localStorage.setItem(`draft:${chatId}`, JSON.stringify(draft));
269
281
 
270
- ```css
271
- .im-composer {
272
- --im-composer-border-color: #e0e0e0;
273
- --im-composer-focus-color: #1890ff;
282
+ // Restore
283
+ const savedDraft = localStorage.getItem(`draft:${chatId}`);
284
+ if (savedDraft) {
285
+ composerRef.current?.setDraft(JSON.parse(savedDraft));
274
286
  }
275
287
  ```
276
288
 
277
- ## Exported Types
289
+ ### Why does exportPayload return null?
278
290
 
279
- ```tsx
280
- import type {
281
- IMComposerProps,
282
- IMComposerRef,
283
- IMComposerLocale,
284
- PlainMessagePayload,
285
- MarkdownMessagePayload,
286
- MessagePayload,
287
- Member,
288
- MentionInfo,
289
- Attachment,
290
- QuoteInfo,
291
- ComposerMode,
292
- UploadImageFn,
293
- UploadImageResult,
294
- MarkdownSyntaxOptions,
295
- SendKey,
296
- } from 'im-composer';
297
- ```
291
+ `exportPayload()` returns `null` when:
292
+ - The editor is empty (no text and no attachments)
293
+ - Image upload is in progress (rich mode)
298
294
 
299
- ## License
295
+ This helps prevent sending empty or incomplete messages.
300
296
 
301
- This package is licensed under the MIT License.
297
+ ### How do I customize styles?
302
298
 
303
- ## Note on Emoji Rendering
299
+ The component uses CSS Modules internally. You can override styles using the `className` prop and targeting the internal class names with higher specificity.
304
300
 
305
- This package uses CSS font-family declarations for emoji rendering. For consistent emoji display across platforms:
301
+ ## Development
302
+
303
+ ```bash
304
+ # Install dependencies
305
+ pnpm install
306
+
307
+ # Start demo
308
+ pnpm dev
309
+
310
+ # Build package
311
+ pnpm build
312
+
313
+ # Run tests
314
+ pnpm test
315
+ ```
316
+
317
+ ## License
306
318
 
307
- 1. Include an emoji font (like Twemoji) in your project
308
- 2. If using Twemoji graphics, ensure proper attribution per CC BY 4.0 license
319
+ MIT