UNPKG

lexical-beautiful-mentions

Version:
482 lines (481 loc) 19.9 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.useAnchorRef = useAnchorRef; exports.checkForTriggers = checkForTriggers; exports.ComboboxPlugin = ComboboxPlugin; const jsx_runtime_1 = require("react/jsx-runtime"); const LexicalComposerContext_1 = require("@lexical/react/LexicalComposerContext"); const utils_1 = require("@lexical/utils"); const lexical_1 = require("lexical"); const react_1 = require("react"); const ReactDOM = __importStar(require("react-dom")); const Menu_1 = require("./Menu"); const environment_1 = require("./environment"); const mention_commands_1 = require("./mention-commands"); const mention_utils_1 = require("./mention-utils"); const useIsFocused_1 = require("./useIsFocused"); class ComboboxOption extends Menu_1.MenuOption { constructor(itemType, value, displayValue, data = {}) { super(value, displayValue, data); this.itemType = itemType; this.comboboxItem = { itemType: itemType, value: value, displayValue: displayValue, data: data, }; this.menuOption = new Menu_1.MenuOption(value, displayValue, data); } } function getQueryTextForSearch(editor) { let text = null; editor.getEditorState().read(() => { const selection = (0, lexical_1.$getSelection)(); if (!(0, lexical_1.$isRangeSelection)(selection)) { return; } text = getTextUpToAnchor(selection); }); return text; } function getTextUpToAnchor(selection) { const anchor = selection.anchor; if (anchor.type !== "text") { return null; } const anchorNode = anchor.getNode(); if (!anchorNode.isSimpleText()) { return null; } const anchorOffset = anchor.offset; return (0, mention_utils_1.getTextContent)(anchorNode).slice(0, anchorOffset); } function isCharacterKey(event) { return (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey && !event.repeat); } function useAnchorRef(render, comboboxAnchor, comboboxAnchorClassName) { const [editor] = (0, LexicalComposerContext_1.useLexicalComposerContext)(); const [anchor, setAnchor] = (0, react_1.useState)(comboboxAnchor !== null && comboboxAnchor !== void 0 ? comboboxAnchor : null); const [anchorChild, setAnchorChild] = (0, react_1.useState)(null); (0, react_1.useEffect)(() => { if (comboboxAnchor) { setAnchor(comboboxAnchor); return; } return editor.registerRootListener((rootElement) => { if (rootElement) { setAnchor(rootElement.parentElement); } }); }, [editor, comboboxAnchor]); (0, react_1.useEffect)(() => { if (!anchor) { return; } if (!render) { if (anchorChild) { anchorChild.remove(); setAnchorChild(null); } return; } const { height } = anchor.getBoundingClientRect(); const newAnchorChild = anchorChild !== null && anchorChild !== void 0 ? anchorChild : document.createElement("div"); newAnchorChild.style.position = "absolute"; newAnchorChild.style.left = "0"; newAnchorChild.style.right = "0"; newAnchorChild.style.paddingTop = `${height}px`; anchor.prepend(newAnchorChild); if (!anchorChild) { setAnchorChild(newAnchorChild); } const anchorObserver = new ResizeObserver(([entry]) => { newAnchorChild.style.paddingTop = `${entry.contentRect.height}px`; }); anchorObserver.observe(anchor); setTimeout(() => { newAnchorChild.className = comboboxAnchorClassName !== null && comboboxAnchorClassName !== void 0 ? comboboxAnchorClassName : ""; }); return () => { anchorObserver.disconnect(); anchor.removeChild(newAnchorChild); }; }, [anchor, render, anchorChild, comboboxAnchorClassName]); return anchorChild; } function checkForTriggers(text, triggers) { var _a; const last = (_a = text.split(/\s/).pop()) !== null && _a !== void 0 ? _a : text; const offset = text !== last ? text.lastIndexOf(last) : 0; const match = triggers.some((t) => t.startsWith(last) && t !== last); if (match) { return { leadOffset: offset, matchingString: last, replaceableString: last, }; } return null; } function ComboboxPlugin(props) { var _a; const { onSelectOption, triggers, punctuation, autoSpace, loading, triggerFn, onQueryChange, onReset, comboboxAnchor, comboboxAnchorClassName, comboboxComponent: ComboboxComponent = "div", comboboxItemComponent: ComboboxItemComponent = "div", onComboboxOpen, onComboboxClose, onComboboxFocusChange, comboboxAdditionalItems = [], onComboboxItemSelect, } = props; const focused = (0, useIsFocused_1.useIsFocused)(); const [editor] = (0, LexicalComposerContext_1.useLexicalComposerContext)(); const [selectedIndex, setSelectedIndex] = (0, react_1.useState)(null); const [triggerMatch, setTriggerMatch] = (0, react_1.useState)(null); const [valueMatch, setValueMatch] = (0, react_1.useState)(null); const [triggerQueryString, setTriggerQueryString] = (0, react_1.useState)(null); const itemType = props.options.length === 0 ? "trigger" : "value"; const options = (0, react_1.useMemo)(() => { const additionalOptions = comboboxAdditionalItems.map((opt) => new ComboboxOption("additional", opt.value, opt.displayValue, opt.data)); if (itemType === "trigger") { const triggerOptions = triggers.map((trigger) => new ComboboxOption("trigger", trigger, trigger)); if (!triggerQueryString || triggerOptions.every((o) => !o.value.startsWith(triggerQueryString))) { return [...triggerOptions, ...additionalOptions]; } return [ ...triggerOptions.filter((o) => o.value.startsWith(triggerQueryString)), ...additionalOptions, ]; } return [ ...props.options.map((opt) => new ComboboxOption("value", opt.value, opt.displayValue, opt.data)), ...additionalOptions, ]; }, [ comboboxAdditionalItems, itemType, props.options, triggers, triggerQueryString, ]); const [open, setOpen] = (0, react_1.useState)((_a = props.comboboxOpen) !== null && _a !== void 0 ? _a : false); const anchor = useAnchorRef(open, comboboxAnchor, comboboxAnchorClassName); const highlightOption = (0, react_1.useCallback)((index) => { if (!environment_1.IS_MOBILE) { setSelectedIndex(index); } }, []); const scrollIntoView = (0, react_1.useCallback)((index) => { var _a; const option = options[index]; const el = (_a = option.ref) === null || _a === void 0 ? void 0 : _a.current; if (el) { el.scrollIntoView({ block: "nearest" }); } }, [options]); const handleArrowKeyDown = (0, react_1.useCallback)((event, direction) => { if (!focused) { return false; } let newIndex; if (direction === "up") { if (selectedIndex === null) { newIndex = options.length - 1; } else if (selectedIndex === 0) { newIndex = null; } else { newIndex = selectedIndex - 1; } } else { if (selectedIndex === null) { newIndex = 0; } else if (selectedIndex === options.length - 1) { newIndex = null; } else { newIndex = selectedIndex + 1; } } highlightOption(newIndex); if (newIndex) { scrollIntoView(newIndex); } event.preventDefault(); event.stopImmediatePropagation(); return true; }, [focused, selectedIndex, options.length, scrollIntoView, highlightOption]); const handleMouseEnter = (0, react_1.useCallback)((index) => { highlightOption(index); scrollIntoView(index); }, [scrollIntoView, highlightOption]); const handleMouseLeave = (0, react_1.useCallback)(() => { highlightOption(null); }, [highlightOption]); const handleSelectValue = (0, react_1.useCallback)((index) => { const option = options[index]; onComboboxItemSelect === null || onComboboxItemSelect === void 0 ? void 0 : onComboboxItemSelect(option.comboboxItem); if (option.itemType === "additional") { return; } editor.update(() => { const textNode = valueMatch ? (0, Menu_1.$splitNodeContainingQuery)(valueMatch) : null; onSelectOption(option.menuOption, textNode); }); setValueMatch(null); onQueryChange(null); setTriggerQueryString(null); highlightOption(null); }, [ options, editor, onQueryChange, highlightOption, onComboboxItemSelect, valueMatch, onSelectOption, ]); const handleSelectTrigger = (0, react_1.useCallback)((index) => { const option = options[index]; onComboboxItemSelect === null || onComboboxItemSelect === void 0 ? void 0 : onComboboxItemSelect(option.comboboxItem); if (option.itemType === "additional") { return; } editor.update(() => { const nodeToReplace = triggerMatch ? (0, Menu_1.$splitNodeContainingQuery)(triggerMatch) : null; if (nodeToReplace) { const textNode = (0, lexical_1.$createTextNode)(option.value); nodeToReplace.replace(textNode); textNode.select(); } else { (0, mention_commands_1.$insertTriggerAtSelection)(triggers, punctuation, option.value, autoSpace); } }); setTriggerMatch(null); setTriggerQueryString(null); highlightOption(0); }, [ options, editor, highlightOption, onComboboxItemSelect, triggerMatch, triggers, punctuation, autoSpace, ]); const handleClick = (0, react_1.useCallback)((index) => { if (itemType === "trigger") { handleSelectTrigger(index); } if (itemType === "value") { handleSelectValue(index); } }, [itemType, handleSelectTrigger, handleSelectValue]); const handleKeySelect = (0, react_1.useCallback)((event) => { if (!focused || selectedIndex === null) { return false; } let handled = false; if (itemType === "trigger") { handled = true; handleSelectTrigger(selectedIndex); } if (itemType === "value") { handled = true; handleSelectValue(selectedIndex); } if (handled) { event.preventDefault(); event.stopImmediatePropagation(); } return handled; }, [focused, handleSelectValue, handleSelectTrigger, itemType, selectedIndex]); const handleBackspace = (0, react_1.useCallback)(() => { const text = getQueryTextForSearch(editor); const newText = text ? text.substring(0, text.length - 1) : undefined; if (!(newText === null || newText === void 0 ? void 0 : newText.trim())) { highlightOption(null); } return false; }, [editor, highlightOption]); const handleKeyDown = (0, react_1.useCallback)((event) => { setOpen(true); if (!isCharacterKey(event)) { return false; } const text = getQueryTextForSearch(editor); const value = text === null ? event.key : text + event.key; const valueTrimmed = value.trim(); if (options.some((o) => o.displayValue.startsWith(valueTrimmed) && valueTrimmed.length <= o.displayValue.length)) { highlightOption(0); } else if (itemType === "trigger") { highlightOption(null); } return false; }, [editor, options, itemType, highlightOption]); const handleFocus = (0, react_1.useCallback)(() => { setOpen(true); return false; }, []); const handleClickOutside = (0, react_1.useCallback)(() => { setOpen(false); if (!triggerQueryString) { setTriggerQueryString(null); setTriggerMatch(null); setValueMatch(null); } return false; }, [triggerQueryString]); (0, react_1.useEffect)(() => { return (0, utils_1.mergeRegister)(editor.registerCommand(lexical_1.KEY_ARROW_DOWN_COMMAND, (event) => { return handleArrowKeyDown(event, "down"); }, lexical_1.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical_1.KEY_ARROW_UP_COMMAND, (event) => { return handleArrowKeyDown(event, "up"); }, lexical_1.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical_1.KEY_ENTER_COMMAND, handleKeySelect, lexical_1.COMMAND_PRIORITY_NORMAL), editor.registerCommand(lexical_1.KEY_TAB_COMMAND, handleKeySelect, lexical_1.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical_1.KEY_BACKSPACE_COMMAND, handleBackspace, lexical_1.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical_1.KEY_DOWN_COMMAND, handleKeyDown, lexical_1.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical_1.FOCUS_COMMAND, handleFocus, lexical_1.COMMAND_PRIORITY_NORMAL), editor.registerCommand(lexical_1.CLICK_COMMAND, () => { if (!open) { setOpen(true); } return false; }, lexical_1.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical_1.KEY_ESCAPE_COMMAND, () => { setOpen(false); return false; }, lexical_1.COMMAND_PRIORITY_LOW)); }, [ editor, open, handleArrowKeyDown, handleKeySelect, handleBackspace, handleKeyDown, handleFocus, ]); (0, react_1.useEffect)(() => { const updateListener = () => { editor.getEditorState().read(() => { const text = getQueryTextForSearch(editor); // reset if no text if (text === null) { onReset(); setTriggerMatch(null); setValueMatch(null); onQueryChange(null); setTriggerQueryString(null); return; } // check for triggers const triggerMatch = checkForTriggers(text, triggers); setTriggerMatch(triggerMatch); if (triggerMatch) { setTriggerQueryString(triggerMatch.matchingString); setValueMatch(null); return; } // check for mentions const valueMatch = triggerFn(text, editor); setValueMatch(valueMatch); onQueryChange(valueMatch ? valueMatch.matchingString : null); if (valueMatch === null || valueMatch === void 0 ? void 0 : valueMatch.matchingString) { setTriggerQueryString(valueMatch.matchingString); return; } setTriggerQueryString(null); }); }; return editor.registerUpdateListener(updateListener); }, [editor, triggerFn, onQueryChange, onReset, triggers]); (0, react_1.useEffect)(() => { var _a; setOpen((_a = props.comboboxOpen) !== null && _a !== void 0 ? _a : false); }, [props.comboboxOpen]); // call open/close callbacks when open state changes (0, react_1.useEffect)(() => { if (open) { onComboboxOpen === null || onComboboxOpen === void 0 ? void 0 : onComboboxOpen(); } else { setSelectedIndex(null); onComboboxClose === null || onComboboxClose === void 0 ? void 0 : onComboboxClose(); } }, [onComboboxOpen, onComboboxClose, open]); // call focus change callback when selected index changes (0, react_1.useEffect)(() => { if (selectedIndex !== null && !!options[selectedIndex]) { onComboboxFocusChange === null || onComboboxFocusChange === void 0 ? void 0 : onComboboxFocusChange(options[selectedIndex].comboboxItem); } else { onComboboxFocusChange === null || onComboboxFocusChange === void 0 ? void 0 : onComboboxFocusChange(null); } }, [selectedIndex, options, onComboboxFocusChange]); // close combobox when clicking outside (0, react_1.useEffect)(() => { if (!environment_1.CAN_USE_DOM) { return; } const root = editor.getRootElement(); const handleMousedown = (event) => { if (anchor && !anchor.contains(event.target) && root && !root.contains(event.target)) { handleClickOutside(); } }; document.addEventListener("mousedown", handleMousedown); return () => { document.removeEventListener("mousedown", handleMousedown); }; }, [anchor, editor, handleClickOutside]); if (!open || !anchor) { return null; } return ReactDOM.createPortal((0, jsx_runtime_1.jsx)(ComboboxComponent, { loading: loading, itemType: itemType, role: "menu", "aria-activedescendant": selectedIndex !== null && !!options[selectedIndex] ? options[selectedIndex].displayValue : "", "aria-label": "Choose trigger and value", "aria-hidden": !open, children: options.map((option, index) => ((0, jsx_runtime_1.jsx)(ComboboxItemComponent, { selected: index === selectedIndex, role: "menuitem", "aria-selected": selectedIndex === index, "aria-label": `Choose ${option.value}`, item: option.comboboxItem, ref: option.setRefElement, onClick: () => { handleClick(index); }, onMouseEnter: () => { handleMouseEnter(index); }, onMouseLeave: handleMouseLeave, onMouseDown: (e) => { e.preventDefault(); }, children: option.displayValue }, option.key))) }), anchor); }