UNPKG

@lexical/react

Version:

This package provides Lexical components and hooks for React applications.

252 lines (247 loc) 7.02 kB
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import { useFloating, autoUpdate, offset, flip, shift, useRole, useDismiss, useListNavigation, useTypeahead, useInteractions, FloatingPortal, FloatingOverlay, FloatingFocusManager } from '@floating-ui/react'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { $getNearestNodeFromDOMNode } from 'lexical'; import { forwardRef, useState, useRef, useEffect, createElement } from 'react'; import { jsx, jsxs } from 'react/jsx-runtime'; /** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ class MenuOption { key; ref; constructor(key) { this.key = key; this.ref = { current: null }; this.setRefElement = this.setRefElement.bind(this); } setRefElement(element) { this.ref = { current: element }; } } class NodeContextMenuOption extends MenuOption { type; title; icon; disabled; $onSelect; $showOn; constructor(title, options) { super(title); this.type = 'item'; this.title = title; this.disabled = options.disabled ?? false; this.icon = options.icon ?? null; this.$onSelect = options.$onSelect; if (options.$showOn) { this.$showOn = options.$showOn; } } } class NodeContextMenuSeparator extends MenuOption { type; $showOn; constructor(options) { super('_separator'); this.type = 'separator'; if (options && options.$showOn) { this.$showOn = options.$showOn; } } } const ContextMenuSeparatorItem = /*#__PURE__*/forwardRef(({ className, disabled, ...props }, ref) => { return /*#__PURE__*/jsx("hr", { className: className }); }); const ContextMenuItem = /*#__PURE__*/forwardRef(({ className, label, disabled, icon, ...props }, ref) => { return /*#__PURE__*/jsxs("button", { ...props, className: className, ref: ref, role: "menuitem", disabled: disabled, children: [icon, label] }); }); const NodeContextMenuPlugin = /*#__PURE__*/forwardRef(({ items, className, itemClassName, separatorClassName }, forwardedRef) => { const [editor] = useLexicalComposerContext(); const [activeIndex, setActiveIndex] = useState(null); const [isOpen, setIsOpen] = useState(false); const listItemsRef = useRef([]); const listContentRef = useRef([]); const { refs, floatingStyles, context } = useFloating({ middleware: [offset({ alignmentAxis: 4, mainAxis: 5 }), flip({ fallbackPlacements: ['left-start'] }), shift({ padding: 10 })], onOpenChange: setIsOpen, open: isOpen, placement: 'right-start', strategy: 'fixed', whileElementsMounted: autoUpdate }); const role = useRole(context, { role: 'menu' }); const dismiss = useDismiss(context); const listNavigation = useListNavigation(context, { activeIndex, listRef: listItemsRef, onNavigate: setActiveIndex }); const typeahead = useTypeahead(context, { activeIndex, enabled: isOpen, listRef: listContentRef, onMatch: setActiveIndex }); const { getFloatingProps, getItemProps } = useInteractions([role, dismiss, listNavigation, typeahead]); const [renderItems, setRenderItems] = useState([]); useEffect(() => { function onContextMenu(e) { e.preventDefault(); refs.setPositionReference({ getBoundingClientRect() { return { bottom: e.clientY, height: 0, left: e.clientX, right: e.clientX, top: e.clientY, width: 0, x: e.clientX, y: e.clientY }; } }); let visibleItems = []; if (items) { editor.read(() => { const node = $getNearestNodeFromDOMNode(e.target); if (node) { visibleItems = items.filter(option => option.$showOn ? option.$showOn(node) : true); } }); } const renderableItems = visibleItems.map((option, index) => { if (option.type === 'separator') { return { className: separatorClassName, key: option.key + '-' + index, type: option.type }; } else { return { className: itemClassName, disabled: option.disabled, icon: option.icon, key: option.key, label: option.title, onClick: () => editor.update(() => option.$onSelect()), title: option.title, type: option.type }; } }); listContentRef.current = renderableItems.map(item => item.key); setRenderItems(renderableItems); setIsOpen(true); } return editor.registerRootListener(rootElement => { if (rootElement !== null) { rootElement.addEventListener('contextmenu', onContextMenu); return () => rootElement.removeEventListener('contextmenu', onContextMenu); } }); }, [items, itemClassName, separatorClassName, refs, editor]); return /*#__PURE__*/jsx(FloatingPortal, { children: isOpen && /*#__PURE__*/jsx(FloatingOverlay, { lockScroll: true, children: /*#__PURE__*/jsx(FloatingFocusManager, { context: context, initialFocus: refs.floating, children: /*#__PURE__*/jsx("div", { className: className, ref: refs.setFloating, style: floatingStyles, ...getFloatingProps(), children: renderItems.map((item, index) => { if (item.type === 'item') { return /*#__PURE__*/createElement(ContextMenuItem, { ...getItemProps({ ...item, onClick() { item.onClick(); setIsOpen(false); }, onMouseUp() { item.onClick(); setIsOpen(false); }, ref(node) { listItemsRef.current[index] = node; }, tabIndex: activeIndex === index ? 0 : -1 }), key: item.key }); } else if (item.type === 'separator') { return /*#__PURE__*/createElement(ContextMenuSeparatorItem, { ...getItemProps({ ...item, ref(node) { listItemsRef.current[index] = node; }, tabIndex: activeIndex === index ? 0 : -1 }), key: item.key }); } }) }) }) }) }); }); export { NodeContextMenuOption, NodeContextMenuPlugin, NodeContextMenuSeparator };