react-mention-input
Version:
A React component for input with @mention functionality.
377 lines (376 loc) • 20.8 kB
JavaScript
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 };
}
};
import React, { useState, useRef } from "react";
import ReactDOM from "react-dom";
import "./MentionInput.css";
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 ? 'bottom' : _d, onImageUpload = _a.onImageUpload;
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];
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 tagsListRef = useRef([]); // Store hashtags
var fileInputRef = useRef(null);
var highlightMentionsAndLinks = function (text) {
// Regular expression for detecting links
var linkRegex = /(https?:\/\/[^\s]+)/g;
// Regular expression for detecting hashtags
var hashtagRegex = /#[\w]+/g;
// Highlight links
var highlightedText = text.replace(linkRegex, '<a href="$1" target="_blank" rel="noopener noreferrer" class="link-highlight">$1</a>');
// Highlight hashtags
highlightedText = highlightedText.replace(hashtagRegex, function (match) {
return "<span class=\"hashtag-highlight\">".concat(match, "</span>");
});
// 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> ");
});
});
return highlightedText;
};
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 handleInputChange = function () {
if (!inputRef.current)
return;
// Store current selection before modifications
var selection = window.getSelection();
var range = selection === null || selection === void 0 ? void 0 : selection.getRangeAt(0);
var newCaretOffset = 0;
if (range && inputRef.current.contains(range.startContainer)) {
var preCaretRange = range.cloneRange();
preCaretRange.selectNodeContents(inputRef.current);
preCaretRange.setEnd(range.startContainer, range.startOffset);
newCaretOffset = preCaretRange.toString().length;
}
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);
}
// Extract and store hashtags
var hashtagMatches = plainText.match(/#[\w]+/g);
if (hashtagMatches) {
var uniqueTags = Array.from(new Set(hashtagMatches));
tagsListRef.current = uniqueTags;
}
else {
tagsListRef.current = [];
}
// Only apply highlighting if we have mentions, hashtags, or links to highlight
if (userSelectListRef.current.length > 0 || plainText.match(/(https?:\/\/[^\s]+)/g) || plainText.match(/#[\w]+/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 () {
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 styles = {
position: 'absolute',
zIndex: 1000,
};
// Use suggestionPosition prop to adjust tooltip position
switch (suggestionPosition) {
case 'top':
styles.left = "".concat(inputRect.left, "px");
styles.top = "".concat(inputRect.top - 150, "px");
break;
case 'bottom':
styles.left = "".concat(inputRect.left, "px");
styles.top = "".concat(inputRect.bottom, "px");
break;
case 'left':
styles.left = "".concat(inputRect.left - 150, "px");
styles.top = "".concat(inputRect.top, "px");
break;
case 'right':
styles.left = "".concat(inputRect.right, "px");
styles.top = "".concat(inputRect.top, "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
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: 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);
};
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,
tags: tagsListRef.current,
images: selectedImage ? [selectedImage] : [],
imageUrl: imageUrl
});
setInputValue("");
setShowSuggestions(false);
inputRef.current.innerText = "";
setSelectedImage(null);
setImageUrl(null);
userSelectListRef.current = [];
userSelectListWithIdsRef.current = [];
tagsListRef.current = [];
}
}
};
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 (React.createElement("div", { className: "mention-container ".concat(containerClassName || "") },
imageUrl && selectedImage && (React.createElement("div", { className: "image-preview-card ".concat(attachedImageContainerClassName || ""), style: attachedImageContainerStyle },
React.createElement("img", { src: imageUrl, 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;