diff --git a/.flowconfig b/.flowconfig index af6a5d14fe6..a5a055b8ba1 100644 --- a/.flowconfig +++ b/.flowconfig @@ -55,6 +55,7 @@ module.name_mapper='^@lexical/react/LexicalCollaborationPlugin$' -> ' '/packages/lexical-react/flow/LexicalComposer.js.flow' module.name_mapper='^@lexical/react/LexicalComposerContext$' -> '/packages/lexical-react/flow/LexicalComposerContext.js.flow' module.name_mapper='^@lexical/react/LexicalContentEditable$' -> '/packages/lexical-react/flow/LexicalContentEditable.js.flow' +module.name_mapper='^@lexical/react/LexicalContextMenuPlugin$' -> '/packages/lexical-react/flow/LexicalContextMenuPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalDecoratorBlockNode$' -> '/packages/lexical-react/flow/LexicalDecoratorBlockNode.js.flow' module.name_mapper='^@lexical/react/LexicalDraggableBlockPlugin$' -> '/packages/lexical-react/flow/LexicalDraggableBlockPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalEditorRefPlugin$' -> '/packages/lexical-react/flow/LexicalEditorRefPlugin.js.flow' diff --git a/packages/lexical-devtools/tsconfig.json b/packages/lexical-devtools/tsconfig.json index f9fae1ab80a..c6db89e6ac8 100644 --- a/packages/lexical-devtools/tsconfig.json +++ b/packages/lexical-devtools/tsconfig.json @@ -72,6 +72,9 @@ "@lexical/react/LexicalContentEditable": [ "../lexical-react/src/LexicalContentEditable.tsx" ], + "@lexical/react/LexicalContextMenuPlugin": [ + "../lexical-react/src/LexicalContextMenuPlugin.tsx" + ], "@lexical/react/LexicalDecoratorBlockNode": [ "../lexical-react/src/LexicalDecoratorBlockNode.ts" ], diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index aaff0b2c5ee..902cad9b29d 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -355,10 +355,22 @@ pre::-webkit-scrollbar-thumb { background-position: center; } +.component-picker-menu { + width: 200px; +} + .mentions-menu { width: 250px; } +.auto-embed-menu { + width: 150px; +} + +.emoji-menu { + width: 200px; +} + i.palette { background-image: url(images/icons/palette.svg); } diff --git a/packages/lexical-playground/src/plugins/AutoEmbedPlugin/index.tsx b/packages/lexical-playground/src/plugins/AutoEmbedPlugin/index.tsx index d7822e3dd68..b249ffe61ba 100644 --- a/packages/lexical-playground/src/plugins/AutoEmbedPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/AutoEmbedPlugin/index.tsx @@ -18,6 +18,8 @@ import { } from '@lexical/react/LexicalAutoEmbedPlugin'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {useMemo, useState} from 'react'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; import useModal from '../../hooks/useModal'; import Button from '../../ui/Button'; @@ -153,6 +155,68 @@ export const EmbedConfigs = [ FigmaEmbedConfig, ]; +function AutoEmbedMenuItem({ + index, + isSelected, + onClick, + onMouseEnter, + option, +}: { + index: number; + isSelected: boolean; + onClick: () => void; + onMouseEnter: () => void; + option: AutoEmbedOption; +}) { + let className = 'item'; + if (isSelected) { + className += ' selected'; + } + return ( +
  • + {option.title} +
  • + ); +} + +function AutoEmbedMenu({ + options, + selectedItemIndex, + onOptionClick, + onOptionMouseEnter, +}: { + selectedItemIndex: number | null; + onOptionClick: (option: AutoEmbedOption, index: number) => void; + onOptionMouseEnter: (index: number) => void; + options: Array; +}) { + return ( +
    +
      + {options.map((option: AutoEmbedOption, i: number) => ( + onOptionClick(option, i)} + onMouseEnter={() => onOptionMouseEnter(i)} + key={option.key} + option={option} + /> + ))} +
    +
    + ); +} + const debounce = (callback: (text: string) => void, delay: number) => { let timeoutId: number; return (text: string) => { @@ -257,6 +321,37 @@ export default function AutoEmbedPlugin(): JSX.Element { embedConfigs={EmbedConfigs} onOpenEmbedModalForConfig={openEmbedModal} getMenuOptions={getMenuOptions} + menuRenderFn={( + anchorElementRef, + {selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex}, + ) => + anchorElementRef.current + ? ReactDOM.createPortal( +
    + { + setHighlightedIndex(index); + selectOptionAndCleanUp(option); + }} + onOptionMouseEnter={(index: number) => { + setHighlightedIndex(index); + }} + /> +
    , + anchorElementRef.current, + ) + : null + } /> ); diff --git a/packages/lexical-playground/src/plugins/ComponentPickerPlugin/index.tsx b/packages/lexical-playground/src/plugins/ComponentPickerPlugin/index.tsx index c89379797dc..555ea54b732 100644 --- a/packages/lexical-playground/src/plugins/ComponentPickerPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ComponentPickerPlugin/index.tsx @@ -34,6 +34,7 @@ import { TextNode, } from 'lexical'; import {useCallback, useMemo, useState} from 'react'; +import * as ReactDOM from 'react-dom'; import useModal from '../../hooks/useModal'; import catTypingGif from '../../images/cat-typing.gif'; @@ -78,6 +79,40 @@ class ComponentPickerOption extends MenuOption { } } +function ComponentPickerMenuItem({ + index, + isSelected, + onClick, + onMouseEnter, + option, +}: { + index: number; + isSelected: boolean; + onClick: () => void; + onMouseEnter: () => void; + option: ComponentPickerOption; +}) { + let className = 'item'; + if (isSelected) { + className += ' selected'; + } + return ( +
  • + {option.icon} + {option.title} +
  • + ); +} + function getDynamicOptions(editor: LexicalEditor, queryString: string) { const options: Array = []; @@ -371,6 +406,35 @@ export default function ComponentPickerMenuPlugin(): JSX.Element { onSelectOption={onSelectOption} triggerFn={checkForTriggerMatch} options={options} + menuRenderFn={( + anchorElementRef, + {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex}, + ) => + anchorElementRef.current && options.length + ? ReactDOM.createPortal( +
    +
      + {options.map((option, i: number) => ( + { + setHighlightedIndex(i); + selectOptionAndCleanUp(option); + }} + onMouseEnter={() => { + setHighlightedIndex(i); + }} + key={option.key} + option={option} + /> + ))} +
    +
    , + anchorElementRef.current, + ) + : null + } /> ); diff --git a/packages/lexical-playground/src/plugins/EmojiPickerPlugin/index.tsx b/packages/lexical-playground/src/plugins/EmojiPickerPlugin/index.tsx index c7d0f95cc49..cd59113472d 100644 --- a/packages/lexical-playground/src/plugins/EmojiPickerPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/EmojiPickerPlugin/index.tsx @@ -18,7 +18,9 @@ import { $isRangeSelection, TextNode, } from 'lexical'; +import * as React from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react'; +import * as ReactDOM from 'react-dom'; class EmojiOption extends MenuOption { title: string; @@ -38,6 +40,40 @@ class EmojiOption extends MenuOption { this.keywords = options.keywords || []; } } +function EmojiMenuItem({ + index, + isSelected, + onClick, + onMouseEnter, + option, +}: { + index: number; + isSelected: boolean; + onClick: () => void; + onMouseEnter: () => void; + option: EmojiOption; +}) { + let className = 'item'; + if (isSelected) { + className += ' selected'; + } + return ( +
  • + + {option.emoji} {option.title} + +
  • + ); +} type Emoji = { emoji: string; @@ -66,7 +102,7 @@ export default function EmojiPickerPlugin() { emojis != null ? emojis.map( ({emoji, aliases, tags}) => - new EmojiOption(`${emoji} ${aliases[0]}`, emoji, { + new EmojiOption(aliases[0], emoji, { keywords: [...aliases, ...tags], }), ) @@ -125,6 +161,39 @@ export default function EmojiPickerPlugin() { onSelectOption={onSelectOption} triggerFn={checkForTriggerMatch} options={options} + menuRenderFn={( + anchorElementRef, + {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex}, + ) => { + if (anchorElementRef.current == null || options.length === 0) { + return null; + } + + return anchorElementRef.current && options.length + ? ReactDOM.createPortal( +
    +
      + {options.map((option: EmojiOption, index) => ( + { + setHighlightedIndex(index); + selectOptionAndCleanUp(option); + }} + onMouseEnter={() => { + setHighlightedIndex(index); + }} + option={option} + /> + ))} +
    +
    , + anchorElementRef.current, + ) + : null; + }} /> ); } diff --git a/packages/lexical-playground/src/plugins/MentionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/MentionsPlugin/index.tsx index 151dd6b8250..c81bdf6e542 100644 --- a/packages/lexical-playground/src/plugins/MentionsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/MentionsPlugin/index.tsx @@ -17,6 +17,8 @@ import { } from '@lexical/react/LexicalTypeaheadMenuPlugin'; import {TextNode} from 'lexical'; import {useCallback, useEffect, useMemo, useState} from 'react'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; import {$createMentionNode} from '../../nodes/MentionNode'; @@ -568,14 +570,43 @@ class MentionTypeaheadOption extends MenuOption { super(name); this.name = name; this.picture = picture; - this.title = ( - <> - {picture} {name} - - ); } } +function MentionsTypeaheadMenuItem({ + index, + isSelected, + onClick, + onMouseEnter, + option, +}: { + index: number; + isSelected: boolean; + onClick: () => void; + onMouseEnter: () => void; + option: MentionTypeaheadOption; +}) { + let className = 'item'; + if (isSelected) { + className += ' selected'; + } + return ( +
  • + {option.picture} + {option.name} +
  • + ); +} + export default function NewMentionsPlugin(): JSX.Element | null { const [editor] = useLexicalComposerContext(); @@ -633,6 +664,35 @@ export default function NewMentionsPlugin(): JSX.Element | null { onSelectOption={onSelectOption} triggerFn={checkForMentionMatch} options={options} + menuRenderFn={( + anchorElementRef, + {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex}, + ) => + anchorElementRef.current && results.length + ? ReactDOM.createPortal( +
    +
      + {options.map((option, i: number) => ( + { + setHighlightedIndex(i); + selectOptionAndCleanUp(option); + }} + onMouseEnter={() => { + setHighlightedIndex(i); + }} + key={option.key} + option={option} + /> + ))} +
    +
    , + anchorElementRef.current, + ) + : null + } /> ); } diff --git a/packages/lexical-react/flow/LexicalAutoEmbedPlugin.js.flow b/packages/lexical-react/flow/LexicalAutoEmbedPlugin.js.flow index d44fe47e5b0..b30259da4d7 100644 --- a/packages/lexical-react/flow/LexicalAutoEmbedPlugin.js.flow +++ b/packages/lexical-react/flow/LexicalAutoEmbedPlugin.js.flow @@ -13,6 +13,7 @@ import {MenuOption} from '@lexical/react/LexicalTypeaheadMenuPlugin'; import type {LexicalCommand, LexicalEditor, NodeKey, TextNode} from 'lexical'; import * as React from 'react'; import {createCommand} from 'lexical'; +import type {MenuRenderFn} from './LexicalTypeaheadMenuPlugin'; export type EmbedMatchResult = { url: string, @@ -42,6 +43,7 @@ type LexicalAutoEmbedPluginProps = { embedFn: () => void, dismissFn: () => void, ) => Array, + menuRenderFn: MenuRenderFn, }; declare export class AutoEmbedOption extends MenuOption { diff --git a/packages/lexical-react/flow/LexicalNodeMenuPlugin.js.flow b/packages/lexical-react/flow/LexicalNodeMenuPlugin.js.flow index 716a9eca4dc..8376c186aa6 100644 --- a/packages/lexical-react/flow/LexicalNodeMenuPlugin.js.flow +++ b/packages/lexical-react/flow/LexicalNodeMenuPlugin.js.flow @@ -28,6 +28,17 @@ declare export class MenuOption { setRefElement(element: HTMLElement | null): void; } +export type MenuRenderFn = ( + anchorElementRef: {current: HTMLElement | null}, + itemProps: { + selectedIndex: number | null, + selectOptionAndCleanUp: (option: TOption) => void, + setHighlightedIndex: (index: number) => void, + options: Array, + }, + matchingString: string, +) => React.Portal | React.MixedElement | null; + export type TriggerFn = ( text: string, editor: LexicalEditor, @@ -44,6 +55,7 @@ type NodeMenuPluginProps = { nodeKey: NodeKey | null, onClose?: () => void, onOpen?: (resolution: MenuResolution) => void, + menuRenderFn: MenuRenderFn, anchorClassName?: string, }; diff --git a/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow b/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow index 3e4b7d2f4f7..82c3883298f 100644 --- a/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow +++ b/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow @@ -36,6 +36,17 @@ declare export var SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ option: MenuOption, }>; +export type MenuRenderFn = ( + anchorElementRef: {current: HTMLElement | null}, + itemProps: { + selectedIndex: number | null, + selectOptionAndCleanUp: (option: TOption) => void, + setHighlightedIndex: (index: number) => void, + options: Array, + }, + matchingString: string, +) => React.Portal | React.MixedElement | null; + declare export function getScrollParent( element: HTMLElement, includeHidden: boolean, @@ -55,6 +66,7 @@ export type TypeaheadMenuPluginProps = { matchingString: string, ) => void, options: Array, + menuRenderFn: MenuRenderFn, triggerFn: TriggerFn, onOpen?: (resolution: MenuResolution) => void, onClose?: () => void, diff --git a/packages/lexical-react/package.json b/packages/lexical-react/package.json index 8885770a759..9a50acb6441 100644 --- a/packages/lexical-react/package.json +++ b/packages/lexical-react/package.json @@ -460,6 +460,36 @@ "default": "./LexicalContentEditable.js" } }, + "./LexicalContextMenuPlugin": { + "import": { + "types": "./LexicalContextMenuPlugin.d.ts", + "development": "./LexicalContextMenuPlugin.dev.mjs", + "production": "./LexicalContextMenuPlugin.prod.mjs", + "node": "./LexicalContextMenuPlugin.node.mjs", + "default": "./LexicalContextMenuPlugin.mjs" + }, + "require": { + "types": "./LexicalContextMenuPlugin.d.ts", + "development": "./LexicalContextMenuPlugin.dev.js", + "production": "./LexicalContextMenuPlugin.prod.js", + "default": "./LexicalContextMenuPlugin.js" + } + }, + "./LexicalContextMenuPlugin.js": { + "import": { + "types": "./LexicalContextMenuPlugin.d.ts", + "development": "./LexicalContextMenuPlugin.dev.mjs", + "production": "./LexicalContextMenuPlugin.prod.mjs", + "node": "./LexicalContextMenuPlugin.node.mjs", + "default": "./LexicalContextMenuPlugin.mjs" + }, + "require": { + "types": "./LexicalContextMenuPlugin.d.ts", + "development": "./LexicalContextMenuPlugin.dev.js", + "production": "./LexicalContextMenuPlugin.prod.js", + "default": "./LexicalContextMenuPlugin.js" + } + }, "./LexicalDecoratorBlockNode": { "import": { "types": "./LexicalDecoratorBlockNode.d.ts", diff --git a/packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx b/packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx index d7ff71d225d..47191dee3b6 100644 --- a/packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx +++ b/packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx @@ -17,6 +17,7 @@ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { LexicalNodeMenuPlugin, MenuOption, + MenuRenderFn, } from '@lexical/react/LexicalNodeMenuPlugin'; import {mergeRegister} from '@lexical/utils'; import { @@ -32,6 +33,7 @@ import { TextNode, } from 'lexical'; import {useCallback, useEffect, useMemo, useState} from 'react'; +import * as React from 'react'; export type EmbedMatchResult = { url: string; @@ -82,6 +84,7 @@ type LexicalAutoEmbedPluginProps = { embedFn: () => void, dismissFn: () => void, ) => Array; + menuRenderFn: MenuRenderFn; menuCommandPriority?: CommandListenerPriority; }; @@ -89,6 +92,7 @@ export function LexicalAutoEmbedPlugin({ embedConfigs, onOpenEmbedModalForConfig, getMenuOptions, + menuRenderFn, menuCommandPriority = COMMAND_PRIORITY_LOW, }: LexicalAutoEmbedPluginProps): JSX.Element | null { const [editor] = useLexicalComposerContext(); @@ -231,6 +235,7 @@ export function LexicalAutoEmbedPlugin({ onClose={reset} onSelectOption={onSelectOption} options={options} + menuRenderFn={menuRenderFn} commandPriority={menuCommandPriority} /> ) : null; diff --git a/packages/lexical-react/src/LexicalContextMenuPlugin.tsx b/packages/lexical-react/src/LexicalContextMenuPlugin.tsx new file mode 100644 index 00000000000..aaaa3cdd893 --- /dev/null +++ b/packages/lexical-react/src/LexicalContextMenuPlugin.tsx @@ -0,0 +1,169 @@ +/** + * 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 type {MenuRenderFn, MenuResolution} from './shared/LexicalMenu'; +import type {JSX} from 'react'; + +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {calculateZoomLevel} from '@lexical/utils'; +import { + COMMAND_PRIORITY_LOW, + CommandListenerPriority, + isDOMNode, + LexicalNode, +} from 'lexical'; +import {ReactPortal, RefObject, useCallback, useEffect, useState} from 'react'; +import * as React from 'react'; + +import {LexicalMenu, MenuOption, useMenuAnchorRef} from './shared/LexicalMenu'; + +export type ContextMenuRenderFn = ( + anchorElementRef: RefObject, + itemProps: { + selectedIndex: number | null; + selectOptionAndCleanUp: (option: TOption) => void; + setHighlightedIndex: (index: number) => void; + options: Array; + }, + menuProps: { + setMenuRef: (element: HTMLElement | null) => void; + }, +) => ReactPortal | JSX.Element | null; + +export type LexicalContextMenuPluginProps = { + onSelectOption: ( + option: TOption, + textNodeContainingQuery: LexicalNode | null, + closeMenu: () => void, + matchingString: string, + ) => void; + options: Array; + onClose?: () => void; + onWillOpen?: (event: MouseEvent) => void; + onOpen?: (resolution: MenuResolution) => void; + menuRenderFn: ContextMenuRenderFn; + anchorClassName?: string; + commandPriority?: CommandListenerPriority; + parent?: HTMLElement; +}; + +const PRE_PORTAL_DIV_SIZE = 1; + +/** + * @deprecated Use LexicalNodeContextMenuPlugin instead. + */ +export function LexicalContextMenuPlugin({ + options, + onWillOpen, + onClose, + onOpen, + onSelectOption, + menuRenderFn: contextMenuRenderFn, + anchorClassName, + commandPriority = COMMAND_PRIORITY_LOW, + parent, +}: LexicalContextMenuPluginProps): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + const [resolution, setResolution] = useState(null); + const menuRef = React.useRef(null); + + const anchorElementRef = useMenuAnchorRef( + resolution, + setResolution, + anchorClassName, + parent, + ); + + const closeNodeMenu = useCallback(() => { + setResolution(null); + if (onClose != null && resolution !== null) { + onClose(); + } + }, [onClose, resolution]); + + const openNodeMenu = useCallback( + (res: MenuResolution) => { + setResolution(res); + if (onOpen != null && resolution === null) { + onOpen(res); + } + }, + [onOpen, resolution], + ); + + const handleContextMenu = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + if (onWillOpen != null) { + onWillOpen(event); + } + const zoom = calculateZoomLevel(event.target as Element); + openNodeMenu({ + getRect: () => + new DOMRect( + event.clientX / zoom, + event.clientY / zoom, + PRE_PORTAL_DIV_SIZE, + PRE_PORTAL_DIV_SIZE, + ), + }); + }, + [openNodeMenu, onWillOpen], + ); + + const handleClick = useCallback( + (event: MouseEvent) => { + if ( + resolution !== null && + menuRef.current != null && + event.target != null && + isDOMNode(event.target) && + !menuRef.current.contains(event.target) + ) { + closeNodeMenu(); + } + }, + [closeNodeMenu, resolution], + ); + + useEffect(() => { + const editorElement = editor.getRootElement(); + if (editorElement) { + editorElement.addEventListener('contextmenu', handleContextMenu); + return () => + editorElement.removeEventListener('contextmenu', handleContextMenu); + } + }, [editor, handleContextMenu]); + + useEffect(() => { + document.addEventListener('click', handleClick); + return () => document.removeEventListener('click', handleClick); + }, [editor, handleClick]); + + return anchorElementRef.current === null || + resolution === null || + editor === null ? null : ( + + contextMenuRenderFn(anchorRef, itemProps, { + setMenuRef: (ref) => { + menuRef.current = ref; + }, + }) + } + onSelectOption={onSelectOption} + commandPriority={commandPriority} + /> + ); +} + +export {MenuOption, MenuRenderFn, MenuResolution}; diff --git a/packages/lexical-react/src/LexicalNodeMenuPlugin.tsx b/packages/lexical-react/src/LexicalNodeMenuPlugin.tsx index b41caa6e440..068ee9c9791 100644 --- a/packages/lexical-react/src/LexicalNodeMenuPlugin.tsx +++ b/packages/lexical-react/src/LexicalNodeMenuPlugin.tsx @@ -6,7 +6,7 @@ * */ -import type {MenuResolution} from './shared/LexicalMenu'; +import type {MenuRenderFn, MenuResolution} from './shared/LexicalMenu'; import type {JSX} from 'react'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; @@ -34,6 +34,7 @@ export type NodeMenuPluginProps = { nodeKey: NodeKey | null; onClose?: () => void; onOpen?: (resolution: MenuResolution) => void; + menuRenderFn: MenuRenderFn; anchorClassName?: string; commandPriority?: CommandListenerPriority; parent?: HTMLElement; @@ -45,6 +46,7 @@ export function LexicalNodeMenuPlugin({ onClose, onOpen, onSelectOption, + menuRenderFn, anchorClassName, commandPriority = COMMAND_PRIORITY_LOW, parent, @@ -118,10 +120,11 @@ export function LexicalNodeMenuPlugin({ editor={editor} anchorElementRef={anchorElementRef} options={options} + menuRenderFn={menuRenderFn} onSelectOption={onSelectOption} commandPriority={commandPriority} /> ); } -export {MenuOption, MenuResolution}; +export {MenuOption, MenuRenderFn, MenuResolution}; diff --git a/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx b/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx index 3bea18975e5..40ff38ea246 100644 --- a/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx +++ b/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx @@ -7,6 +7,7 @@ */ import type { + MenuRenderFn, MenuResolution, MenuTextMatch, TriggerFn, @@ -201,6 +202,7 @@ export type TypeaheadMenuPluginProps = { matchingString: string, ) => void; options: Array; + menuRenderFn: MenuRenderFn; triggerFn: TriggerFn; onOpen?: (resolution: MenuResolution) => void; onClose?: () => void; @@ -217,6 +219,7 @@ export function LexicalTypeaheadMenuPlugin({ onSelectOption, onOpen, onClose, + menuRenderFn, triggerFn, anchorClassName, commandPriority = COMMAND_PRIORITY_LOW, @@ -339,6 +342,7 @@ export function LexicalTypeaheadMenuPlugin({ editor={editor} anchorElementRef={anchorElementRef} options={options} + menuRenderFn={menuRenderFn} shouldSplitNodeWithQuery={true} onSelectOption={onSelectOption} commandPriority={commandPriority} @@ -347,4 +351,4 @@ export function LexicalTypeaheadMenuPlugin({ ); } -export {MenuOption, MenuResolution, MenuTextMatch, TriggerFn}; +export {MenuOption, MenuRenderFn, MenuResolution, MenuTextMatch, TriggerFn}; diff --git a/packages/lexical-react/src/shared/LexicalMenu.tsx b/packages/lexical-react/src/shared/LexicalMenu.ts similarity index 91% rename from packages/lexical-react/src/shared/LexicalMenu.tsx rename to packages/lexical-react/src/shared/LexicalMenu.ts index 97e0c608340..0968665cc36 100644 --- a/packages/lexical-react/src/shared/LexicalMenu.tsx +++ b/packages/lexical-react/src/shared/LexicalMenu.ts @@ -25,8 +25,15 @@ import { LexicalEditor, TextNode, } from 'lexical'; -import {RefObject, useCallback, useEffect, useRef, useState} from 'react'; -import ReactDOM from 'react-dom'; +import { + ReactPortal, + RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import {CAN_USE_DOM} from 'shared/canUseDOM'; import useLayoutEffect from 'shared/useLayoutEffect'; @@ -41,11 +48,12 @@ export type MenuResolution = { getRect: () => DOMRect; }; +export const PUNCTUATION = + '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; + export class MenuOption { key: string; ref?: RefObject; - icon?: JSX.Element; - title?: JSX.Element | string | null; constructor(key: string) { this.key = key; @@ -58,6 +66,17 @@ export class MenuOption { } } +export type MenuRenderFn = ( + anchorElementRef: RefObject, + itemProps: { + selectedIndex: number | null; + selectOptionAndCleanUp: (option: TOption) => void; + setHighlightedIndex: (index: number) => void; + options: Array; + }, + matchingString: string | null, +) => ReactPortal | JSX.Element | null; + const scrollIntoViewIfNeeded = (target: HTMLElement) => { const typeaheadContainerNode = document.getElementById('typeahead-menu'); if (!typeaheadContainerNode) { @@ -243,46 +262,13 @@ export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ option: MenuOption; }> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND'); -function MenuItem({ - index, - isSelected, - onClick, - onMouseEnter, - option, -}: { - index: number; - isSelected: boolean; - onClick: () => void; - onMouseEnter: () => void; - option: MenuOption; -}) { - let className = 'item'; - if (isSelected) { - className += ' selected'; - } - return ( -
  • - {option.icon} - {option.title} -
  • - ); -} - export function LexicalMenu({ close, editor, anchorElementRef, resolution, options, + menuRenderFn, onSelectOption, shouldSplitNodeWithQuery = false, commandPriority = COMMAND_PRIORITY_LOW, @@ -294,6 +280,7 @@ export function LexicalMenu({ resolution: MenuResolution; options: Array; shouldSplitNodeWithQuery?: boolean; + menuRenderFn: MenuRenderFn; onSelectOption: ( option: TOption, textNodeContainingQuery: TextNode | null, @@ -351,39 +338,6 @@ export function LexicalMenu({ [editor], ); - const menuRenderFn = useCallback(() => { - return anchorElementRef.current && options.length - ? ReactDOM.createPortal( -
    -
      - {options.map((option, i: number) => ( - { - setHighlightedIndex(i); - selectOptionAndCleanUp(option); - }} - onMouseEnter={() => { - setHighlightedIndex(i); - }} - key={option.key} - option={option} - /> - ))} -
    -
    , - anchorElementRef.current, - ) - : null; - }, [ - anchorElementRef, - options, - selectedIndex, - selectOptionAndCleanUp, - setHighlightedIndex, - ]); - useEffect(() => { return () => { const rootElem = editor.getRootElement(); @@ -549,7 +503,21 @@ export function LexicalMenu({ commandPriority, ]); - return menuRenderFn(); + const listItemProps = useMemo( + () => ({ + options, + selectOptionAndCleanUp, + selectedIndex, + setHighlightedIndex, + }), + [selectOptionAndCleanUp, selectedIndex, options], + ); + + return menuRenderFn( + anchorElementRef, + listItemProps, + resolution.match ? resolution.match.matchingString : '', + ); } function setContainerDivAttributes( diff --git a/tsconfig.build.json b/tsconfig.build.json index 02a09792ea2..c65be773d11 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -73,6 +73,9 @@ "@lexical/react/LexicalContentEditable": [ "./packages/lexical-react/src/LexicalContentEditable.tsx" ], + "@lexical/react/LexicalContextMenuPlugin": [ + "./packages/lexical-react/src/LexicalContextMenuPlugin.tsx" + ], "@lexical/react/LexicalDecoratorBlockNode": [ "./packages/lexical-react/src/LexicalDecoratorBlockNode.ts" ], diff --git a/tsconfig.json b/tsconfig.json index ae7b47d6688..40227ec288d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -81,6 +81,9 @@ "@lexical/react/LexicalContentEditable": [ "./packages/lexical-react/src/LexicalContentEditable.tsx" ], + "@lexical/react/LexicalContextMenuPlugin": [ + "./packages/lexical-react/src/LexicalContextMenuPlugin.tsx" + ], "@lexical/react/LexicalDecoratorBlockNode": [ "./packages/lexical-react/src/LexicalDecoratorBlockNode.ts" ],