UNPKG

react-mention-input

Version:

A React component for input with @mention functionality.

486 lines (485 loc) 26 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (g && (g = 0, op[0] && (_ = 0)), _) try { if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [op[0] & 2, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; } }; var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { if (ar || !(i in from)) { if (!ar) ar = Array.prototype.slice.call(from, 0, i); ar[i] = from[i]; } } return to.concat(ar || Array.prototype.slice.call(from)); }; import React, { useState, useRef, useEffect } from "react"; import ReactDOM from "react-dom"; import "./MentionInput.css"; import { useProtectedImage } from "./useProtectedImage"; var MentionInput = function (_a) { var _b; var users = _a.users, _c = _a.placeholder, placeholder = _c === void 0 ? "Type a message... (or drag & drop an image)" : _c, containerClassName = _a.containerClassName, inputContainerClassName = _a.inputContainerClassName, inputClassName = _a.inputClassName, sendBtnClassName = _a.sendBtnClassName, suggestionListClassName = _a.suggestionListClassName, suggestionItemClassName = _a.suggestionItemClassName, imgClassName = _a.imgClassName, imgStyle = _a.imgStyle, attachedImageContainerClassName = _a.attachedImageContainerClassName, attachedImageContainerStyle = _a.attachedImageContainerStyle, sendButtonIcon = _a.sendButtonIcon, attachmentButtonIcon = _a.attachmentButtonIcon, onSendMessage = _a.onSendMessage, _d = _a.suggestionPosition, suggestionPosition = _d === void 0 ? 'top' : _d, onImageUpload = _a.onImageUpload, getAuthHeaders = _a.getAuthHeaders, isProtectedUrl = _a.isProtectedUrl; var _e = useState(""), inputValue = _e[0], setInputValue = _e[1]; // Plain text var _f = useState([]), suggestions = _f[0], setSuggestions = _f[1]; var _g = useState(false), showSuggestions = _g[0], setShowSuggestions = _g[1]; var _h = useState(null), selectedImage = _h[0], setSelectedImage = _h[1]; var _j = useState(null), imageUrl = _j[0], setImageUrl = _j[1]; var _k = useState(false), isUploading = _k[0], setIsUploading = _k[1]; var _l = useState(false), isDraggingOver = _l[0], setIsDraggingOver = _l[1]; // Use protected image hook to handle authenticated image URLs var displayImageUrl = useProtectedImage({ url: imageUrl, isProtected: isProtectedUrl, getAuthHeaders: getAuthHeaders, }); var inputRef = useRef(null); var suggestionListRef = useRef(null); var caretOffsetRef = useRef(0); var userSelectListRef = useRef([]); // Only unique names var userSelectListWithIdsRef = useRef([]); // Unique IDs with names var fileInputRef = useRef(null); var highlightMentionsAndLinks = function (text) { // Regular expression for detecting links var linkRegex = /(https?:\/\/[^\s]+)/g; // Highlight links var highlightedText = text.replace(linkRegex, '<a href="$1" target="_blank" rel="noopener noreferrer" class="link-highlight">$1</a>'); // Highlight mentions manually based on `userSelectListRef` userSelectListRef === null || userSelectListRef === void 0 ? void 0 : userSelectListRef.current.forEach(function (userName) { var mentionPattern = new RegExp("@".concat(userName, "(\\s|$)"), "g"); highlightedText = highlightedText.replace(mentionPattern, function (match) { return "<span class=\"mention-highlight\">".concat(match.trim(), "</span>&nbsp;"); }); }); return highlightedText; }; useEffect(function () { var handleClickOutside = function (event) { var target = event.target; if (showSuggestions && inputRef.current && !inputRef.current.contains(target) && suggestionListRef.current && !suggestionListRef.current.contains(target)) { setShowSuggestions(false); } }; if (showSuggestions) { document.addEventListener("mousedown", handleClickOutside); } return function () { document.removeEventListener("mousedown", handleClickOutside); }; }, [showSuggestions]); var restoreCaretPosition = function (node, caretOffset) { var range = document.createRange(); var sel = window.getSelection(); var charCount = 0; var findCaret = function (currentNode) { var _a; for (var _i = 0, _b = Array.from(currentNode.childNodes); _i < _b.length; _i++) { var child = _b[_i]; if (child.nodeType === Node.TEXT_NODE) { var textLength = ((_a = child.textContent) === null || _a === void 0 ? void 0 : _a.length) || 0; if (charCount + textLength >= caretOffset) { range.setStart(child, caretOffset - charCount); range.collapse(true); return true; } else { charCount += textLength; } } else if (child.nodeType === Node.ELEMENT_NODE) { if (findCaret(child)) return true; } } return false; }; findCaret(node); if (sel) { sel.removeAllRanges(); sel.addRange(range); } }; var getCurrentCaretOffset = function () { if (!inputRef.current) return 0; var selection = window.getSelection(); var range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; if (range && inputRef.current.contains(range.startContainer)) { var preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(inputRef.current); preCaretRange.setEnd(range.startContainer, range.startOffset); return preCaretRange.toString().length; } return caretOffsetRef.current; }; var findMentionAtOffset = function (plainText, caretOffset, direction) { if (!userSelectListRef.current.length) return null; var names = __spreadArray([], userSelectListRef.current, true).sort(function (a, b) { return b.length - a.length; }); for (var _i = 0, names_1 = names; _i < names_1.length; _i++) { var name_1 = names_1[_i]; var pattern = "@".concat(name_1); var searchIndex = plainText.indexOf(pattern); while (searchIndex !== -1) { var endIndex = searchIndex + pattern.length; if (plainText[endIndex] === " " || plainText[endIndex] === "\u00a0") { endIndex += 1; } var isCaretInsideMention = direction === "backward" ? caretOffset > searchIndex && caretOffset <= endIndex : caretOffset >= searchIndex && caretOffset < endIndex; if (isCaretInsideMention) { return { name: name_1, start: searchIndex, end: endIndex }; } searchIndex = plainText.indexOf(pattern, searchIndex + 1); } } return null; }; var removeMentionToken = function (direction) { if (!inputRef.current) return false; var plainText = inputRef.current.innerText; var caretOffset = getCurrentCaretOffset(); var mentionInfo = findMentionAtOffset(plainText, caretOffset, direction); if (!mentionInfo) return false; var newText = plainText.slice(0, mentionInfo.start) + plainText.slice(mentionInfo.end); var hasLinks = !!newText.match(/(https?:\/\/[^\s]+)/g); if (userSelectListRef.current.length > 0 || hasLinks) { var htmlWithHighlights = highlightMentionsAndLinks(newText); inputRef.current.innerHTML = htmlWithHighlights; } else { inputRef.current.innerText = newText; } setInputValue(newText); setShowSuggestions(false); var newCaretOffset = mentionInfo.start; caretOffsetRef.current = newCaretOffset; if (inputRef.current) { restoreCaretPosition(inputRef.current, newCaretOffset); } if (!newText.includes("@".concat(mentionInfo.name))) { userSelectListRef.current = userSelectListRef.current.filter(function (storedName) { return storedName !== mentionInfo.name; }); userSelectListWithIdsRef.current = userSelectListWithIdsRef.current.filter(function (user) { return user.name !== mentionInfo.name; }); } return true; }; var handleInputChange = function () { if (!inputRef.current) return; // Store current selection before modifications var newCaretOffset = getCurrentCaretOffset(); caretOffsetRef.current = newCaretOffset; var plainText = inputRef.current.innerText; setInputValue(plainText); // Process for mention suggestions var mentionMatch = plainText.slice(0, newCaretOffset).match(/@(\S*)$/); if (mentionMatch) { var query_1 = mentionMatch[1].toLowerCase(); var filteredUsers = query_1 === "" ? users : users.filter(function (user) { return user.name.toLowerCase().includes(query_1); }); setSuggestions(filteredUsers); setShowSuggestions(filteredUsers.length > 0); } else { setShowSuggestions(false); } // Only apply highlighting if we have mentions or links to highlight if (userSelectListRef.current.length > 0 || plainText.match(/(https?:\/\/[^\s]+)/g)) { var currentHTML = inputRef.current.innerHTML; var htmlWithHighlights = highlightMentionsAndLinks(plainText); // Only update if the highlighted HTML is different to avoid cursor jumping if (currentHTML !== htmlWithHighlights) { inputRef.current.innerHTML = htmlWithHighlights; // Restore cursor position after changing innerHTML restoreCaretPosition(inputRef.current, newCaretOffset); } } }; var renderSuggestions = function () { var _a, _b, _c, _d, _e, _f; if (!showSuggestions || !inputRef.current) return null; var getInitials = function (name) { var nameParts = name.split(" "); var initials = nameParts .map(function (part) { var _a; return ((_a = part[0]) === null || _a === void 0 ? void 0 : _a.toUpperCase()) || ""; }) .slice(0, 2) .join(""); return initials; }; var inputRect = inputRef.current.getBoundingClientRect(); var scrollLeft = (_c = (_b = (_a = window.scrollX) !== null && _a !== void 0 ? _a : window.pageXOffset) !== null && _b !== void 0 ? _b : document.documentElement.scrollLeft) !== null && _c !== void 0 ? _c : 0; var scrollTop = (_f = (_e = (_d = window.scrollY) !== null && _d !== void 0 ? _d : window.pageYOffset) !== null && _e !== void 0 ? _e : document.documentElement.scrollTop) !== null && _f !== void 0 ? _f : 0; var styles = { position: 'absolute', zIndex: 1000, minWidth: inputRect.width, }; // Use suggestionPosition prop to adjust tooltip position switch (suggestionPosition) { case 'top': styles.left = "".concat(inputRect.left + scrollLeft, "px"); styles.top = "".concat(inputRect.top + scrollTop - 8, "px"); styles.transform = 'translateY(-100%)'; break; case 'bottom': styles.left = "".concat(inputRect.left + scrollLeft, "px"); styles.top = "".concat(inputRect.bottom + scrollTop + 8, "px"); break; case 'left': styles.left = "".concat(inputRect.left + scrollLeft - 8, "px"); styles.top = "".concat(inputRect.top + scrollTop, "px"); styles.transform = "".concat(styles.transform ? "".concat(styles.transform, " ") : '', "translateX(-100%)"); break; case 'right': styles.left = "".concat(inputRect.right + scrollLeft + 8, "px"); styles.top = "".concat(inputRect.top + scrollTop, "px"); break; default: break; } return ReactDOM.createPortal(React.createElement("div", { className: "suggestion-container ".concat(suggestionListClassName || ''), style: styles }, React.createElement("ul", { className: "suggestion-list", ref: suggestionListRef }, suggestions.map(function (user) { return (React.createElement("li", { key: user.id, onClick: function () { return handleSuggestionClick(user); }, className: "suggestion-item ".concat(suggestionItemClassName || ''), role: "option", tabIndex: 0, "aria-selected": "false" }, React.createElement("div", { className: "user-icon" }, getInitials(user === null || user === void 0 ? void 0 : user.name)), React.createElement("span", { className: "user-name" }, user.name))); }))), window.document.body); }; var handleSuggestionClick = function (user) { if (!inputRef.current) return; var plainText = inputValue; var caretOffset = caretOffsetRef.current; var mentionMatch = plainText.slice(0, caretOffset).match(/@(\S*)$/); if (!userSelectListRef.current.includes(user.name)) { userSelectListRef.current.push(user.name); } // Check if the ID is already stored var isIdExists = userSelectListWithIdsRef.current.some(function (item) { return item.id === user.id; }); if (!isIdExists) { userSelectListWithIdsRef.current.push(user); } if (!mentionMatch) return; var mentionIndex = plainText.slice(0, caretOffset).lastIndexOf("@"); // Append space after the mention var newValue = plainText.substring(0, mentionIndex + 1) + user.name + " " + plainText.substring(caretOffset); setInputValue(newValue); inputRef.current.innerText = newValue; // Highlight mentions and links with &nbsp; var htmlWithHighlights = highlightMentionsAndLinks(newValue); // Set highlighted content inputRef.current.innerHTML = htmlWithHighlights; setShowSuggestions(false); // Adjust caret position after adding the mention and space var mentionEnd = mentionIndex + user.name.length + 1; restoreCaretPosition(inputRef.current, mentionEnd + 1); // +1 for the space }; var handleImageSelect = function (event) { return __awaiter(void 0, void 0, void 0, function () { var files, file; return __generator(this, function (_a) { switch (_a.label) { case 0: files = Array.from(event.target.files || []); if (!(files.length > 0)) return [3 /*break*/, 2]; file = files[0]; if (!file.type.startsWith('image/')) return [3 /*break*/, 2]; return [4 /*yield*/, uploadImage(file)]; case 1: _a.sent(); _a.label = 2; case 2: // Reset the input value to allow selecting the same file again if (fileInputRef.current) { fileInputRef.current.value = ''; } return [2 /*return*/]; } }); }); }; var handleDragOver = function (e) { e.preventDefault(); e.stopPropagation(); // Only set dragging if files are being dragged if (e.dataTransfer.types.includes('Files')) { setIsDraggingOver(true); } }; var handleDragLeave = function (e) { e.preventDefault(); e.stopPropagation(); // Check if we're leaving the container, not just moving between children var rect = e.currentTarget.getBoundingClientRect(); var x = e.clientX; var y = e.clientY; if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { setIsDraggingOver(false); } }; var handleDrop = function (e) { return __awaiter(void 0, void 0, void 0, function () { var files, imageFiles; return __generator(this, function (_a) { switch (_a.label) { case 0: e.preventDefault(); e.stopPropagation(); setIsDraggingOver(false); files = Array.from(e.dataTransfer.files); if (!(files.length > 0)) return [3 /*break*/, 2]; imageFiles = files.filter(function (file) { return file.type.startsWith('image/'); }); if (!(imageFiles.length > 0)) return [3 /*break*/, 2]; return [4 /*yield*/, uploadImage(imageFiles[0])]; case 1: _a.sent(); _a.label = 2; case 2: return [2 /*return*/]; } }); }); }; var uploadImage = function (file) { return __awaiter(void 0, void 0, void 0, function () { var url, error_1; return __generator(this, function (_a) { switch (_a.label) { case 0: if (!onImageUpload) { // If no upload function provided, store the file directly setSelectedImage(file); setImageUrl(URL.createObjectURL(file)); return [2 /*return*/]; } _a.label = 1; case 1: _a.trys.push([1, 3, 4, 5]); setIsUploading(true); return [4 /*yield*/, onImageUpload(file)]; case 2: url = _a.sent(); setSelectedImage(file); setImageUrl(url); return [3 /*break*/, 5]; case 3: error_1 = _a.sent(); console.error('Error uploading image:', error_1); return [3 /*break*/, 5]; case 4: setIsUploading(false); return [7 /*endfinally*/]; case 5: return [2 /*return*/]; } }); }); }; var removeImage = function () { setSelectedImage(null); setImageUrl(null); // Reset the input value when image is removed if (fileInputRef.current) { fileInputRef.current.value = ''; } }; var handleSendMessage = function () { if (inputRef.current) { var messageText = inputRef.current.innerText.trim(); var messageHTML = inputRef.current.innerHTML.trim(); if ((messageText || selectedImage) && onSendMessage) { onSendMessage({ messageText: messageText, messageHTML: messageHTML, userSelectListWithIds: userSelectListWithIdsRef.current, userSelectListName: userSelectListRef.current, images: selectedImage ? [selectedImage] : [], imageUrl: imageUrl }); setInputValue(""); setShowSuggestions(false); inputRef.current.innerText = ""; setSelectedImage(null); setImageUrl(null); userSelectListRef.current = []; userSelectListWithIdsRef.current = []; // Reset the file input value after sending if (fileInputRef.current) { fileInputRef.current.value = ''; } } } }; var handleKeyDown = function (event) { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); // Prevent newline in content-editable handleSendMessage(); // Trigger the same function as the Send button return; } if (event.key === "Backspace") { var removed = removeMentionToken("backward"); if (removed) { event.preventDefault(); } } if (event.key === "Delete") { var removed = removeMentionToken("forward"); if (removed) { event.preventDefault(); } } }; return (React.createElement("div", { className: "mention-container ".concat(containerClassName || "") }, displayImageUrl && selectedImage && (React.createElement("div", { className: "image-preview-card ".concat(attachedImageContainerClassName || ""), style: attachedImageContainerStyle }, React.createElement("img", { src: displayImageUrl, alt: "Preview", className: imgClassName || "", style: imgStyle }), React.createElement("button", { onClick: removeImage, className: "remove-image-btn", "aria-label": "Remove image" }, "\u00D7"))), React.createElement("div", { className: "mention-input-container ".concat(inputContainerClassName || "", " ").concat(isDraggingOver ? 'dragging-over' : ''), onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDragEnd: function () { return setIsDraggingOver(false); }, onDrop: handleDrop }, isDraggingOver && (React.createElement("div", { className: "drag-overlay" }, React.createElement("div", { className: "drag-message" }, React.createElement("span", null, "Drop to upload")))), React.createElement("button", { onClick: function () { var _a; return (_a = fileInputRef.current) === null || _a === void 0 ? void 0 : _a.click(); }, className: "attachment-button", type: "button", "aria-label": "Attach image" }, React.createElement("span", { className: "attachment-icon" }, attachmentButtonIcon || "📷")), React.createElement("div", { className: "mention-input-wrapper" }, (!inputValue || !inputRef.current || ((_b = inputRef.current) === null || _b === void 0 ? void 0 : _b.innerText.trim()) === "") && (React.createElement("span", { className: "placeholder" }, placeholder)), 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'); } })), React.createElement("button", { onClick: handleSendMessage, className: "send-button ".concat(sendBtnClassName || ""), "aria-label": "Send message" }, sendButtonIcon || "➤"), React.createElement("input", { type: "file", ref: fileInputRef, accept: "image/*", onChange: handleImageSelect, style: { display: 'none' } }), isUploading && (React.createElement("div", { className: "upload-loading" }, React.createElement("span", null, "Uploading...")))), renderSuggestions())); }; export default MentionInput;