/** * 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 { autoUpdate, flip, FloatingFocusManager, FloatingOverlay, FloatingPortal, offset, shift, useDismiss, useFloating, useInteractions, useListNavigation, useRole, useTypeahead, } from '@floating-ui/react'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {$getNearestNodeFromDOMNode, $getRoot, LexicalNode} from 'lexical'; import {forwardRef, JSX, RefObject, useEffect, useRef, useState} from 'react'; class MenuOption { key: string; ref?: RefObject; constructor(key: string) { this.key = key; this.ref = {current: null}; this.setRefElement = this.setRefElement.bind(this); } setRefElement(element: HTMLElement | null) { this.ref = {current: element}; } } class NodeContextMenuOption extends MenuOption { type: string; title: string; icon: JSX.Element | null; disabled: boolean; $onSelect: () => void; $showOn?: (node: LexicalNode) => boolean; constructor( title: string, options: { disabled?: boolean; icon?: JSX.Element; $onSelect: () => void; $showOn?: (node: LexicalNode) => boolean; }, ) { 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: string; $showOn?: (node: LexicalNode) => boolean; constructor(options?: {$showOn?: (node: LexicalNode) => boolean}) { super('_separator'); this.type = 'separator'; if (options && options.$showOn) { this.$showOn = options.$showOn; } } } const ContextMenuSeparatorItem = forwardRef< HTMLButtonElement, React.ButtonHTMLAttributes & { disabled?: boolean; } >(({className, disabled, ...props}, ref) => { return
; }); const ContextMenuItem = forwardRef< HTMLButtonElement, React.ButtonHTMLAttributes & { label?: string; disabled?: boolean; icon?: JSX.Element | null; } >(({className, label, disabled, icon, ...props}, ref) => { return ( ); }); type ContextMenuType = NodeContextMenuOption | NodeContextMenuSeparator; interface MenuSeparatorType { className: string; key: string; type: string; } interface MenuItemType extends MenuSeparatorType { disabled: boolean; label: string; onClick: () => void; title: string; } type MenuType = MenuItemType | MenuSeparatorType; interface Props { label?: string; nested?: boolean; itemClassName?: string; separatorClassName?: string; items: ContextMenuType[]; } const NodeContextMenuPlugin = forwardRef< HTMLButtonElement, Props & React.HTMLProps >(({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: MouseEvent) { 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: ContextMenuType[] = []; if (items) { editor.read(() => { const node = $getNearestNodeFromDOMNode(e.target as Element) ?? $getRoot(); 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 as NodeContextMenuOption).disabled, icon: (option as NodeContextMenuOption).icon, key: option.key, label: (option as NodeContextMenuOption).title, onClick: () => editor.update(() => (option as NodeContextMenuOption).$onSelect(), ), title: (option as NodeContextMenuOption).title, type: option.type, }; } }) as MenuType[]; 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 ( {isOpen && (
{renderItems.map((item, index) => { if (item.type === 'item') { return ( ); } else if (item.type === 'separator') { return ( ); } })}
)}
); }); export {NodeContextMenuOption, NodeContextMenuPlugin, NodeContextMenuSeparator};