lexical-beautiful-mentions
Version:
A mentions plugin for the lexical text editor.
500 lines (499 loc) • 24.1 kB
JavaScript
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
import { jsx as _jsx } from "react/jsx-runtime";
/* eslint @typescript-eslint/no-deprecated: 0 */
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { LexicalTypeaheadMenuPlugin, } from "@lexical/react/LexicalTypeaheadMenuPlugin";
import { mergeRegister } from "@lexical/utils";
import { $createTextNode, $getSelection, $isNodeSelection, $isParagraphNode, $setSelection, BLUR_COMMAND, COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_NORMAL, KEY_BACKSPACE_COMMAND, KEY_DOWN_COMMAND, KEY_SPACE_COMMAND, PASTE_COMMAND, SELECTION_CHANGE_COMMAND, TextNode, } from "lexical";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as ReactDOM from "react-dom";
import { ComboboxPlugin } from "./ComboboxPlugin";
import { $createBeautifulMentionNode, $isBeautifulMentionNode, BeautifulMentionNode, } from "./MentionNode";
import { MenuOption } from "./Menu";
import { CAN_USE_DOM, IS_MOBILE } from "./environment";
import { $insertMentionAtSelection, $insertTriggerAtSelection, $removeMention, $renameMention, INSERT_MENTION_COMMAND, OPEN_MENTION_MENU_COMMAND, REMOVE_MENTIONS_COMMAND, RENAME_MENTIONS_COMMAND, } from "./mention-commands";
import { $findBeautifulMentionNodes, $getSelectionInfo, $selectEnd, DEFAULT_PUNCTUATION, LENGTH_LIMIT, PRE_TRIGGER_CHARS, TRIGGERS, VALID_CHARS, getCreatableProp, getMenuItemLimitProp, getTextContent, isWordChar, } from "./mention-utils";
import { useIsFocused } from "./useIsFocused";
import { useMentionLookupService } from "./useMentionLookupService";
class MentionOption extends MenuOption {
constructor(trigger, value, displayValue, data) {
super(value, displayValue, data);
this.trigger = trigger;
this.menuItem = {
trigger,
value,
displayValue,
data,
};
}
}
// Non-standard series of chars. Each series must be preceded and followed by
// a valid char.
const VALID_JOINS = (punctuation) => "(?:" +
"\\.[ |$]|" + // E.g. "r. " in "Mr. Smith"
"\\s|" + // E.g. " " in "Josh Duck"
"[" +
punctuation +
"]|" + // E.g. "-' in "Salier-Hellendag"
")";
// Regex used to trigger the mention menu.
function createMentionsRegex(triggers, preTriggerChars, punctuation, allowSpaces) {
return new RegExp((preTriggerChars ? `(^|\\s|${preTriggerChars})(` : "(^|\\s)(") +
TRIGGERS(triggers) +
"((?:" +
VALID_CHARS(triggers, punctuation) +
(allowSpaces ? VALID_JOINS(punctuation) : "") +
"){0," +
LENGTH_LIMIT +
"})" +
")$");
}
export function checkForMentions(text, triggers, preTriggerChars, punctuation, allowSpaces) {
const match = createMentionsRegex(triggers, preTriggerChars, punctuation, allowSpaces).exec(text);
if (match !== null) {
// The strategy ignores leading whitespace, but we need to know its
// length to add it to the leadOffset
const maybeLeadingWhitespace = match[1];
const matchingStringWithTrigger = match[2];
const matchingString = match[3];
if (matchingStringWithTrigger.length >= 1) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString: matchingString,
replaceableString: matchingStringWithTrigger,
};
}
}
return null;
}
/**
* A plugin that adds mentions to the lexical editor.
*/
export function BeautifulMentionsPlugin(props) {
const { items, onSearch, autoSpace = true, searchDelay = props.onSearch ? 250 : 0, allowSpaces = true, insertOnBlur = true, menuComponent: MenuComponent = "ul", menuItemComponent: MenuItemComponent = "li", emptyComponent: EmptyComponent, menuAnchorClassName, showMentionsOnDelete, showCurrentMentionsAsSuggestions = true, mentionEnclosure, onMenuOpen, onMenuClose, onMenuItemSelect, punctuation = DEFAULT_PUNCTUATION, preTriggerChars = PRE_TRIGGER_CHARS, } = props;
const justSelectedAnOption = useRef(false);
const isEditorFocused = useIsFocused();
const triggers = useMemo(() => { var _a; return (_a = props.triggers) !== null && _a !== void 0 ? _a : Object.keys(items !== null && items !== void 0 ? items : {}); }, [props.triggers, items]);
const [editor] = useLexicalComposerContext();
const [queryString, setQueryString] = useState(null);
const [trigger, setTrigger] = useState(null);
const { results, loading, query } = useMentionLookupService({
queryString,
searchDelay,
trigger,
items,
onSearch,
justSelectedAnOption,
});
const selectedMenuIndexRef = useRef(null);
const oldSelection = useRef(null);
const creatable = getCreatableProp(props.creatable, trigger);
const menuItemLimit = getMenuItemLimitProp(props.menuItemLimit, trigger);
const options = useMemo(() => {
if (!trigger) {
return [];
}
// add options from the lookup service
let opt = results.map((result) => {
if (typeof result === "string") {
return new MentionOption(trigger, result, result);
}
else {
const { value } = result, data = __rest(result, ["value"]);
return new MentionOption(trigger, value, value, data);
}
});
// limit the number of menu items
if (menuItemLimit !== false && menuItemLimit > 0) {
opt = opt.slice(0, menuItemLimit);
}
// Add mentions from the editor. When a search function is provided, wait for the
// delayed search to prevent flickering
const readyToAddCurrentMentions = !onSearch || (!loading && query !== null);
if (readyToAddCurrentMentions && showCurrentMentionsAsSuggestions) {
editor.getEditorState().read(() => {
const mentions = $findBeautifulMentionNodes(editor);
for (const mention of mentions) {
const value = mention.getValue();
const data = mention.getData();
// only add the mention if it's not already in the list
if (mention.getTrigger() === trigger &&
(query === null || mention.getValue().startsWith(query)) &&
opt.every((o) => o.value !== value)) {
opt.push(new MentionOption(trigger, value, value, data));
}
}
});
}
// add option to create a new mention
if (query && opt.every((o) => o.displayValue !== query)) {
const displayValue = typeof creatable === "string"
? creatable.replace("{{name}}", query)
: typeof creatable === "undefined" || creatable
? `Add "${query}"`
: undefined;
if (displayValue) {
opt.push(new MentionOption(trigger, query, displayValue));
}
}
return opt;
}, [
results,
onSearch,
loading,
query,
editor,
trigger,
creatable,
menuItemLimit,
showCurrentMentionsAsSuggestions,
]);
const queryWithoutResults = !!onSearch && !options.length && !!queryString && /\s+/.test(queryString);
const open = !!options.length || (loading && !queryWithoutResults);
const handleClose = useCallback(() => {
setTrigger(null);
}, []);
const handleSelectOption = useCallback((selectedOption, nodeToReplace, closeMenu) => {
editor.update(() => {
if (!trigger) {
return;
}
const newMention = !!creatable && selectedOption.value !== selectedOption.displayValue;
const value = newMention && mentionEnclosure && /\s/.test(selectedOption.value)
? // if the value has spaces, wrap it in the enclosure
mentionEnclosure + selectedOption.value + mentionEnclosure
: selectedOption.value;
const mentionNode = $createBeautifulMentionNode(trigger, value, selectedOption.data);
if (nodeToReplace) {
const nextSibling = nodeToReplace.getNextSibling();
nodeToReplace.replace(mentionNode);
if (nextSibling instanceof TextNode) {
// prevent that the text directly after the cursor remains in the
// editor if the user moved back with the cursor when selecting a mention
const nextSiblingTextContent = nextSibling.getTextContent();
if (!/\s/.test(nextSiblingTextContent) &&
value.includes(nextSiblingTextContent)) {
nextSibling.remove();
}
}
}
closeMenu === null || closeMenu === void 0 ? void 0 : closeMenu();
justSelectedAnOption.current = true;
});
}, [editor, trigger, creatable, mentionEnclosure]);
const handleSelectMenuItem = useCallback((selectedOption, nodeToReplace, closeMenu) => {
if (!trigger) {
return;
}
onMenuItemSelect === null || onMenuItemSelect === void 0 ? void 0 : onMenuItemSelect({
trigger,
value: selectedOption.value,
displayValue: selectedOption.displayValue,
data: selectedOption.data,
});
handleSelectOption(selectedOption, nodeToReplace, closeMenu);
}, [handleSelectOption, onMenuItemSelect, trigger]);
const checkForMentionMatch = useCallback((text) => {
const queryMatch = checkForMentions(text, triggers, preTriggerChars, punctuation, allowSpaces);
if (queryMatch) {
const { replaceableString, matchingString } = queryMatch;
const index = replaceableString.lastIndexOf(matchingString);
const trigger = index === -1
? replaceableString
: replaceableString.substring(0, index) +
replaceableString.substring(index + matchingString.length);
setTrigger(trigger || null);
if (queryMatch.replaceableString) {
return queryMatch;
}
}
else {
setTrigger(null);
}
return null;
}, [preTriggerChars, allowSpaces, punctuation, triggers]);
const insertSpaceIfNecessary = useCallback((startsWithTriggerChar = false) => {
if (!autoSpace) {
return;
}
const selectionInfo = $getSelectionInfo(triggers, punctuation);
if (!selectionInfo) {
return;
}
const { node, offset, type, parentNode, isTextNode, textContent, prevNode, nextNode, wordCharAfterCursor, cursorAtStartOfNode, cursorAtEndOfNode, } = selectionInfo;
// [Mention][|][ Text]
if (isTextNode &&
cursorAtStartOfNode &&
$isBeautifulMentionNode(prevNode)) {
node.insertBefore($createTextNode(" "));
return;
}
// ^[|][Mention]
if ($isBeautifulMentionNode(node) &&
prevNode === null &&
$isParagraphNode(parentNode) &&
type === "element" &&
offset === 0) {
const textNode = $createTextNode(" ");
node.insertBefore(textNode);
textNode.selectStart();
return;
}
// [Text ][|][Mention]
if (isTextNode &&
cursorAtEndOfNode &&
$isBeautifulMentionNode(nextNode)) {
node.insertAfter($createTextNode(" "));
return;
}
// [Text ][|][Text]
if (isTextNode && startsWithTriggerChar && wordCharAfterCursor) {
const content = textContent.substring(0, offset) +
" " +
textContent.substring(offset);
node.setTextContent(content);
return;
}
// [Mention][|]
if ($isBeautifulMentionNode(node) && nextNode === null) {
node.insertAfter($createTextNode(" "));
}
}, [punctuation, triggers, autoSpace]);
const convertTextToMention = useCallback((refocusAfterInsert = false) => {
const selectedMenuIndex = selectedMenuIndexRef.current;
let option = typeof selectedMenuIndex === "number"
? options[selectedMenuIndex]
: undefined;
const newMention = options.find((o) => o.value !== o.displayValue);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (newMention && (IS_MOBILE || option === null)) {
option = newMention;
}
if (!option) {
return false;
}
const selectionInfo = $getSelectionInfo(triggers, punctuation);
if (!trigger || !(selectionInfo === null || selectionInfo === void 0 ? void 0 : selectionInfo.isTextNode)) {
return false;
}
const node = selectionInfo.node;
const textContent = getTextContent(node);
const queryMatch = checkForMentions(textContent, triggers, preTriggerChars, punctuation, allowSpaces);
if (queryMatch === null) {
return false;
}
const textEndIndex = textContent.search(new RegExp(`${queryMatch.replaceableString}\\s?$`));
if (textEndIndex === -1) {
return false;
}
const mentionNode = $createBeautifulMentionNode(trigger, option.value, option.data);
editor.update(() => {
node.setTextContent(textContent.substring(0, textEndIndex));
node.insertAfter(mentionNode);
mentionNode.selectNext();
}, { tag: "history-merge" });
if (refocusAfterInsert) {
const selection = $getSelection();
setTimeout(() => {
editor.update(() => {
if (selection) {
$setSelection(selection.clone());
}
});
});
}
return true;
}, [
options,
triggers,
punctuation,
trigger,
preTriggerChars,
allowSpaces,
editor,
]);
const restoreSelection = useCallback(() => {
const selection = $getSelection();
if ((!selection || $isNodeSelection(selection)) && oldSelection.current) {
const newSelection = oldSelection.current.clone();
$setSelection(newSelection);
}
else if (!selection) {
$selectEnd();
}
if (oldSelection.current) {
oldSelection.current = null;
}
}, []);
const handleDeleteMention = useCallback((event) => {
if (!showMentionsOnDelete) {
return false;
}
const selectionInfo = $getSelectionInfo(triggers, punctuation);
if (selectionInfo) {
const { node, prevNode, offset } = selectionInfo;
const mentionNode = $isBeautifulMentionNode(node)
? node
: $isBeautifulMentionNode(prevNode) && offset === 0
? prevNode
: null;
if (mentionNode) {
const trigger = mentionNode.getTrigger();
mentionNode.replace($createTextNode(trigger));
event.preventDefault();
return true;
}
}
return false;
}, [showMentionsOnDelete, triggers, punctuation]);
const handleKeyDown = useCallback((event) => {
var _a;
const { key, metaKey, ctrlKey } = event;
const simpleKey = key.length === 1;
const wordChar = isWordChar(key, triggers, punctuation);
const isSpace = allowSpaces && /^\s$/.test(key);
if (!simpleKey || metaKey || ctrlKey) {
return false;
}
// since the key must not be the same as the trigger, we try to build the trigger
// by combining the key with the last word before the cursor
let combinedKey = key;
const selectionInfo = $getSelectionInfo(triggers, punctuation);
if (selectionInfo === null || selectionInfo === void 0 ? void 0 : selectionInfo.isTextNode) {
const { textContent, offset } = selectionInfo;
const last = (_a = textContent.substring(0, offset).split(/\s+/).at(-1)) !== null && _a !== void 0 ? _a : "";
combinedKey = (last + key).trim();
}
const isTrigger = triggers.some((trigger) => combinedKey === trigger);
if (!wordChar && !isTrigger && !isSpace) {
return false;
}
insertSpaceIfNecessary(isTrigger);
return false;
}, [insertSpaceIfNecessary, punctuation, triggers, allowSpaces]);
const handlePaste = useCallback((event) => {
var _a;
const text = (_a = event.clipboardData) === null || _a === void 0 ? void 0 : _a.getData("text/plain");
const firstChar = text === null || text === void 0 ? void 0 : text.charAt(0);
const isTrigger = triggers.some((trigger) => firstChar === trigger);
const isPunctuation = firstChar && new RegExp(`[\\s${punctuation}]`).test(firstChar);
if (isTrigger || !isPunctuation) {
// insert space before pasting if the content starts with a trigger character
insertSpaceIfNecessary();
}
return false; // will be handled by the lexical clipboard module
}, [insertSpaceIfNecessary, triggers, punctuation]);
useEffect(() => {
if (!editor.hasNodes([BeautifulMentionNode])) {
throw new Error("BeautifulMentionsPlugin: BeautifulMentionNode not registered on editor");
}
return mergeRegister(editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
const selection = $getSelection();
if (selection && !$isNodeSelection(selection)) {
oldSelection.current = selection;
}
else if (!selection) {
oldSelection.current = null;
}
return false;
}, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_DOWN_COMMAND, handleKeyDown, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_BACKSPACE_COMMAND, handleDeleteMention, COMMAND_PRIORITY_LOW), editor.registerCommand(BLUR_COMMAND, () => {
if (insertOnBlur) {
return convertTextToMention(true);
}
return false;
}, COMMAND_PRIORITY_LOW), editor.registerCommand(KEY_SPACE_COMMAND, () => {
if (!allowSpaces && creatable) {
return convertTextToMention();
}
else {
return false;
}
}, COMMAND_PRIORITY_LOW), editor.registerCommand(INSERT_MENTION_COMMAND, ({ trigger, value, data, focus = true }) => {
restoreSelection();
const inserted = $insertMentionAtSelection(triggers, punctuation, trigger, value, data, autoSpace);
if (!focus) {
$setSelection(null);
}
return inserted;
}, COMMAND_PRIORITY_LOW), editor.registerCommand(REMOVE_MENTIONS_COMMAND, ({ trigger, value, focus }) => {
const removed = $removeMention(trigger, value, focus);
if (!focus) {
// remove oldSelection manually because the SELECTION_CHANGE_COMMAND
// listener is not called in this case.
oldSelection.current = null;
}
return removed;
}, COMMAND_PRIORITY_LOW), editor.registerCommand(RENAME_MENTIONS_COMMAND, ({ trigger, newValue, value, focus }) => $renameMention(trigger, newValue, value, focus), COMMAND_PRIORITY_LOW), editor.registerCommand(OPEN_MENTION_MENU_COMMAND, ({ trigger }) => {
restoreSelection();
return $insertTriggerAtSelection(triggers, punctuation, trigger, autoSpace);
}, COMMAND_PRIORITY_LOW), editor.registerCommand(PASTE_COMMAND, handlePaste, COMMAND_PRIORITY_LOW));
}, [
editor,
triggers,
punctuation,
autoSpace,
allowSpaces,
insertOnBlur,
creatable,
isEditorFocused,
convertTextToMention,
handleKeyDown,
handleDeleteMention,
handlePaste,
restoreSelection,
]);
useEffect(() => {
if (open && isEditorFocused) {
onMenuOpen === null || onMenuOpen === void 0 ? void 0 : onMenuOpen();
}
else {
onMenuClose === null || onMenuClose === void 0 ? void 0 : onMenuClose();
}
if (open && !isEditorFocused) {
handleClose();
}
}, [onMenuOpen, onMenuClose, open, isEditorFocused, handleClose]);
if (!CAN_USE_DOM) {
return null;
}
if (props.combobox) {
return (_jsx(ComboboxPlugin, { options: options, loading: loading, onQueryChange: setQueryString, onSelectOption: handleSelectOption, onReset: () => {
setTrigger(null);
}, triggerFn: checkForMentionMatch, triggers: triggers, punctuation: punctuation, creatable: creatable, comboboxOpen: props.comboboxOpen, comboboxAnchor: props.comboboxAnchor, comboboxAnchorClassName: props.comboboxAnchorClassName, comboboxComponent: props.comboboxComponent, comboboxItemComponent: props.comboboxItemComponent, comboboxAdditionalItems: props.comboboxAdditionalItems, onComboboxOpen: props.onComboboxOpen, onComboboxClose: props.onComboboxClose, onComboboxFocusChange: props.onComboboxFocusChange, onComboboxItemSelect: props.onComboboxItemSelect }));
}
return (_jsx(LexicalTypeaheadMenuPlugin, { commandPriority: COMMAND_PRIORITY_NORMAL, onQueryChange: setQueryString, onSelectOption: handleSelectMenuItem, triggerFn: checkForMentionMatch, options: options, anchorClassName: menuAnchorClassName, onClose: handleClose, menuRenderFn: (anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
selectedMenuIndexRef.current = selectedIndex;
if (anchorElementRef.current &&
options.length === 0 &&
query &&
!loading &&
isEditorFocused &&
EmptyComponent) {
return ReactDOM.createPortal(_jsx(EmptyComponent, {}), anchorElementRef.current);
}
return anchorElementRef.current && open
? ReactDOM.createPortal(_jsx(MenuComponent, { loading: loading, role: "menu", "aria-label": "Choose a mention", "aria-hidden": !open, "aria-activedescendant": !IS_MOBILE &&
selectedIndex !== null &&
!!options[selectedIndex]
? options[selectedIndex].displayValue
: "", children: options.map((option, i) => (_jsx(MenuItemComponent, Object.assign({ tabIndex: -1, selected: !IS_MOBILE && selectedIndex === i, ref: option.setRefElement, role: "menuitem", "aria-selected": !IS_MOBILE && selectedIndex === i, "aria-label": `Choose ${option.value}`, item: option.menuItem, itemValue: option.value, label: option.displayValue }, option.data, { onClick: () => {
setHighlightedIndex(i);
selectOptionAndCleanUp(option);
}, onMouseDown: (event) => {
event.preventDefault();
}, onMouseEnter: () => {
setHighlightedIndex(i);
}, children: option.displayValue }), option.key))) }), anchorElementRef.current)
: null;
} }));
}