@lexical/react
Version:
This package provides Lexical components and hooks for React applications.
286 lines (277 loc) • 8.61 kB
JavaScript
/**
* 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 { registerEventListener, $getNearestNodeFromDOMNode, $getRoot } 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
};
}
}
/**
* A selectable item in a {@link NodeContextMenuPlugin}. It pairs a `title` (and
* optional `icon`/`disabled` state) with a `$onSelect` callback that runs in an
* editor update when chosen, and an optional `$showOn` predicate that decides,
* per node, whether the item is shown for the right-clicked node.
*/
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;
}
}
}
/**
* A non-interactive divider between groups of {@link NodeContextMenuOption}s in
* a {@link NodeContextMenuPlugin}. Like menu options, it accepts an optional
* `$showOn` predicate to control when it is displayed.
*/
class NodeContextMenuSeparator extends MenuOption {
type;
$showOn;
constructor(options) {
super('_separator');
this.type = 'separator';
if (options && options.$showOn) {
this.$showOn = options.$showOn;
}
}
}
// The forwardRef calls are annotated by hand: React's forwardRef is not a
// Lexical factory, so the build does not annotate it, and an unannotated
// module-scope call pins the module into every bundle that imports it.
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]
});
});
/**
* Renders a custom context menu (replacing the browser's) when the user
* right-clicks inside the editor. Pass the `items` to display as
* {@link NodeContextMenuOption}s and {@link NodeContextMenuSeparator}s; each
* item's optional `$showOn` predicate is evaluated against the node nearest the
* click so the menu can adapt to its target. Supports keyboard navigation and
* type-ahead.
*
* @returns A portal containing the floating context menu while it is open.
*/
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) {
let visibleItems = [];
if (items) {
editor.read(() => {
const node = $getNearestNodeFromDOMNode(e.target) ?? $getRoot();
if (node) {
visibleItems = items.filter(option => option.$showOn ? option.$showOn(node) : true);
}
});
}
// Nothing is left to show for this node -- separators on their own draw
// a menu with no items in it -- so let the browser's own context menu
// open rather than suppressing it and mounting a scroll-locking overlay
// around nothing.
if (!visibleItems.some(option => option.type !== 'separator')) {
return;
}
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
};
}
});
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) {
return registerEventListener(rootElement, '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 };