react-mention-input 1.1.10 → 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.
@@ -1,4 +1,4 @@
1
- import React, { useState, useRef, useEffect, ReactNode } from "react";
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:{messageText: string, messageHTML: string,userSelectListWithIds:{ id: number; name: string }[],userSelectListName:string[]}) => void;
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', // Default position is 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, trailingSpace) => {
78
+ (match) => {
65
79
  return `<span class="mention-highlight">${match.trim()}</span>&nbsp;`;
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) => user.name.toLowerCase().startsWith(query));
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
- const previousHTML = inputRef.current.innerHTML;
136
- const htmlWithHighlights = highlightMentionsAndLinks(plainText); // Updated function
137
- if (previousHTML !== htmlWithHighlights) {
138
- inputRef.current.innerHTML = htmlWithHighlights;
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() || "") // Take the first letter of each part
152
- .slice(0, 2) // Limit to 2 letters
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
- <ul
187
- className={`suggestion-list ${suggestionListClassName || ''}`}
188
- ref={suggestionListRef}
209
+ <div
210
+ className={`suggestion-container ${suggestionListClassName || ''}`}
189
211
  style={styles}
190
212
  >
191
- {suggestions.map((user) => (
192
- <li
193
- key={user.id}
194
- onClick={() => handleSuggestionClick(user)}
195
- className={`suggestion-item ${suggestionItemClassName || ''}`}
196
- role="option"
197
- tabIndex={0}
198
- aria-selected="false"
199
- >
200
- <div className="user-icon">{getInitials(user?.name)}</div>
201
- {user.name}
202
- </li>
203
- ))}
204
- </ul>,
205
- window.document.body // Render in portal
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(); // Plain text
255
- const messageHTML = inputRef.current.innerHTML.trim(); // HTML with <span> highlighting
256
-
257
- if (messageText && onSendMessage) {
258
- onSendMessage({messageText, messageHTML,userSelectListWithIds:userSelectListWithIdsRef.current,userSelectListName:userSelectListRef.current }); // Pass both plain text and HTML
259
- setInputValue(""); // Clear state
260
- setShowSuggestions(false); // Hide suggestions
261
- inputRef.current.innerText = ""; // Clear input field
262
- userSelectListRef.current = []
263
- userSelectListWithIdsRef.current = []
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
- <div className={`mention-input-container ${inputContainerClassName || ""}`}>
279
- {(!inputValue || !inputRef.current || inputRef.current?.innerText.trim() === "") ? (
280
- <span className="placeholder">{placeholder}</span>
281
- ) : null}
282
- <div
283
- ref={inputRef}
284
- contentEditable
285
- suppressContentEditableWarning
286
- className={`mention-input ${inputClassName || ""}`}
287
- onInput={handleInputChange}
288
- onKeyDown={handleKeyDown} // Add keydown listener
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>