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