react-mention-input 1.1.11 → 1.1.13
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/dist/MentionInput.css +290 -74
- package/dist/MentionInput.d.ts +6 -0
- package/dist/MentionInput.js +196 -29
- package/package.json +1 -1
- package/src/MentionInput.css +290 -74
- package/src/MentionInput.tsx +234 -60
package/src/MentionInput.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useRef,
|
|
1
|
+
import React, { useState, useRef, ReactNode } from "react";
|
|
2
2
|
import ReactDOM from "react-dom";
|
|
3
3
|
import "./MentionInput.css";
|
|
4
4
|
|
|
@@ -16,27 +16,46 @@ interface MentionInputProps {
|
|
|
16
16
|
sendBtnClassName?: string;
|
|
17
17
|
suggestionListClassName?: string;
|
|
18
18
|
suggestionItemClassName?: string;
|
|
19
|
+
imgClassName?: string; // New prop for image CSS class
|
|
20
|
+
imgStyle?: React.CSSProperties; // New prop for inline image styles
|
|
19
21
|
sendButtonIcon?: ReactNode; // Button icon (MUI icon or image path)
|
|
20
|
-
|
|
22
|
+
attachmentButtonIcon?: ReactNode; // New prop for attachment button icon
|
|
23
|
+
onSendMessage?: (obj: {
|
|
24
|
+
messageText: string;
|
|
25
|
+
messageHTML: string;
|
|
26
|
+
userSelectListWithIds: { id: number; name: string }[];
|
|
27
|
+
userSelectListName: string[];
|
|
28
|
+
images?: File[];
|
|
29
|
+
imageUrl?: string | null;
|
|
30
|
+
}) => void;
|
|
21
31
|
suggestionPosition?: 'top' | 'bottom' | 'left' | 'right'; // New prop for tooltip position
|
|
32
|
+
onImageUpload?: (file: File) => Promise<string>; // New prop for image upload
|
|
22
33
|
}
|
|
23
34
|
|
|
24
35
|
const MentionInput: React.FC<MentionInputProps> = ({
|
|
25
36
|
users,
|
|
26
|
-
placeholder = "Type a message...",
|
|
37
|
+
placeholder = "Type a message... (or drag & drop an image)",
|
|
27
38
|
containerClassName,
|
|
28
39
|
inputContainerClassName,
|
|
29
40
|
inputClassName,
|
|
30
41
|
sendBtnClassName,
|
|
31
42
|
suggestionListClassName,
|
|
32
43
|
suggestionItemClassName,
|
|
44
|
+
imgClassName,
|
|
45
|
+
imgStyle,
|
|
33
46
|
sendButtonIcon,
|
|
47
|
+
attachmentButtonIcon,
|
|
34
48
|
onSendMessage,
|
|
35
|
-
suggestionPosition = 'bottom',
|
|
49
|
+
suggestionPosition = 'bottom',
|
|
50
|
+
onImageUpload,
|
|
36
51
|
}) => {
|
|
37
52
|
const [inputValue, setInputValue] = useState<string>(""); // Plain text
|
|
38
53
|
const [suggestions, setSuggestions] = useState<User[]>([]);
|
|
39
54
|
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
|
|
55
|
+
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
|
56
|
+
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
|
57
|
+
const [isUploading, setIsUploading] = useState<boolean>(false);
|
|
58
|
+
const [isDraggingOver, setIsDraggingOver] = useState<boolean>(false);
|
|
40
59
|
|
|
41
60
|
|
|
42
61
|
|
|
@@ -45,6 +64,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
45
64
|
const caretOffsetRef = useRef<number>(0);
|
|
46
65
|
const userSelectListRef = useRef<string[]>([]); // Only unique names
|
|
47
66
|
const userSelectListWithIdsRef = useRef<{ id: number; name: string }[]>([]); // Unique IDs with names
|
|
67
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
48
68
|
|
|
49
69
|
const highlightMentionsAndLinks = (text: string): string => {
|
|
50
70
|
// Regular expression for detecting links
|
|
@@ -61,7 +81,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
61
81
|
const mentionPattern = new RegExp(`@${userName}(\\s|$)`, "g");
|
|
62
82
|
highlightedText = highlightedText.replace(
|
|
63
83
|
mentionPattern,
|
|
64
|
-
(match
|
|
84
|
+
(match) => {
|
|
65
85
|
return `<span class="mention-highlight">${match.trim()}</span> `;
|
|
66
86
|
}
|
|
67
87
|
);
|
|
@@ -104,10 +124,11 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
104
124
|
|
|
105
125
|
const handleInputChange = () => {
|
|
106
126
|
if (!inputRef.current) return;
|
|
107
|
-
|
|
127
|
+
|
|
128
|
+
// Store current selection before modifications
|
|
108
129
|
const selection = window.getSelection();
|
|
109
130
|
const range = selection?.getRangeAt(0);
|
|
110
|
-
|
|
131
|
+
|
|
111
132
|
let newCaretOffset = 0;
|
|
112
133
|
if (range && inputRef.current.contains(range.startContainer)) {
|
|
113
134
|
const preCaretRange = range.cloneRange();
|
|
@@ -115,30 +136,38 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
115
136
|
preCaretRange.setEnd(range.startContainer, range.startOffset);
|
|
116
137
|
newCaretOffset = preCaretRange.toString().length;
|
|
117
138
|
}
|
|
118
|
-
|
|
139
|
+
|
|
119
140
|
caretOffsetRef.current = newCaretOffset;
|
|
120
|
-
|
|
141
|
+
|
|
121
142
|
const plainText = inputRef.current.innerText;
|
|
122
143
|
setInputValue(plainText);
|
|
123
|
-
|
|
144
|
+
|
|
145
|
+
// Process for mention suggestions
|
|
124
146
|
const mentionMatch = plainText.slice(0, newCaretOffset).match(/@(\S*)$/);
|
|
125
147
|
if (mentionMatch) {
|
|
126
148
|
const query = mentionMatch[1].toLowerCase();
|
|
127
|
-
const filteredUsers = query === "" ? users : users.filter((user) =>
|
|
128
|
-
|
|
149
|
+
const filteredUsers = query === "" ? users : users.filter((user) =>
|
|
150
|
+
user.name.toLowerCase().includes(query)
|
|
151
|
+
);
|
|
152
|
+
|
|
129
153
|
setSuggestions(filteredUsers);
|
|
130
154
|
setShowSuggestions(filteredUsers.length > 0);
|
|
131
155
|
} else {
|
|
132
156
|
setShowSuggestions(false);
|
|
133
157
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
158
|
+
|
|
159
|
+
// Only apply highlighting if we have mentions or links to highlight
|
|
160
|
+
if (userSelectListRef.current.length > 0 || plainText.match(/(https?:\/\/[^\s]+)/g)) {
|
|
161
|
+
const currentHTML = inputRef.current.innerHTML;
|
|
162
|
+
const htmlWithHighlights = highlightMentionsAndLinks(plainText);
|
|
163
|
+
|
|
164
|
+
// Only update if the highlighted HTML is different to avoid cursor jumping
|
|
165
|
+
if (currentHTML !== htmlWithHighlights) {
|
|
166
|
+
inputRef.current.innerHTML = htmlWithHighlights;
|
|
167
|
+
// Restore cursor position after changing innerHTML
|
|
168
|
+
restoreCaretPosition(inputRef.current, newCaretOffset);
|
|
169
|
+
}
|
|
139
170
|
}
|
|
140
|
-
|
|
141
|
-
restoreCaretPosition(inputRef.current, newCaretOffset);
|
|
142
171
|
};
|
|
143
172
|
|
|
144
173
|
|
|
@@ -148,8 +177,8 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
148
177
|
const getInitials = (name: string) => {
|
|
149
178
|
const nameParts = name.split(" ");
|
|
150
179
|
const initials = nameParts
|
|
151
|
-
.map((part) => part[0]?.toUpperCase() || "")
|
|
152
|
-
.slice(0, 2)
|
|
180
|
+
.map((part) => part[0]?.toUpperCase() || "")
|
|
181
|
+
.slice(0, 2)
|
|
153
182
|
.join("");
|
|
154
183
|
return initials;
|
|
155
184
|
};
|
|
@@ -183,26 +212,30 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
183
212
|
}
|
|
184
213
|
|
|
185
214
|
return ReactDOM.createPortal(
|
|
186
|
-
<
|
|
187
|
-
className={`suggestion-
|
|
188
|
-
ref={suggestionListRef}
|
|
215
|
+
<div
|
|
216
|
+
className={`suggestion-container ${suggestionListClassName || ''}`}
|
|
189
217
|
style={styles}
|
|
190
218
|
>
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
219
|
+
<ul
|
|
220
|
+
className="suggestion-list"
|
|
221
|
+
ref={suggestionListRef}
|
|
222
|
+
>
|
|
223
|
+
{suggestions.map((user) => (
|
|
224
|
+
<li
|
|
225
|
+
key={user.id}
|
|
226
|
+
onClick={() => handleSuggestionClick(user)}
|
|
227
|
+
className={`suggestion-item ${suggestionItemClassName || ''}`}
|
|
228
|
+
role="option"
|
|
229
|
+
tabIndex={0}
|
|
230
|
+
aria-selected="false"
|
|
231
|
+
>
|
|
232
|
+
<div className="user-icon">{getInitials(user?.name)}</div>
|
|
233
|
+
<span className="user-name">{user.name}</span>
|
|
234
|
+
</li>
|
|
235
|
+
))}
|
|
236
|
+
</ul>
|
|
237
|
+
</div>,
|
|
238
|
+
window.document.body
|
|
206
239
|
);
|
|
207
240
|
};
|
|
208
241
|
|
|
@@ -249,18 +282,103 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
249
282
|
restoreCaretPosition(inputRef.current, mentionEnd + 1); // +1 for the space
|
|
250
283
|
};
|
|
251
284
|
|
|
285
|
+
const handleImageSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
286
|
+
const files = Array.from(event.target.files || []);
|
|
287
|
+
if (files.length > 0) {
|
|
288
|
+
const file = files[0];
|
|
289
|
+
if (file.type.startsWith('image/')) {
|
|
290
|
+
await uploadImage(file);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
e.stopPropagation();
|
|
298
|
+
// Only set dragging if files are being dragged
|
|
299
|
+
if (e.dataTransfer.types.includes('Files')) {
|
|
300
|
+
setIsDraggingOver(true);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
|
305
|
+
e.preventDefault();
|
|
306
|
+
e.stopPropagation();
|
|
307
|
+
// Check if we're leaving the container, not just moving between children
|
|
308
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
309
|
+
const x = e.clientX;
|
|
310
|
+
const y = e.clientY;
|
|
311
|
+
|
|
312
|
+
if (
|
|
313
|
+
x <= rect.left ||
|
|
314
|
+
x >= rect.right ||
|
|
315
|
+
y <= rect.top ||
|
|
316
|
+
y >= rect.bottom
|
|
317
|
+
) {
|
|
318
|
+
setIsDraggingOver(false);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
e.stopPropagation();
|
|
325
|
+
setIsDraggingOver(false);
|
|
326
|
+
const files = Array.from(e.dataTransfer.files);
|
|
327
|
+
if (files.length > 0) {
|
|
328
|
+
const imageFiles = files.filter(file => file.type.startsWith('image/'));
|
|
329
|
+
if (imageFiles.length > 0) {
|
|
330
|
+
await uploadImage(imageFiles[0]);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const uploadImage = async (file: File) => {
|
|
336
|
+
if (!onImageUpload) {
|
|
337
|
+
// If no upload function provided, store the file directly
|
|
338
|
+
setSelectedImage(file);
|
|
339
|
+
setImageUrl(URL.createObjectURL(file));
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
setIsUploading(true);
|
|
345
|
+
const url = await onImageUpload(file);
|
|
346
|
+
setSelectedImage(file);
|
|
347
|
+
setImageUrl(url);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
console.error('Error uploading image:', error);
|
|
350
|
+
// You might want to show an error message to the user
|
|
351
|
+
} finally {
|
|
352
|
+
setIsUploading(false);
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
const removeImage = () => {
|
|
357
|
+
setSelectedImage(null);
|
|
358
|
+
setImageUrl(null);
|
|
359
|
+
};
|
|
360
|
+
|
|
252
361
|
const handleSendMessage = () => {
|
|
253
362
|
if (inputRef.current) {
|
|
254
|
-
const messageText = inputRef.current.innerText.trim();
|
|
255
|
-
const messageHTML = inputRef.current.innerHTML.trim();
|
|
256
|
-
|
|
257
|
-
if (messageText && onSendMessage) {
|
|
258
|
-
onSendMessage({
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
363
|
+
const messageText = inputRef.current.innerText.trim();
|
|
364
|
+
const messageHTML = inputRef.current.innerHTML.trim();
|
|
365
|
+
|
|
366
|
+
if ((messageText || selectedImage) && onSendMessage) {
|
|
367
|
+
onSendMessage({
|
|
368
|
+
messageText,
|
|
369
|
+
messageHTML,
|
|
370
|
+
userSelectListWithIds: userSelectListWithIdsRef.current,
|
|
371
|
+
userSelectListName: userSelectListRef.current,
|
|
372
|
+
images: selectedImage ? [selectedImage] : [],
|
|
373
|
+
imageUrl: imageUrl
|
|
374
|
+
});
|
|
375
|
+
setInputValue("");
|
|
376
|
+
setShowSuggestions(false);
|
|
377
|
+
inputRef.current.innerText = "";
|
|
378
|
+
setSelectedImage(null);
|
|
379
|
+
setImageUrl(null);
|
|
380
|
+
userSelectListRef.current = [];
|
|
381
|
+
userSelectListWithIdsRef.current = [];
|
|
264
382
|
}
|
|
265
383
|
}
|
|
266
384
|
};
|
|
@@ -272,28 +390,84 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
272
390
|
}
|
|
273
391
|
};
|
|
274
392
|
|
|
393
|
+
console.log(inputValue, inputRef.current?.innerText.trim(), "inputValue====")
|
|
275
394
|
|
|
276
395
|
return (
|
|
277
396
|
<div className={`mention-container ${containerClassName || ""}`}>
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
<
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
className=
|
|
287
|
-
|
|
288
|
-
|
|
397
|
+
{imageUrl && selectedImage && (
|
|
398
|
+
<div className="image-preview-card">
|
|
399
|
+
<img
|
|
400
|
+
src={imageUrl}
|
|
401
|
+
alt="Preview"
|
|
402
|
+
className={imgClassName || ""}
|
|
403
|
+
style={imgStyle}
|
|
404
|
+
/>
|
|
405
|
+
<button onClick={removeImage} className="remove-image-btn" aria-label="Remove image">
|
|
406
|
+
×
|
|
407
|
+
</button>
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
|
|
411
|
+
<div
|
|
412
|
+
className={`mention-input-container ${inputContainerClassName || ""} ${isDraggingOver ? 'dragging-over' : ''}`}
|
|
413
|
+
onDragOver={handleDragOver}
|
|
414
|
+
onDragLeave={handleDragLeave}
|
|
415
|
+
onDragEnd={() => setIsDraggingOver(false)}
|
|
416
|
+
onDrop={handleDrop}
|
|
417
|
+
>
|
|
418
|
+
{isDraggingOver && (
|
|
419
|
+
<div className="drag-overlay">
|
|
420
|
+
<div className="drag-message">
|
|
421
|
+
<span>Drop to upload</span>
|
|
422
|
+
</div>
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
|
|
426
|
+
<button
|
|
427
|
+
onClick={() => fileInputRef.current?.click()}
|
|
428
|
+
className="attachment-button"
|
|
429
|
+
type="button"
|
|
430
|
+
aria-label="Attach image"
|
|
289
431
|
>
|
|
432
|
+
<span className="attachment-icon">{attachmentButtonIcon || "📷"}</span>
|
|
433
|
+
</button>
|
|
434
|
+
|
|
435
|
+
<div className="mention-input-wrapper">
|
|
436
|
+
{(!inputValue || !inputRef.current || inputRef.current?.innerText.trim() === "") && (
|
|
437
|
+
<span className="placeholder">{placeholder}</span>
|
|
438
|
+
)}
|
|
439
|
+
<div
|
|
440
|
+
ref={inputRef}
|
|
441
|
+
contentEditable
|
|
442
|
+
suppressContentEditableWarning
|
|
443
|
+
className={`mention-input ${inputClassName || ""}`}
|
|
444
|
+
onInput={handleInputChange}
|
|
445
|
+
onKeyDown={handleKeyDown}
|
|
446
|
+
onFocus={() => document.execCommand('styleWithCSS', false, 'false')}
|
|
447
|
+
/>
|
|
290
448
|
</div>
|
|
449
|
+
|
|
291
450
|
<button
|
|
292
451
|
onClick={handleSendMessage}
|
|
293
452
|
className={`send-button ${sendBtnClassName || ""}`}
|
|
453
|
+
aria-label="Send message"
|
|
294
454
|
>
|
|
295
455
|
{sendButtonIcon || "➤"}
|
|
296
456
|
</button>
|
|
457
|
+
|
|
458
|
+
<input
|
|
459
|
+
type="file"
|
|
460
|
+
ref={fileInputRef}
|
|
461
|
+
accept="image/*"
|
|
462
|
+
onChange={handleImageSelect}
|
|
463
|
+
style={{ display: 'none' }}
|
|
464
|
+
/>
|
|
465
|
+
|
|
466
|
+
{isUploading && (
|
|
467
|
+
<div className="upload-loading">
|
|
468
|
+
<span>Uploading...</span>
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
297
471
|
</div>
|
|
298
472
|
{renderSuggestions()}
|
|
299
473
|
</div>
|