@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 +1 -1
- package/README.md +214 -203
- package/dist/index.css +1 -518
- package/dist/index.d.mts +617 -0
- package/dist/index.d.ts +487 -276
- package/dist/index.js +24 -2641
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +32 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +129 -82
- package/CHANGELOG.md +0 -37
- package/dist/index.cjs +0 -2603
- package/dist/index.cjs.map +0 -1
- package/dist/index.css.map +0 -1
- package/dist/index.d.cts +0 -406
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# IM Composer
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A dual-mode input editor component for IM (Instant Messaging) applications, built with Tiptap and React.
|
|
4
4
|
|
|
5
|
-
|
|
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 {
|
|
21
|
-
import '@openim/im-composer
|
|
39
|
+
import { useRef } from 'react';
|
|
40
|
+
import { IMComposer, type IMComposerRef, type PlainMessagePayload } from '@openim/im-composer';
|
|
22
41
|
|
|
23
|
-
function
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
console.log('Sent:', payload);
|
|
33
|
-
}}
|
|
55
|
+
onSend={handleSend}
|
|
56
|
+
enableMention={true}
|
|
34
57
|
mentionProvider={async (query) => {
|
|
35
|
-
// Return
|
|
36
|
-
|
|
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
|
-
###
|
|
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'` |
|
|
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
|
-
###
|
|
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
|
|
83
|
+
| `mentionProvider` | `(query: string) => Promise<Member[]>` | - | Async search handler |
|
|
81
84
|
| `maxMentions` | `number` | - | Maximum mentions allowed |
|
|
82
|
-
| `renderMentionItem` | `(props) => ReactNode` | - | Custom
|
|
85
|
+
| `renderMentionItem` | `(props) => ReactNode` | - | Custom mention list item |
|
|
83
86
|
|
|
84
|
-
###
|
|
87
|
+
### Plain Mode - Attachments
|
|
85
88
|
|
|
86
89
|
| Prop | Type | Default | Description |
|
|
87
90
|
|------|------|---------|-------------|
|
|
88
91
|
| `enableAttachments` | `boolean` | `true` | Enable file attachments |
|
|
89
|
-
| `
|
|
90
|
-
| `
|
|
91
|
-
| `
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
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
|
|
99
|
+
### Rich Mode
|
|
98
100
|
|
|
99
101
|
| Prop | Type | Default | Description |
|
|
100
102
|
|------|------|---------|-------------|
|
|
101
|
-
| `uploadImage` | `(file: File) => Promise<{
|
|
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
|
|
105
|
+
### Keymap
|
|
105
106
|
|
|
106
107
|
| Prop | Type | Default | Description |
|
|
107
108
|
|------|------|---------|-------------|
|
|
108
|
-
| `keymap.send` | `'enter' \| 'ctrlEnter' \| 'cmdEnter'` | `'enter'` |
|
|
109
|
+
| `keymap.send` | `'enter' \| 'ctrlEnter' \| 'cmdEnter'` | `'enter'` | Send key configuration |
|
|
109
110
|
|
|
110
|
-
###
|
|
111
|
+
### Common
|
|
111
112
|
|
|
112
|
-
| Prop | Type | Description |
|
|
113
|
-
|
|
114
|
-
| `
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
159
|
+
### Plain Message Payload
|
|
161
160
|
|
|
162
161
|
```typescript
|
|
163
|
-
|
|
162
|
+
interface PlainMessagePayload {
|
|
164
163
|
type: 'text';
|
|
165
|
-
plainText: string; // Text with
|
|
166
|
-
mentions: MentionInfo[]; //
|
|
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
|
-
|
|
181
|
+
interface MarkdownMessagePayload {
|
|
176
182
|
type: 'markdown';
|
|
177
|
-
markdown: string; //
|
|
178
|
-
}
|
|
183
|
+
markdown: string; // Markdown content
|
|
184
|
+
}
|
|
179
185
|
```
|
|
180
186
|
|
|
181
|
-
##
|
|
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
|
-
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
throw new Error('Search failed');
|
|
195
|
+
}
|
|
184
196
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
208
|
+
The provider is called whenever the user types after `@`. Handle errors gracefully - they will be displayed in the mention list.
|
|
207
209
|
|
|
208
|
-
|
|
210
|
+
## Implementing uploadImage
|
|
209
211
|
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
231
|
+
While uploading, `exportPayload()` returns `null` and send is disabled.
|
|
223
232
|
|
|
224
|
-
|
|
233
|
+
## Mention Index Calculation
|
|
225
234
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
##
|
|
252
|
+
## IME Handling
|
|
247
253
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
##
|
|
262
|
+
## FAQ
|
|
261
263
|
|
|
262
|
-
|
|
264
|
+
### How do I switch between modes programmatically?
|
|
265
|
+
|
|
266
|
+
Use controlled mode with the `mode` prop:
|
|
263
267
|
|
|
264
268
|
```tsx
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
.
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
289
|
+
### Why does exportPayload return null?
|
|
278
290
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
295
|
+
This helps prevent sending empty or incomplete messages.
|
|
300
296
|
|
|
301
|
-
|
|
297
|
+
### How do I customize styles?
|
|
302
298
|
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
2. If using Twemoji graphics, ensure proper attribution per CC BY 4.0 license
|
|
319
|
+
MIT
|