react-mention-input 1.1.29 → 1.1.31
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/CHANGELOG.md +23 -0
- package/dist/MentionInput.js +28 -1
- package/dist/ShowMessageCard.js +4 -4
- package/dist/useProtectedImage.d.ts +1 -0
- package/dist/useProtectedImage.js +61 -43
- package/package.json +2 -2
- package/src/MentionInput.tsx +72 -37
- package/src/ShowMessageCard.tsx +5 -5
- package/src/useProtectedImage.ts +63 -39
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [1.1.31] - 2026-02-02
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Font restriction enforcement**: Added paste event handler to strip all formatting (color, font-size, font-weight, font-family) from pasted content
|
|
12
|
+
- Pasted text now uses the component's standard CSS styling instead of preserving source formatting
|
|
13
|
+
- Prevents visual inconsistencies when users paste content from Word, websites, emails, or other formatted sources
|
|
14
|
+
|
|
15
|
+
### Technical Details
|
|
16
|
+
- Implemented `handlePaste` function using standard Clipboard API (`clipboardData.getData('text/plain')`)
|
|
17
|
+
- Preserves newlines, special characters, and cursor position
|
|
18
|
+
- Maintains full backward compatibility with existing features (mentions, links, images, keyboard shortcuts)
|
|
19
|
+
- Uses W3C standard DOM APIs (Selection and Range)
|
|
20
|
+
|
|
21
|
+
## [1.1.30] - Previous Release
|
|
22
|
+
|
|
23
|
+
(Previous versions not documented)
|
package/dist/MentionInput.js
CHANGED
|
@@ -444,6 +444,33 @@ var MentionInput = function (_a) {
|
|
|
444
444
|
}
|
|
445
445
|
}
|
|
446
446
|
};
|
|
447
|
+
var handlePaste = function (event) {
|
|
448
|
+
// Prevent default paste behavior to control formatting
|
|
449
|
+
event.preventDefault();
|
|
450
|
+
// Get plain text from clipboard (strips all HTML/formatting)
|
|
451
|
+
var text = event.clipboardData.getData('text/plain');
|
|
452
|
+
// If no text, do nothing
|
|
453
|
+
if (!text)
|
|
454
|
+
return;
|
|
455
|
+
// Get current selection
|
|
456
|
+
var selection = window.getSelection();
|
|
457
|
+
if (!selection || !selection.rangeCount)
|
|
458
|
+
return;
|
|
459
|
+
// Get the range where text will be inserted
|
|
460
|
+
var range = selection.getRangeAt(0);
|
|
461
|
+
// Delete any selected content first
|
|
462
|
+
range.deleteContents();
|
|
463
|
+
// Create a text node with the plain text (preserves newlines)
|
|
464
|
+
var textNode = document.createTextNode(text);
|
|
465
|
+
range.insertNode(textNode);
|
|
466
|
+
// Move cursor to end of inserted text
|
|
467
|
+
range.setStartAfter(textNode);
|
|
468
|
+
range.setEndAfter(textNode);
|
|
469
|
+
selection.removeAllRanges();
|
|
470
|
+
selection.addRange(range);
|
|
471
|
+
// Trigger input change to update state and apply mention/link highlighting
|
|
472
|
+
handleInputChange();
|
|
473
|
+
};
|
|
447
474
|
var handleKeyDown = function (event) {
|
|
448
475
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
449
476
|
event.preventDefault(); // Prevent newline in content-editable
|
|
@@ -475,7 +502,7 @@ var MentionInput = function (_a) {
|
|
|
475
502
|
React.createElement("span", { className: "attachment-icon" }, attachmentButtonIcon || "📷")),
|
|
476
503
|
React.createElement("div", { className: "mention-input-wrapper" },
|
|
477
504
|
(!inputValue || !inputRef.current || ((_b = inputRef.current) === null || _b === void 0 ? void 0 : _b.innerText.trim()) === "") && (React.createElement("span", { className: "placeholder" }, placeholder)),
|
|
478
|
-
React.createElement("div", { ref: inputRef, contentEditable: true, suppressContentEditableWarning: true, className: "mention-input ".concat(inputClassName || ""), onInput: handleInputChange, onKeyDown: handleKeyDown, onFocus: function () { return document.execCommand('styleWithCSS', false, 'false'); } })),
|
|
505
|
+
React.createElement("div", { ref: inputRef, contentEditable: true, suppressContentEditableWarning: true, className: "mention-input ".concat(inputClassName || ""), onInput: handleInputChange, onKeyDown: handleKeyDown, onPaste: handlePaste, onFocus: function () { return document.execCommand('styleWithCSS', false, 'false'); } })),
|
|
479
506
|
React.createElement("button", { onClick: handleSendMessage, className: "send-button ".concat(sendBtnClassName || ""), "aria-label": "Send message" }, sendButtonIcon || "➤"),
|
|
480
507
|
React.createElement("input", { type: "file", ref: fileInputRef, accept: "image/*", onChange: handleImageSelect, style: { display: 'none' } }),
|
|
481
508
|
isUploading && (React.createElement("div", { className: "upload-loading" },
|
package/dist/ShowMessageCard.js
CHANGED
|
@@ -18,7 +18,7 @@ var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) {
|
|
|
18
18
|
}
|
|
19
19
|
return to.concat(ar || Array.prototype.slice.call(from));
|
|
20
20
|
};
|
|
21
|
-
import React, { useState } from "react";
|
|
21
|
+
import React, { useState, memo } from "react";
|
|
22
22
|
import "./ShowMessageCard.css";
|
|
23
23
|
import { useProtectedImage } from "./useProtectedImage";
|
|
24
24
|
export var ShowMessageCard = function (_a) {
|
|
@@ -50,8 +50,8 @@ export var ShowMessageCard = function (_a) {
|
|
|
50
50
|
.join("");
|
|
51
51
|
return initials;
|
|
52
52
|
};
|
|
53
|
-
// Component to render protected images
|
|
54
|
-
var ProtectedImage = function (_a) {
|
|
53
|
+
// Component to render protected images - memoized to prevent recreation on every render
|
|
54
|
+
var ProtectedImage = memo(function (_a) {
|
|
55
55
|
var url = _a.url, alt = _a.alt, className = _a.className, style = _a.style, containerClassName = _a.containerClassName, containerStyle = _a.containerStyle, onError = _a.onError, _b = _a.renderInContainer, renderInContainer = _b === void 0 ? true : _b;
|
|
56
56
|
var displayUrl = useProtectedImage({
|
|
57
57
|
url: url,
|
|
@@ -66,7 +66,7 @@ export var ShowMessageCard = function (_a) {
|
|
|
66
66
|
return (React.createElement("div", { className: containerClassName, style: containerStyle }, imgElement));
|
|
67
67
|
}
|
|
68
68
|
return imgElement;
|
|
69
|
-
};
|
|
69
|
+
});
|
|
70
70
|
// Helper function to extract hashtags and mentions from text
|
|
71
71
|
var extractTagsAndMentions = function (text) {
|
|
72
72
|
// First extract from HTML with spans
|
|
@@ -7,6 +7,7 @@ interface UseProtectedImageOptions {
|
|
|
7
7
|
* Custom hook to handle protected image URLs that require authentication tokens in headers.
|
|
8
8
|
* For protected URLs, it fetches the image with auth headers and converts it to a blob URL.
|
|
9
9
|
* For non-protected URLs, it returns the URL as-is.
|
|
10
|
+
* Uses a module-level cache to prevent blinking on re-renders.
|
|
10
11
|
*/
|
|
11
12
|
export declare const useProtectedImage: ({ url, isProtected, getAuthHeaders, }: UseProtectedImageOptions) => string | null;
|
|
12
13
|
export {};
|
|
@@ -46,40 +46,49 @@ var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
|
46
46
|
}
|
|
47
47
|
};
|
|
48
48
|
import { useState, useEffect, useRef } from 'react';
|
|
49
|
+
// Module-level cache to persist blob URLs across component re-renders
|
|
50
|
+
var blobUrlCache = new Map();
|
|
51
|
+
var fetchingUrls = new Set();
|
|
49
52
|
/**
|
|
50
53
|
* Custom hook to handle protected image URLs that require authentication tokens in headers.
|
|
51
54
|
* For protected URLs, it fetches the image with auth headers and converts it to a blob URL.
|
|
52
55
|
* For non-protected URLs, it returns the URL as-is.
|
|
56
|
+
* Uses a module-level cache to prevent blinking on re-renders.
|
|
53
57
|
*/
|
|
54
58
|
export var useProtectedImage = function (_a) {
|
|
55
59
|
var url = _a.url, isProtected = _a.isProtected, getAuthHeaders = _a.getAuthHeaders;
|
|
56
|
-
var _b = useState(
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
var _b = useState(function () {
|
|
61
|
+
// Initialize from cache if available
|
|
62
|
+
return url ? blobUrlCache.get(url) || null : null;
|
|
63
|
+
}), blobUrl = _b[0], setBlobUrl = _b[1];
|
|
59
64
|
var previousUrlRef = useRef(null);
|
|
60
65
|
var isProtectedRef = useRef(isProtected);
|
|
61
66
|
var getAuthHeadersRef = useRef(getAuthHeaders);
|
|
67
|
+
var mountedRef = useRef(true);
|
|
62
68
|
// Update refs when props change (but don't trigger re-fetch)
|
|
63
69
|
useEffect(function () {
|
|
64
70
|
isProtectedRef.current = isProtected;
|
|
65
71
|
getAuthHeadersRef.current = getAuthHeaders;
|
|
66
72
|
}, [isProtected, getAuthHeaders]);
|
|
67
73
|
useEffect(function () {
|
|
68
|
-
|
|
74
|
+
mountedRef.current = true;
|
|
75
|
+
return function () {
|
|
76
|
+
mountedRef.current = false;
|
|
77
|
+
};
|
|
78
|
+
}, []);
|
|
79
|
+
useEffect(function () {
|
|
80
|
+
// Always check cache first and restore if needed (synchronous)
|
|
81
|
+
if (url && blobUrlCache.has(url) && !blobUrl) {
|
|
82
|
+
var cachedBlobUrl = blobUrlCache.get(url);
|
|
83
|
+
setBlobUrl(cachedBlobUrl);
|
|
84
|
+
}
|
|
85
|
+
// If URL hasn't changed, keep using cached blob URL
|
|
69
86
|
if (url === previousUrlRef.current) {
|
|
70
87
|
return;
|
|
71
88
|
}
|
|
72
|
-
// Store previous URL and blob URL for later cleanup
|
|
73
89
|
var oldUrl = previousUrlRef.current;
|
|
74
|
-
var oldBlobUrl = blobUrlRef.current;
|
|
75
90
|
previousUrlRef.current = url;
|
|
76
91
|
if (!url) {
|
|
77
|
-
// If URL is null, clean up old blob URL and clear state
|
|
78
|
-
if (oldBlobUrl) {
|
|
79
|
-
URL.revokeObjectURL(oldBlobUrl);
|
|
80
|
-
blobUrlRef.current = null;
|
|
81
|
-
blobUrlForUrlRef.current = null;
|
|
82
|
-
}
|
|
83
92
|
setBlobUrl(null);
|
|
84
93
|
return;
|
|
85
94
|
}
|
|
@@ -87,23 +96,35 @@ export var useProtectedImage = function (_a) {
|
|
|
87
96
|
var shouldUseAuth = typeof isProtectedRef.current === 'boolean'
|
|
88
97
|
? isProtectedRef.current
|
|
89
98
|
: isProtectedRef.current ? isProtectedRef.current(url) : false;
|
|
90
|
-
// If not protected,
|
|
99
|
+
// If not protected, use original URL
|
|
91
100
|
if (!shouldUseAuth || !getAuthHeadersRef.current) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
// Clear any cached blob URL for this URL
|
|
102
|
+
if (blobUrlCache.has(url)) {
|
|
103
|
+
var cachedBlobUrl = blobUrlCache.get(url);
|
|
104
|
+
URL.revokeObjectURL(cachedBlobUrl);
|
|
105
|
+
blobUrlCache.delete(url);
|
|
96
106
|
}
|
|
97
107
|
setBlobUrl(null);
|
|
98
108
|
return;
|
|
99
109
|
}
|
|
110
|
+
// Check cache first - if we have a cached blob URL, use it immediately
|
|
111
|
+
if (blobUrlCache.has(url)) {
|
|
112
|
+
var cachedBlobUrl = blobUrlCache.get(url);
|
|
113
|
+
setBlobUrl(cachedBlobUrl);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// If already fetching this URL, don't fetch again
|
|
117
|
+
if (fetchingUrls.has(url)) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
100
120
|
// Fetch protected image with auth headers
|
|
121
|
+
fetchingUrls.add(url);
|
|
101
122
|
var fetchProtectedImage = function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
102
123
|
var headers, response, blob, newBlobUrl, error_1;
|
|
103
124
|
return __generator(this, function (_a) {
|
|
104
125
|
switch (_a.label) {
|
|
105
126
|
case 0:
|
|
106
|
-
_a.trys.push([0, 4, ,
|
|
127
|
+
_a.trys.push([0, 4, 5, 6]);
|
|
107
128
|
return [4 /*yield*/, Promise.resolve(getAuthHeadersRef.current())];
|
|
108
129
|
case 1:
|
|
109
130
|
headers = _a.sent();
|
|
@@ -120,44 +141,36 @@ export var useProtectedImage = function (_a) {
|
|
|
120
141
|
case 3:
|
|
121
142
|
blob = _a.sent();
|
|
122
143
|
newBlobUrl = URL.createObjectURL(blob);
|
|
123
|
-
// Only update if URL hasn't changed during fetch
|
|
124
|
-
if (previousUrlRef.current === url) {
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
URL.revokeObjectURL(oldBlobUrl);
|
|
128
|
-
}
|
|
129
|
-
blobUrlRef.current = newBlobUrl;
|
|
130
|
-
blobUrlForUrlRef.current = url;
|
|
144
|
+
// Only update if URL hasn't changed during fetch and component is still mounted
|
|
145
|
+
if (previousUrlRef.current === url && mountedRef.current) {
|
|
146
|
+
// Cache the blob URL
|
|
147
|
+
blobUrlCache.set(url, newBlobUrl);
|
|
131
148
|
setBlobUrl(newBlobUrl);
|
|
132
149
|
}
|
|
133
150
|
else {
|
|
134
|
-
// URL changed during fetch, revoke this blob URL
|
|
151
|
+
// URL changed during fetch or component unmounted, revoke this blob URL
|
|
135
152
|
URL.revokeObjectURL(newBlobUrl);
|
|
136
153
|
}
|
|
137
|
-
return [3 /*break*/,
|
|
154
|
+
return [3 /*break*/, 6];
|
|
138
155
|
case 4:
|
|
139
156
|
error_1 = _a.sent();
|
|
140
157
|
console.error('Error fetching protected image:', error_1);
|
|
141
|
-
// On error, only clear if URL hasn't changed
|
|
142
|
-
if (previousUrlRef.current === url) {
|
|
158
|
+
// On error, only clear if URL hasn't changed and component is mounted
|
|
159
|
+
if (previousUrlRef.current === url && mountedRef.current) {
|
|
143
160
|
setBlobUrl(null);
|
|
144
161
|
}
|
|
145
|
-
return [3 /*break*/,
|
|
146
|
-
case 5:
|
|
162
|
+
return [3 /*break*/, 6];
|
|
163
|
+
case 5:
|
|
164
|
+
fetchingUrls.delete(url);
|
|
165
|
+
return [7 /*endfinally*/];
|
|
166
|
+
case 6: return [2 /*return*/];
|
|
147
167
|
}
|
|
148
168
|
});
|
|
149
169
|
}); };
|
|
150
170
|
fetchProtectedImage();
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (blobUrlRef.current) {
|
|
155
|
-
URL.revokeObjectURL(blobUrlRef.current);
|
|
156
|
-
blobUrlRef.current = null;
|
|
157
|
-
blobUrlForUrlRef.current = null;
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
}, [url]); // Only depend on url, not isProtected or getAuthHeaders
|
|
171
|
+
}, [url]); // Only depend on url
|
|
172
|
+
// Cleanup: Don't revoke blob URLs on unmount - keep them in cache for reuse
|
|
173
|
+
// They will be cleaned up when the URL changes or when the cache is cleared
|
|
161
174
|
// Return blob URL if available, otherwise return original URL (or null)
|
|
162
175
|
if (!url) {
|
|
163
176
|
return null;
|
|
@@ -165,5 +178,10 @@ export var useProtectedImage = function (_a) {
|
|
|
165
178
|
var isUrlProtected = typeof isProtectedRef.current === 'boolean'
|
|
166
179
|
? isProtectedRef.current
|
|
167
180
|
: isProtectedRef.current ? isProtectedRef.current(url) : false;
|
|
168
|
-
return
|
|
181
|
+
// For protected URLs, return cached blob URL or current blobUrl state
|
|
182
|
+
// For non-protected URLs, return the original URL
|
|
183
|
+
if (isUrlProtected) {
|
|
184
|
+
return blobUrl || blobUrlCache.get(url) || null;
|
|
185
|
+
}
|
|
186
|
+
return url;
|
|
169
187
|
};
|
package/package.json
CHANGED
package/src/MentionInput.tsx
CHANGED
|
@@ -65,14 +65,14 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
65
65
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
|
66
66
|
const [isUploading, setIsUploading] = useState<boolean>(false);
|
|
67
67
|
const [isDraggingOver, setIsDraggingOver] = useState<boolean>(false);
|
|
68
|
-
|
|
68
|
+
|
|
69
69
|
// Use protected image hook to handle authenticated image URLs
|
|
70
70
|
const displayImageUrl = useProtectedImage({
|
|
71
71
|
url: imageUrl,
|
|
72
72
|
isProtected: isProtectedUrl,
|
|
73
73
|
getAuthHeaders,
|
|
74
74
|
});
|
|
75
|
-
|
|
75
|
+
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
const inputRef = useRef<HTMLDivElement>(null);
|
|
@@ -85,13 +85,13 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
85
85
|
const highlightMentionsAndLinks = (text: string): string => {
|
|
86
86
|
// Regular expression for detecting links
|
|
87
87
|
const linkRegex = /(https?:\/\/[^\s]+)/g;
|
|
88
|
-
|
|
88
|
+
|
|
89
89
|
// Highlight links
|
|
90
90
|
let highlightedText = text.replace(
|
|
91
91
|
linkRegex,
|
|
92
92
|
'<a href="$1" target="_blank" rel="noopener noreferrer" class="link-highlight">$1</a>'
|
|
93
93
|
);
|
|
94
|
-
|
|
94
|
+
|
|
95
95
|
// Highlight mentions manually based on `userSelectListRef`
|
|
96
96
|
userSelectListRef?.current.forEach((userName) => {
|
|
97
97
|
const mentionPattern = new RegExp(`@${userName}(\\s|$)`, "g");
|
|
@@ -102,7 +102,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
102
102
|
}
|
|
103
103
|
);
|
|
104
104
|
});
|
|
105
|
-
|
|
105
|
+
|
|
106
106
|
return highlightedText;
|
|
107
107
|
};
|
|
108
108
|
|
|
@@ -128,7 +128,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
128
128
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
129
129
|
};
|
|
130
130
|
}, [showSuggestions]);
|
|
131
|
-
|
|
131
|
+
|
|
132
132
|
|
|
133
133
|
const restoreCaretPosition = (node: HTMLElement, caretOffset: number) => {
|
|
134
134
|
const range = document.createRange();
|
|
@@ -268,29 +268,29 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
268
268
|
const newCaretOffset = getCurrentCaretOffset();
|
|
269
269
|
|
|
270
270
|
caretOffsetRef.current = newCaretOffset;
|
|
271
|
-
|
|
271
|
+
|
|
272
272
|
const plainText = inputRef.current.innerText;
|
|
273
273
|
setInputValue(plainText);
|
|
274
|
-
|
|
274
|
+
|
|
275
275
|
// Process for mention suggestions
|
|
276
276
|
const mentionMatch = plainText.slice(0, newCaretOffset).match(/@(\S*)$/);
|
|
277
277
|
if (mentionMatch) {
|
|
278
278
|
const query = mentionMatch[1].toLowerCase();
|
|
279
|
-
const filteredUsers = query === "" ? users : users.filter((user) =>
|
|
279
|
+
const filteredUsers = query === "" ? users : users.filter((user) =>
|
|
280
280
|
user.name.toLowerCase().includes(query)
|
|
281
281
|
);
|
|
282
|
-
|
|
282
|
+
|
|
283
283
|
setSuggestions(filteredUsers);
|
|
284
284
|
setShowSuggestions(filteredUsers.length > 0);
|
|
285
285
|
} else {
|
|
286
286
|
setShowSuggestions(false);
|
|
287
287
|
}
|
|
288
|
-
|
|
288
|
+
|
|
289
289
|
// Only apply highlighting if we have mentions or links to highlight
|
|
290
290
|
if (userSelectListRef.current.length > 0 || plainText.match(/(https?:\/\/[^\s]+)/g)) {
|
|
291
291
|
const currentHTML = inputRef.current.innerHTML;
|
|
292
292
|
const htmlWithHighlights = highlightMentionsAndLinks(plainText);
|
|
293
|
-
|
|
293
|
+
|
|
294
294
|
// Only update if the highlighted HTML is different to avoid cursor jumping
|
|
295
295
|
if (currentHTML !== htmlWithHighlights) {
|
|
296
296
|
inputRef.current.innerHTML = htmlWithHighlights;
|
|
@@ -299,7 +299,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
299
299
|
}
|
|
300
300
|
}
|
|
301
301
|
};
|
|
302
|
-
|
|
302
|
+
|
|
303
303
|
|
|
304
304
|
const renderSuggestions = () => {
|
|
305
305
|
if (!showSuggestions || !inputRef.current) return null;
|
|
@@ -312,7 +312,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
312
312
|
.join("");
|
|
313
313
|
return initials;
|
|
314
314
|
};
|
|
315
|
-
|
|
315
|
+
|
|
316
316
|
const inputRect = inputRef.current.getBoundingClientRect();
|
|
317
317
|
const scrollLeft =
|
|
318
318
|
window.scrollX ?? window.pageXOffset ?? document.documentElement.scrollLeft ?? 0;
|
|
@@ -349,7 +349,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
return ReactDOM.createPortal(
|
|
352
|
-
<div
|
|
352
|
+
<div
|
|
353
353
|
className={`suggestion-container ${suggestionListClassName || ''}`}
|
|
354
354
|
style={styles}
|
|
355
355
|
>
|
|
@@ -378,15 +378,15 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
378
378
|
|
|
379
379
|
const handleSuggestionClick = (user: User) => {
|
|
380
380
|
if (!inputRef.current) return;
|
|
381
|
-
|
|
381
|
+
|
|
382
382
|
const plainText = inputValue;
|
|
383
383
|
const caretOffset = caretOffsetRef.current;
|
|
384
384
|
const mentionMatch = plainText.slice(0, caretOffset).match(/@(\S*)$/);
|
|
385
|
-
|
|
385
|
+
|
|
386
386
|
if (!userSelectListRef.current.includes(user.name)) {
|
|
387
387
|
userSelectListRef.current.push(user.name);
|
|
388
388
|
}
|
|
389
|
-
|
|
389
|
+
|
|
390
390
|
// Check if the ID is already stored
|
|
391
391
|
const isIdExists = userSelectListWithIdsRef.current.some(
|
|
392
392
|
(item) => item.id === user.id
|
|
@@ -394,26 +394,26 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
394
394
|
if (!isIdExists) {
|
|
395
395
|
userSelectListWithIdsRef.current.push(user);
|
|
396
396
|
}
|
|
397
|
-
|
|
397
|
+
|
|
398
398
|
if (!mentionMatch) return;
|
|
399
|
-
|
|
399
|
+
|
|
400
400
|
const mentionIndex = plainText.slice(0, caretOffset).lastIndexOf("@");
|
|
401
|
-
|
|
401
|
+
|
|
402
402
|
// Append space after the mention
|
|
403
403
|
const newValue =
|
|
404
404
|
plainText.substring(0, mentionIndex + 1) + user.name + " " + plainText.substring(caretOffset);
|
|
405
|
-
|
|
405
|
+
|
|
406
406
|
setInputValue(newValue);
|
|
407
407
|
inputRef.current.innerText = newValue;
|
|
408
|
-
|
|
408
|
+
|
|
409
409
|
// Highlight mentions and links with
|
|
410
410
|
const htmlWithHighlights = highlightMentionsAndLinks(newValue);
|
|
411
|
-
|
|
411
|
+
|
|
412
412
|
// Set highlighted content
|
|
413
413
|
inputRef.current.innerHTML = htmlWithHighlights;
|
|
414
|
-
|
|
414
|
+
|
|
415
415
|
setShowSuggestions(false);
|
|
416
|
-
|
|
416
|
+
|
|
417
417
|
// Adjust caret position after adding the mention and space
|
|
418
418
|
const mentionEnd = mentionIndex + user.name.length + 1;
|
|
419
419
|
restoreCaretPosition(inputRef.current, mentionEnd + 1); // +1 for the space
|
|
@@ -449,7 +449,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
449
449
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
450
450
|
const x = e.clientX;
|
|
451
451
|
const y = e.clientY;
|
|
452
|
-
|
|
452
|
+
|
|
453
453
|
if (
|
|
454
454
|
x <= rect.left ||
|
|
455
455
|
x >= rect.right ||
|
|
@@ -532,6 +532,40 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
532
532
|
}
|
|
533
533
|
};
|
|
534
534
|
|
|
535
|
+
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
|
|
536
|
+
// Prevent default paste behavior to control formatting
|
|
537
|
+
event.preventDefault();
|
|
538
|
+
|
|
539
|
+
// Get plain text from clipboard (strips all HTML/formatting)
|
|
540
|
+
const text = event.clipboardData.getData('text/plain');
|
|
541
|
+
|
|
542
|
+
// If no text, do nothing
|
|
543
|
+
if (!text) return;
|
|
544
|
+
|
|
545
|
+
// Get current selection
|
|
546
|
+
const selection = window.getSelection();
|
|
547
|
+
if (!selection || !selection.rangeCount) return;
|
|
548
|
+
|
|
549
|
+
// Get the range where text will be inserted
|
|
550
|
+
const range = selection.getRangeAt(0);
|
|
551
|
+
|
|
552
|
+
// Delete any selected content first
|
|
553
|
+
range.deleteContents();
|
|
554
|
+
|
|
555
|
+
// Create a text node with the plain text (preserves newlines)
|
|
556
|
+
const textNode = document.createTextNode(text);
|
|
557
|
+
range.insertNode(textNode);
|
|
558
|
+
|
|
559
|
+
// Move cursor to end of inserted text
|
|
560
|
+
range.setStartAfter(textNode);
|
|
561
|
+
range.setEndAfter(textNode);
|
|
562
|
+
selection.removeAllRanges();
|
|
563
|
+
selection.addRange(range);
|
|
564
|
+
|
|
565
|
+
// Trigger input change to update state and apply mention/link highlighting
|
|
566
|
+
handleInputChange();
|
|
567
|
+
};
|
|
568
|
+
|
|
535
569
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
536
570
|
if (event.key === "Enter" && !event.shiftKey) {
|
|
537
571
|
event.preventDefault(); // Prevent newline in content-editable
|
|
@@ -558,9 +592,9 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
558
592
|
<div className={`mention-container ${containerClassName || ""}`}>
|
|
559
593
|
{displayImageUrl && selectedImage && (
|
|
560
594
|
<div className={`image-preview-card ${attachedImageContainerClassName || ""}`} style={attachedImageContainerStyle}>
|
|
561
|
-
<img
|
|
562
|
-
src={displayImageUrl}
|
|
563
|
-
alt="Preview"
|
|
595
|
+
<img
|
|
596
|
+
src={displayImageUrl}
|
|
597
|
+
alt="Preview"
|
|
564
598
|
className={imgClassName || ""}
|
|
565
599
|
style={imgStyle}
|
|
566
600
|
/>
|
|
@@ -569,8 +603,8 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
569
603
|
</button>
|
|
570
604
|
</div>
|
|
571
605
|
)}
|
|
572
|
-
|
|
573
|
-
<div
|
|
606
|
+
|
|
607
|
+
<div
|
|
574
608
|
className={`mention-input-container ${inputContainerClassName || ""} ${isDraggingOver ? 'dragging-over' : ''}`}
|
|
575
609
|
onDragOver={handleDragOver}
|
|
576
610
|
onDragLeave={handleDragLeave}
|
|
@@ -584,7 +618,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
584
618
|
</div>
|
|
585
619
|
</div>
|
|
586
620
|
)}
|
|
587
|
-
|
|
621
|
+
|
|
588
622
|
<button
|
|
589
623
|
onClick={() => fileInputRef.current?.click()}
|
|
590
624
|
className="attachment-button"
|
|
@@ -593,7 +627,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
593
627
|
>
|
|
594
628
|
<span className="attachment-icon">{attachmentButtonIcon || "📷"}</span>
|
|
595
629
|
</button>
|
|
596
|
-
|
|
630
|
+
|
|
597
631
|
<div className="mention-input-wrapper">
|
|
598
632
|
{(!inputValue || !inputRef.current || inputRef.current?.innerText.trim() === "") && (
|
|
599
633
|
<span className="placeholder">{placeholder}</span>
|
|
@@ -605,10 +639,11 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
605
639
|
className={`mention-input ${inputClassName || ""}`}
|
|
606
640
|
onInput={handleInputChange}
|
|
607
641
|
onKeyDown={handleKeyDown}
|
|
642
|
+
onPaste={handlePaste}
|
|
608
643
|
onFocus={() => document.execCommand('styleWithCSS', false, 'false')}
|
|
609
644
|
/>
|
|
610
645
|
</div>
|
|
611
|
-
|
|
646
|
+
|
|
612
647
|
<button
|
|
613
648
|
onClick={handleSendMessage}
|
|
614
649
|
className={`send-button ${sendBtnClassName || ""}`}
|
|
@@ -616,7 +651,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
616
651
|
>
|
|
617
652
|
{sendButtonIcon || "➤"}
|
|
618
653
|
</button>
|
|
619
|
-
|
|
654
|
+
|
|
620
655
|
<input
|
|
621
656
|
type="file"
|
|
622
657
|
ref={fileInputRef}
|
|
@@ -624,7 +659,7 @@ const MentionInput: React.FC<MentionInputProps> = ({
|
|
|
624
659
|
onChange={handleImageSelect}
|
|
625
660
|
style={{ display: 'none' }}
|
|
626
661
|
/>
|
|
627
|
-
|
|
662
|
+
|
|
628
663
|
{isUploading && (
|
|
629
664
|
<div className="upload-loading">
|
|
630
665
|
<span>Uploading...</span>
|
package/src/ShowMessageCard.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { CSSProperties, useState, ReactNode } from "react";
|
|
1
|
+
import React, { CSSProperties, useState, ReactNode, useMemo, memo } from "react";
|
|
2
2
|
import "./ShowMessageCard.css";
|
|
3
3
|
import { useProtectedImage } from "./useProtectedImage";
|
|
4
4
|
|
|
@@ -112,8 +112,8 @@ export const ShowMessageCard: React.FC<ShowMessageCardProps> = ({
|
|
|
112
112
|
return initials;
|
|
113
113
|
};
|
|
114
114
|
|
|
115
|
-
// Component to render protected images
|
|
116
|
-
const ProtectedImage
|
|
115
|
+
// Component to render protected images - memoized to prevent recreation on every render
|
|
116
|
+
const ProtectedImage = memo<{
|
|
117
117
|
url: string;
|
|
118
118
|
alt: string;
|
|
119
119
|
className?: string;
|
|
@@ -122,7 +122,7 @@ export const ShowMessageCard: React.FC<ShowMessageCardProps> = ({
|
|
|
122
122
|
containerStyle?: CSSProperties;
|
|
123
123
|
onError?: () => void;
|
|
124
124
|
renderInContainer?: boolean;
|
|
125
|
-
}>
|
|
125
|
+
}>(({ url, alt, className, style, containerClassName, containerStyle, onError, renderInContainer = true }) => {
|
|
126
126
|
const displayUrl = useProtectedImage({
|
|
127
127
|
url,
|
|
128
128
|
isProtected: isProtectedUrl,
|
|
@@ -152,7 +152,7 @@ export const ShowMessageCard: React.FC<ShowMessageCardProps> = ({
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
return imgElement;
|
|
155
|
-
};
|
|
155
|
+
});
|
|
156
156
|
|
|
157
157
|
// Helper function to extract hashtags and mentions from text
|
|
158
158
|
const extractTagsAndMentions = (text: string) => {
|
package/src/useProtectedImage.ts
CHANGED
|
@@ -6,22 +6,29 @@ interface UseProtectedImageOptions {
|
|
|
6
6
|
getAuthHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// Module-level cache to persist blob URLs across component re-renders
|
|
10
|
+
const blobUrlCache = new Map<string, string>();
|
|
11
|
+
const fetchingUrls = new Set<string>();
|
|
12
|
+
|
|
9
13
|
/**
|
|
10
14
|
* Custom hook to handle protected image URLs that require authentication tokens in headers.
|
|
11
15
|
* For protected URLs, it fetches the image with auth headers and converts it to a blob URL.
|
|
12
16
|
* For non-protected URLs, it returns the URL as-is.
|
|
17
|
+
* Uses a module-level cache to prevent blinking on re-renders.
|
|
13
18
|
*/
|
|
14
19
|
export const useProtectedImage = ({
|
|
15
20
|
url,
|
|
16
21
|
isProtected,
|
|
17
22
|
getAuthHeaders,
|
|
18
23
|
}: UseProtectedImageOptions): string | null => {
|
|
19
|
-
const [blobUrl, setBlobUrl] = useState<string | null>(
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
const [blobUrl, setBlobUrl] = useState<string | null>(() => {
|
|
25
|
+
// Initialize from cache if available
|
|
26
|
+
return url ? blobUrlCache.get(url) || null : null;
|
|
27
|
+
});
|
|
22
28
|
const previousUrlRef = useRef<string | null>(null);
|
|
23
29
|
const isProtectedRef = useRef(isProtected);
|
|
24
30
|
const getAuthHeadersRef = useRef(getAuthHeaders);
|
|
31
|
+
const mountedRef = useRef(true);
|
|
25
32
|
|
|
26
33
|
// Update refs when props change (but don't trigger re-fetch)
|
|
27
34
|
useEffect(() => {
|
|
@@ -30,23 +37,28 @@ export const useProtectedImage = ({
|
|
|
30
37
|
}, [isProtected, getAuthHeaders]);
|
|
31
38
|
|
|
32
39
|
useEffect(() => {
|
|
33
|
-
|
|
40
|
+
mountedRef.current = true;
|
|
41
|
+
return () => {
|
|
42
|
+
mountedRef.current = false;
|
|
43
|
+
};
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
// Always check cache first and restore if needed (synchronous)
|
|
48
|
+
if (url && blobUrlCache.has(url) && !blobUrl) {
|
|
49
|
+
const cachedBlobUrl = blobUrlCache.get(url)!;
|
|
50
|
+
setBlobUrl(cachedBlobUrl);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// If URL hasn't changed, keep using cached blob URL
|
|
34
54
|
if (url === previousUrlRef.current) {
|
|
35
55
|
return;
|
|
36
56
|
}
|
|
37
57
|
|
|
38
|
-
// Store previous URL and blob URL for later cleanup
|
|
39
58
|
const oldUrl = previousUrlRef.current;
|
|
40
|
-
const oldBlobUrl = blobUrlRef.current;
|
|
41
59
|
previousUrlRef.current = url;
|
|
42
60
|
|
|
43
61
|
if (!url) {
|
|
44
|
-
// If URL is null, clean up old blob URL and clear state
|
|
45
|
-
if (oldBlobUrl) {
|
|
46
|
-
URL.revokeObjectURL(oldBlobUrl);
|
|
47
|
-
blobUrlRef.current = null;
|
|
48
|
-
blobUrlForUrlRef.current = null;
|
|
49
|
-
}
|
|
50
62
|
setBlobUrl(null);
|
|
51
63
|
return;
|
|
52
64
|
}
|
|
@@ -56,18 +68,32 @@ export const useProtectedImage = ({
|
|
|
56
68
|
? isProtectedRef.current
|
|
57
69
|
: isProtectedRef.current ? isProtectedRef.current(url) : false;
|
|
58
70
|
|
|
59
|
-
// If not protected,
|
|
71
|
+
// If not protected, use original URL
|
|
60
72
|
if (!shouldUseAuth || !getAuthHeadersRef.current) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
// Clear any cached blob URL for this URL
|
|
74
|
+
if (blobUrlCache.has(url)) {
|
|
75
|
+
const cachedBlobUrl = blobUrlCache.get(url)!;
|
|
76
|
+
URL.revokeObjectURL(cachedBlobUrl);
|
|
77
|
+
blobUrlCache.delete(url);
|
|
65
78
|
}
|
|
66
79
|
setBlobUrl(null);
|
|
67
80
|
return;
|
|
68
81
|
}
|
|
69
82
|
|
|
83
|
+
// Check cache first - if we have a cached blob URL, use it immediately
|
|
84
|
+
if (blobUrlCache.has(url)) {
|
|
85
|
+
const cachedBlobUrl = blobUrlCache.get(url)!;
|
|
86
|
+
setBlobUrl(cachedBlobUrl);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// If already fetching this URL, don't fetch again
|
|
91
|
+
if (fetchingUrls.has(url)) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
70
95
|
// Fetch protected image with auth headers
|
|
96
|
+
fetchingUrls.add(url);
|
|
71
97
|
const fetchProtectedImage = async () => {
|
|
72
98
|
try {
|
|
73
99
|
const headers = await Promise.resolve(getAuthHeadersRef.current!());
|
|
@@ -85,40 +111,31 @@ export const useProtectedImage = ({
|
|
|
85
111
|
const blob = await response.blob();
|
|
86
112
|
const newBlobUrl = URL.createObjectURL(blob);
|
|
87
113
|
|
|
88
|
-
// Only update if URL hasn't changed during fetch
|
|
89
|
-
if (previousUrlRef.current === url) {
|
|
90
|
-
//
|
|
91
|
-
|
|
92
|
-
URL.revokeObjectURL(oldBlobUrl);
|
|
93
|
-
}
|
|
94
|
-
blobUrlRef.current = newBlobUrl;
|
|
95
|
-
blobUrlForUrlRef.current = url;
|
|
114
|
+
// Only update if URL hasn't changed during fetch and component is still mounted
|
|
115
|
+
if (previousUrlRef.current === url && mountedRef.current) {
|
|
116
|
+
// Cache the blob URL
|
|
117
|
+
blobUrlCache.set(url, newBlobUrl);
|
|
96
118
|
setBlobUrl(newBlobUrl);
|
|
97
119
|
} else {
|
|
98
|
-
// URL changed during fetch, revoke this blob URL
|
|
120
|
+
// URL changed during fetch or component unmounted, revoke this blob URL
|
|
99
121
|
URL.revokeObjectURL(newBlobUrl);
|
|
100
122
|
}
|
|
101
123
|
} catch (error) {
|
|
102
124
|
console.error('Error fetching protected image:', error);
|
|
103
|
-
// On error, only clear if URL hasn't changed
|
|
104
|
-
if (previousUrlRef.current === url) {
|
|
125
|
+
// On error, only clear if URL hasn't changed and component is mounted
|
|
126
|
+
if (previousUrlRef.current === url && mountedRef.current) {
|
|
105
127
|
setBlobUrl(null);
|
|
106
128
|
}
|
|
129
|
+
} finally {
|
|
130
|
+
fetchingUrls.delete(url);
|
|
107
131
|
}
|
|
108
132
|
};
|
|
109
133
|
|
|
110
134
|
fetchProtectedImage();
|
|
135
|
+
}, [url]); // Only depend on url
|
|
111
136
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// On unmount, clean up the current blob URL
|
|
115
|
-
if (blobUrlRef.current) {
|
|
116
|
-
URL.revokeObjectURL(blobUrlRef.current);
|
|
117
|
-
blobUrlRef.current = null;
|
|
118
|
-
blobUrlForUrlRef.current = null;
|
|
119
|
-
}
|
|
120
|
-
};
|
|
121
|
-
}, [url]); // Only depend on url, not isProtected or getAuthHeaders
|
|
137
|
+
// Cleanup: Don't revoke blob URLs on unmount - keep them in cache for reuse
|
|
138
|
+
// They will be cleaned up when the URL changes or when the cache is cleared
|
|
122
139
|
|
|
123
140
|
// Return blob URL if available, otherwise return original URL (or null)
|
|
124
141
|
if (!url) {
|
|
@@ -128,6 +145,13 @@ export const useProtectedImage = ({
|
|
|
128
145
|
const isUrlProtected = typeof isProtectedRef.current === 'boolean'
|
|
129
146
|
? isProtectedRef.current
|
|
130
147
|
: isProtectedRef.current ? isProtectedRef.current(url) : false;
|
|
131
|
-
|
|
148
|
+
|
|
149
|
+
// For protected URLs, return cached blob URL or current blobUrl state
|
|
150
|
+
// For non-protected URLs, return the original URL
|
|
151
|
+
if (isUrlProtected) {
|
|
152
|
+
return blobUrl || blobUrlCache.get(url) || null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return url;
|
|
132
156
|
};
|
|
133
157
|
|