From ddcf5be3372c9509ac81eaaf22710384e8579b55 Mon Sep 17 00:00:00 2001 From: Tyler Bainbridge Date: Mon, 8 May 2023 14:54:13 -0400 Subject: [PATCH] Generalize Menu Logic (#4284) --- .../plugins/ComponentPickerPlugin/index.tsx | 4 +- .../src/plugins/EmojiPickerPlugin/index.tsx | 4 +- .../src/plugins/MentionsPlugin/index.tsx | 12 +- packages/lexical-playground/vite.config.js | 1 + .../lexical-playground/vite.prod.config.js | 1 + .../flow/LexicalAutoEmbedPlugin.js.flow | 4 +- .../flow/LexicalNodeMenuPlugin.js.flow | 64 ++ .../flow/LexicalTypeaheadMenuPlugin.js.flow | 33 +- .../src/LexicalAutoEmbedPlugin.tsx | 6 +- .../src/LexicalNodeMenuPlugin.tsx | 122 ++++ .../src/LexicalTypeaheadMenuPlugin.tsx | 557 +----------------- .../lexical-react/src/shared/LexicalMenu.ts | 549 +++++++++++++++++ tsconfig.json | 4 + 13 files changed, 782 insertions(+), 579 deletions(-) create mode 100644 packages/lexical-react/flow/LexicalNodeMenuPlugin.js.flow create mode 100644 packages/lexical-react/src/LexicalNodeMenuPlugin.tsx create mode 100644 packages/lexical-react/src/shared/LexicalMenu.ts diff --git a/packages/lexical-playground/src/plugins/ComponentPickerPlugin/index.tsx b/packages/lexical-playground/src/plugins/ComponentPickerPlugin/index.tsx index cadd08d3f99..6f537028d08 100644 --- a/packages/lexical-playground/src/plugins/ComponentPickerPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ComponentPickerPlugin/index.tsx @@ -17,7 +17,7 @@ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {INSERT_HORIZONTAL_RULE_COMMAND} from '@lexical/react/LexicalHorizontalRuleNode'; import { LexicalTypeaheadMenuPlugin, - TypeaheadOption, + MenuOption, useBasicTypeaheadTriggerMatch, } from '@lexical/react/LexicalTypeaheadMenuPlugin'; import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text'; @@ -44,7 +44,7 @@ import {INSERT_IMAGE_COMMAND, InsertImageDialog} from '../ImagesPlugin'; import {InsertPollDialog} from '../PollPlugin'; import {InsertNewTableDialog, InsertTableDialog} from '../TablePlugin'; -class ComponentPickerOption extends TypeaheadOption { +class ComponentPickerOption extends MenuOption { // What shows up in the editor title: string; // Icon for display diff --git a/packages/lexical-playground/src/plugins/EmojiPickerPlugin/index.tsx b/packages/lexical-playground/src/plugins/EmojiPickerPlugin/index.tsx index f73cb76bdd4..05e7c5660ce 100644 --- a/packages/lexical-playground/src/plugins/EmojiPickerPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/EmojiPickerPlugin/index.tsx @@ -9,7 +9,7 @@ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { LexicalTypeaheadMenuPlugin, - TypeaheadOption, + MenuOption, useBasicTypeaheadTriggerMatch, } from '@lexical/react/LexicalTypeaheadMenuPlugin'; import { @@ -22,7 +22,7 @@ import * as React from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react'; import * as ReactDOM from 'react-dom'; -class EmojiOption extends TypeaheadOption { +class EmojiOption extends MenuOption { title: string; emoji: string; keywords: Array; diff --git a/packages/lexical-playground/src/plugins/MentionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/MentionsPlugin/index.tsx index 3db1c0b3fe5..268f811fa2c 100644 --- a/packages/lexical-playground/src/plugins/MentionsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/MentionsPlugin/index.tsx @@ -9,8 +9,8 @@ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { LexicalTypeaheadMenuPlugin, - QueryMatch, - TypeaheadOption, + MenuOption, + MenuTextMatch, useBasicTypeaheadTriggerMatch, } from '@lexical/react/LexicalTypeaheadMenuPlugin'; import {TextNode} from 'lexical'; @@ -537,7 +537,7 @@ function useMentionLookupService(mentionString: string | null) { function checkForCapitalizedNameMentions( text: string, minMatchLength: number, -): QueryMatch | null { +): MenuTextMatch | null { const match = CapitalizedNameMentionsRegex.exec(text); if (match !== null) { // The strategy ignores leading whitespace but we need to know it's @@ -559,7 +559,7 @@ function checkForCapitalizedNameMentions( function checkForAtSignMentions( text: string, minMatchLength: number, -): QueryMatch | null { +): MenuTextMatch | null { let match = AtSignMentionsRegex.exec(text); if (match === null) { @@ -582,12 +582,12 @@ function checkForAtSignMentions( return null; } -function getPossibleQueryMatch(text: string): QueryMatch | null { +function getPossibleQueryMatch(text: string): MenuTextMatch | null { const match = checkForAtSignMentions(text, 1); return match === null ? checkForCapitalizedNameMentions(text, 3) : match; } -class MentionTypeaheadOption extends TypeaheadOption { +class MentionTypeaheadOption extends MenuOption { name: string; picture: JSX.Element; diff --git a/packages/lexical-playground/vite.config.js b/packages/lexical-playground/vite.config.js index 316c38851d1..1561db880bd 100644 --- a/packages/lexical-playground/vite.config.js +++ b/packages/lexical-playground/vite.config.js @@ -136,6 +136,7 @@ const moduleResolution = [ 'LexicalCollaborationPlugin', 'LexicalHistoryPlugin', 'LexicalTypeaheadMenuPlugin', + 'LexicalNodeMenuPlugin', 'LexicalTablePlugin', 'LexicalLinkPlugin', 'LexicalListPlugin', diff --git a/packages/lexical-playground/vite.prod.config.js b/packages/lexical-playground/vite.prod.config.js index fcf6fa4d54d..a6688be6b55 100644 --- a/packages/lexical-playground/vite.prod.config.js +++ b/packages/lexical-playground/vite.prod.config.js @@ -136,6 +136,7 @@ const moduleResolution = [ 'LexicalCollaborationPlugin', 'LexicalHistoryPlugin', 'LexicalTypeaheadMenuPlugin', + 'LexicalNodeMenuPlugin', 'LexicalTablePlugin', 'LexicalLinkPlugin', 'LexicalListPlugin', diff --git a/packages/lexical-react/flow/LexicalAutoEmbedPlugin.js.flow b/packages/lexical-react/flow/LexicalAutoEmbedPlugin.js.flow index a8032dd00ca..b30259da4d7 100644 --- a/packages/lexical-react/flow/LexicalAutoEmbedPlugin.js.flow +++ b/packages/lexical-react/flow/LexicalAutoEmbedPlugin.js.flow @@ -9,7 +9,7 @@ import type {LexicalNode, MutationListener} from 'lexical'; -import {TypeaheadOption} from '@lexical/react/LexicalTypeaheadMenuPlugin'; +import {MenuOption} from '@lexical/react/LexicalTypeaheadMenuPlugin'; import type {LexicalCommand, LexicalEditor, NodeKey, TextNode} from 'lexical'; import * as React from 'react'; import {createCommand} from 'lexical'; @@ -46,7 +46,7 @@ type LexicalAutoEmbedPluginProps = { menuRenderFn: MenuRenderFn, }; -declare export class AutoEmbedOption extends TypeaheadOption { +declare export class AutoEmbedOption extends MenuOption { title: string; icon: React.MixedElement; onSelect: (targetNode: LexicalNode | null) => void; diff --git a/packages/lexical-react/flow/LexicalNodeMenuPlugin.js.flow b/packages/lexical-react/flow/LexicalNodeMenuPlugin.js.flow new file mode 100644 index 00000000000..8376c186aa6 --- /dev/null +++ b/packages/lexical-react/flow/LexicalNodeMenuPlugin.js.flow @@ -0,0 +1,64 @@ +/** + * 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. + * + * @flow strict + */ + +import type {LexicalCommand, LexicalEditor, NodeKey, TextNode} from 'lexical'; +import * as React from 'react'; + +export type MenuTextMatch = { + leadOffset: number, + matchingString: string, + replaceableString: string, +}; + +export type MenuResolution = { + match?: MenuTextMatch, + getRect: () => ClientRect, +}; + +declare export class MenuOption { + key: string; + ref: {current: HTMLElement | null}; + constructor(key: string): void; + 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, +) => MenuTextMatch | null; + +type NodeMenuPluginProps = { + onSelectOption: ( + option: TOption, + textNodeContainingQuery: TextNode | null, + closeMenu: () => void, + matchingString: string, + ) => void, + options: Array, + nodeKey: NodeKey | null, + onClose?: () => void, + onOpen?: (resolution: MenuResolution) => void, + menuRenderFn: MenuRenderFn, + anchorClassName?: string, +}; + +declare export function LexicalNodeMenuPlugin( + options: NodeMenuPluginProps, +): React.MixedElement | null; diff --git a/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow b/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow index de7c8934906..f0acbd9a23f 100644 --- a/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow +++ b/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow @@ -10,21 +10,21 @@ import type {LexicalCommand, LexicalEditor, NodeKey, TextNode} from 'lexical'; import * as React from 'react'; -export type QueryMatch = { +export type MenuTextMatch = { leadOffset: number, matchingString: string, replaceableString: string, }; -export type Resolution = { - match: QueryMatch, +export type MenuResolution = { + match?: MenuTextMatch, getRect: () => ClientRect, }; export const PUNCTUATION: string = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; -declare export class TypeaheadOption { +declare export class MenuOption { key: string; ref: {current: HTMLElement | null}; constructor(key: string): void; @@ -33,7 +33,7 @@ declare export class TypeaheadOption { declare export var SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ index: number, - option: TypeaheadOption, + option: MenuOption, }>; export type MenuRenderFn = ( @@ -63,7 +63,7 @@ export type TypeaheadMenuPluginProps = { options: Array, menuRenderFn: MenuRenderFn, triggerFn: TriggerFn, - onOpen?: (resolution: Resolution) => void, + onOpen?: (resolution: MenuResolution) => void, onClose?: () => void, anchorClassName?: string, }; @@ -71,27 +71,8 @@ export type TypeaheadMenuPluginProps = { export type TriggerFn = ( text: string, editor: LexicalEditor, -) => QueryMatch | null; +) => MenuTextMatch | null; declare export function LexicalTypeaheadMenuPlugin( options: TypeaheadMenuPluginProps, ): React.MixedElement | null; - -type NodeMenuPluginProps = { - onSelectOption: ( - option: TOption, - textNodeContainingQuery: TextNode | null, - closeMenu: () => void, - matchingString: string, - ) => void, - options: Array, - nodeKey: NodeKey | null, - onClose?: () => void, - onOpen?: (resolution: Resolution) => void, - menuRenderFn: MenuRenderFn, - anchorClassName?: string, -}; - -declare export function LexicalNodeMenuPlugin( - options: NodeMenuPluginProps, -): React.MixedElement | null; diff --git a/packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx b/packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx index 2fc0c3b5edb..2294c8291fd 100644 --- a/packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx +++ b/packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx @@ -11,9 +11,9 @@ import {$isLinkNode, AutoLinkNode, LinkNode} from '@lexical/link'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { LexicalNodeMenuPlugin, + MenuOption, MenuRenderFn, - TypeaheadOption, -} from '@lexical/react/LexicalTypeaheadMenuPlugin'; +} from '@lexical/react/LexicalNodeMenuPlugin'; import {mergeRegister} from '@lexical/utils'; import { $getNodeByKey, @@ -54,7 +54,7 @@ export const URL_MATCHER = export const INSERT_EMBED_COMMAND: LexicalCommand = createCommand('INSERT_EMBED_COMMAND'); -export class AutoEmbedOption extends TypeaheadOption { +export class AutoEmbedOption extends MenuOption { title: string; onSelect: (targetNode: LexicalNode | null) => void; constructor( diff --git a/packages/lexical-react/src/LexicalNodeMenuPlugin.tsx b/packages/lexical-react/src/LexicalNodeMenuPlugin.tsx new file mode 100644 index 00000000000..9bb03284038 --- /dev/null +++ b/packages/lexical-react/src/LexicalNodeMenuPlugin.tsx @@ -0,0 +1,122 @@ +/** + * 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 {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$getNodeByKey, NodeKey, TextNode} from 'lexical'; +import {useCallback, useEffect, useState} from 'react'; +import * as React from 'react'; + +import {LexicalMenu, MenuOption, useMenuAnchorRef} from './shared/LexicalMenu'; + +function startTransition(callback: () => void) { + if (React.startTransition) { + React.startTransition(callback); + } else { + callback(); + } +} + +export type NodeMenuPluginProps = { + onSelectOption: ( + option: TOption, + textNodeContainingQuery: TextNode | null, + closeMenu: () => void, + matchingString: string, + ) => void; + options: Array; + nodeKey: NodeKey | null; + onClose?: () => void; + onOpen?: (resolution: MenuResolution) => void; + menuRenderFn: MenuRenderFn; + anchorClassName?: string; +}; + +export function LexicalNodeMenuPlugin({ + options, + nodeKey, + onClose, + onOpen, + onSelectOption, + menuRenderFn, + anchorClassName, +}: NodeMenuPluginProps): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + const [resolution, setResolution] = useState(null); + const anchorElementRef = useMenuAnchorRef( + resolution, + setResolution, + anchorClassName, + ); + + 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 positionOrCloseMenu = useCallback(() => { + if (nodeKey) { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + const domElement = editor.getElementByKey(nodeKey); + if (node != null && domElement != null) { + if (resolution == null) { + startTransition(() => + openNodeMenu({ + getRect: () => domElement.getBoundingClientRect(), + }), + ); + } + } + }); + } else if (nodeKey == null && resolution != null) { + closeNodeMenu(); + } + }, [closeNodeMenu, editor, nodeKey, openNodeMenu, resolution]); + + useEffect(() => { + positionOrCloseMenu(); + }, [positionOrCloseMenu, nodeKey]); + + useEffect(() => { + if (nodeKey != null) { + return editor.registerUpdateListener(({dirtyElements}) => { + if (dirtyElements.get(nodeKey)) { + positionOrCloseMenu(); + } + }); + } + }, [editor, positionOrCloseMenu, nodeKey]); + + return resolution === null || editor === null ? null : ( + + ); +} + +export {MenuOption, MenuRenderFn, MenuResolution}; diff --git a/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx b/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx index ed727e61d0e..8cd2cf658d3 100644 --- a/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx +++ b/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx @@ -6,99 +6,32 @@ * */ +import type { + MenuRenderFn, + MenuResolution, + MenuTextMatch, + TriggerFn, +} from './shared/LexicalMenu'; + import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {mergeRegister} from '@lexical/utils'; import { - $getNodeByKey, $getSelection, $isRangeSelection, $isTextNode, - COMMAND_PRIORITY_LOW, createCommand, - KEY_ARROW_DOWN_COMMAND, - KEY_ARROW_UP_COMMAND, - KEY_ENTER_COMMAND, - KEY_ESCAPE_COMMAND, - KEY_TAB_COMMAND, LexicalCommand, LexicalEditor, - NodeKey, RangeSelection, TextNode, } from 'lexical'; -import { - MutableRefObject, - ReactPortal, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import {useCallback, useEffect, useState} from 'react'; import * as React from 'react'; -import useLayoutEffect from 'shared/useLayoutEffect'; - -export type QueryMatch = { - leadOffset: number; - matchingString: string; - replaceableString: string; -}; -export type Resolution = { - match: QueryMatch; - getRect: () => DOMRect; -}; +import {LexicalMenu, MenuOption, useMenuAnchorRef} from './shared/LexicalMenu'; export const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; -export class TypeaheadOption { - key: string; - ref?: MutableRefObject; - - constructor(key: string) { - this.key = key; - this.ref = {current: null}; - this.setRefElement = this.setRefElement.bind(this); - } - - setRefElement(element: HTMLElement | null) { - this.ref = {current: element}; - } -} - -export type MenuRenderFn = ( - anchorElementRef: MutableRefObject, - itemProps: { - selectedIndex: number | null; - selectOptionAndCleanUp: (option: TOption) => void; - setHighlightedIndex: (index: number) => void; - options: Array; - }, - matchingString: string, -) => ReactPortal | JSX.Element | null; - -const scrollIntoViewIfNeeded = (target: HTMLElement) => { - const typeaheadContainerNode = document.getElementById('typeahead-menu'); - if (!typeaheadContainerNode) return; - - const typeaheadRect = typeaheadContainerNode.getBoundingClientRect(); - - if (typeaheadRect.top + typeaheadRect.height > window.innerHeight) { - typeaheadContainerNode.scrollIntoView({ - block: 'center', - }); - } - - if (typeaheadRect.top < 0) { - typeaheadContainerNode.scrollIntoView({ - block: 'center', - }); - } - - target.scrollIntoView({block: 'nearest'}); -}; - function getTextUpToAnchor(selection: RangeSelection): string | null { const anchor = selection.anchor; if (anchor.type !== 'text') { @@ -147,66 +80,6 @@ function getQueryTextForSearch(editor: LexicalEditor): string | null { return text; } -/** - * Walk backwards along user input and forward through entity title to try - * and replace more of the user's text with entity. - */ -function getFullMatchOffset( - documentText: string, - entryText: string, - offset: number, -): number { - let triggerOffset = offset; - for (let i = triggerOffset; i <= entryText.length; i++) { - if (documentText.substr(-i) === entryText.substr(0, i)) { - triggerOffset = i; - } - } - return triggerOffset; -} - -/** - * Split Lexical TextNode and return a new TextNode only containing matched text. - * Common use cases include: removing the node, replacing with a new node. - */ -function splitNodeContainingQuery( - editor: LexicalEditor, - match: QueryMatch, -): TextNode | null { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return null; - } - const anchor = selection.anchor; - if (anchor.type !== 'text') { - return null; - } - const anchorNode = anchor.getNode(); - if (!anchorNode.isSimpleText()) { - return null; - } - const selectionOffset = anchor.offset; - const textContent = anchorNode.getTextContent().slice(0, selectionOffset); - const characterOffset = match.replaceableString.length; - const queryOffset = getFullMatchOffset( - textContent, - match.matchingString, - characterOffset, - ); - const startOffset = selectionOffset - queryOffset; - if (startOffset < 0) { - return null; - } - let newNode; - if (startOffset === 0) { - [newNode] = anchorNode.splitText(selectionOffset); - } else { - [, newNode] = anchorNode.splitText(startOffset, selectionOffset); - } - - return newNode; -} - function isSelectionOnEntityBoundary( editor: LexicalEditor, offset: number, @@ -276,7 +149,7 @@ function isTriggerVisibleInNearestScrollContainer( // Reposition the menu on scroll, window resize, and element resize. export function useDynamicPositioning( - resolution: Resolution | null, + resolution: MenuResolution | null, targetElement: HTMLElement | null, onReposition: () => void, onVisibilityChange?: (isInView: boolean) => void, @@ -331,225 +204,9 @@ export function useDynamicPositioning( export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ index: number; - option: TypeaheadOption; + option: MenuOption; }> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND'); -function LexicalPopoverMenu({ - close, - editor, - anchorElementRef, - resolution, - options, - menuRenderFn, - onSelectOption, -}: { - close: () => void; - editor: LexicalEditor; - anchorElementRef: MutableRefObject; - resolution: Resolution; - options: Array; - menuRenderFn: MenuRenderFn; - onSelectOption: ( - option: TOption, - textNodeContainingQuery: TextNode | null, - closeMenu: () => void, - matchingString: string, - ) => void; -}): JSX.Element | null { - const [selectedIndex, setHighlightedIndex] = useState(null); - - useEffect(() => { - setHighlightedIndex(0); - }, [resolution.match.matchingString]); - - const selectOptionAndCleanUp = useCallback( - (selectedEntry: TOption) => { - editor.update(() => { - const textNodeContainingQuery = splitNodeContainingQuery( - editor, - resolution.match, - ); - - onSelectOption( - selectedEntry, - textNodeContainingQuery, - close, - resolution.match.matchingString, - ); - }); - }, - [close, editor, resolution.match, onSelectOption], - ); - - const updateSelectedIndex = useCallback( - (index: number) => { - const rootElem = editor.getRootElement(); - if (rootElem !== null) { - rootElem.setAttribute( - 'aria-activedescendant', - 'typeahead-item-' + index, - ); - setHighlightedIndex(index); - } - }, - [editor], - ); - - useEffect(() => { - return () => { - const rootElem = editor.getRootElement(); - if (rootElem !== null) { - rootElem.removeAttribute('aria-activedescendant'); - } - }; - }, [editor]); - - useLayoutEffect(() => { - if (options === null) { - setHighlightedIndex(null); - } else if (selectedIndex === null) { - updateSelectedIndex(0); - } - }, [options, selectedIndex, updateSelectedIndex]); - - useEffect(() => { - return mergeRegister( - editor.registerCommand( - SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, - ({option}) => { - if (option.ref && option.ref.current != null) { - scrollIntoViewIfNeeded(option.ref.current); - return true; - } - - return false; - }, - COMMAND_PRIORITY_LOW, - ), - ); - }, [editor, updateSelectedIndex]); - - useEffect(() => { - return mergeRegister( - editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, - (payload) => { - const event = payload; - if (options !== null && options.length && selectedIndex !== null) { - const newSelectedIndex = - selectedIndex !== options.length - 1 ? selectedIndex + 1 : 0; - updateSelectedIndex(newSelectedIndex); - const option = options[newSelectedIndex]; - if (option.ref != null && option.ref.current) { - editor.dispatchCommand( - SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, - { - index: newSelectedIndex, - option, - }, - ); - } - event.preventDefault(); - event.stopImmediatePropagation(); - } - return true; - }, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_ARROW_UP_COMMAND, - (payload) => { - const event = payload; - if (options !== null && options.length && selectedIndex !== null) { - const newSelectedIndex = - selectedIndex !== 0 ? selectedIndex - 1 : options.length - 1; - updateSelectedIndex(newSelectedIndex); - const option = options[newSelectedIndex]; - if (option.ref != null && option.ref.current) { - scrollIntoViewIfNeeded(option.ref.current); - } - event.preventDefault(); - event.stopImmediatePropagation(); - } - return true; - }, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_ESCAPE_COMMAND, - (payload) => { - const event = payload; - event.preventDefault(); - event.stopImmediatePropagation(); - close(); - return true; - }, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_TAB_COMMAND, - (payload) => { - const event = payload; - if ( - options === null || - selectedIndex === null || - options[selectedIndex] == null - ) { - return false; - } - event.preventDefault(); - event.stopImmediatePropagation(); - selectOptionAndCleanUp(options[selectedIndex]); - return true; - }, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_ENTER_COMMAND, - (event: KeyboardEvent | null) => { - if ( - options === null || - selectedIndex === null || - options[selectedIndex] == null - ) { - return false; - } - if (event !== null) { - event.preventDefault(); - event.stopImmediatePropagation(); - } - selectOptionAndCleanUp(options[selectedIndex]); - return true; - }, - COMMAND_PRIORITY_LOW, - ), - ); - }, [ - selectOptionAndCleanUp, - close, - editor, - options, - selectedIndex, - updateSelectedIndex, - ]); - - const listItemProps = useMemo( - () => ({ - options, - selectOptionAndCleanUp, - selectedIndex, - setHighlightedIndex, - }), - [selectOptionAndCleanUp, selectedIndex, options], - ); - - return menuRenderFn( - anchorElementRef, - listItemProps, - resolution.match.matchingString, - ); -} - export function useBasicTypeaheadTriggerMatch( trigger: string, {minLength = 1, maxLength = 75}: {minLength?: number; maxLength?: number}, @@ -587,79 +244,7 @@ export function useBasicTypeaheadTriggerMatch( ); } -function useMenuAnchorRef( - resolution: Resolution | null, - setResolution: (r: Resolution | null) => void, - className?: string, -): MutableRefObject { - const [editor] = useLexicalComposerContext(); - const anchorElementRef = useRef(document.createElement('div')); - const positionMenu = useCallback(() => { - const rootElement = editor.getRootElement(); - const containerDiv = anchorElementRef.current; - - if (rootElement !== null && resolution !== null) { - const {left, top, width, height} = resolution.getRect(); - containerDiv.style.top = `${top + window.pageYOffset}px`; - containerDiv.style.left = `${left + window.pageXOffset}px`; - containerDiv.style.height = `${height}px`; - containerDiv.style.width = `${width}px`; - - if (!containerDiv.isConnected) { - if (className != null) { - containerDiv.className = className; - } - containerDiv.setAttribute('aria-label', 'Typeahead menu'); - containerDiv.setAttribute('id', 'typeahead-menu'); - containerDiv.setAttribute('role', 'listbox'); - containerDiv.style.display = 'block'; - containerDiv.style.position = 'absolute'; - document.body.append(containerDiv); - } - anchorElementRef.current = containerDiv; - rootElement.setAttribute('aria-controls', 'typeahead-menu'); - } - }, [editor, resolution, className]); - - useEffect(() => { - const rootElement = editor.getRootElement(); - if (resolution !== null) { - positionMenu(); - return () => { - if (rootElement !== null) { - rootElement.removeAttribute('aria-controls'); - } - - const containerDiv = anchorElementRef.current; - if (containerDiv !== null && containerDiv.isConnected) { - containerDiv.remove(); - } - }; - } - }, [editor, positionMenu, resolution]); - - const onVisibilityChange = useCallback( - (isInView: boolean) => { - if (resolution !== null) { - if (!isInView) { - setResolution(null); - } - } - }, - [resolution, setResolution], - ); - - useDynamicPositioning( - resolution, - anchorElementRef.current, - positionMenu, - onVisibilityChange, - ); - - return anchorElementRef; -} - -export type TypeaheadMenuPluginProps = { +export type TypeaheadMenuPluginProps = { onQueryChange: (matchingString: string | null) => void; onSelectOption: ( option: TOption, @@ -670,17 +255,12 @@ export type TypeaheadMenuPluginProps = { options: Array; menuRenderFn: MenuRenderFn; triggerFn: TriggerFn; - onOpen?: (resolution: Resolution) => void; + onOpen?: (resolution: MenuResolution) => void; onClose?: () => void; anchorClassName?: string; }; -export type TriggerFn = ( - text: string, - editor: LexicalEditor, -) => QueryMatch | null; - -export function LexicalTypeaheadMenuPlugin({ +export function LexicalTypeaheadMenuPlugin({ options, onQueryChange, onSelectOption, @@ -691,7 +271,7 @@ export function LexicalTypeaheadMenuPlugin({ anchorClassName, }: TypeaheadMenuPluginProps): JSX.Element | null { const [editor] = useLexicalComposerContext(); - const [resolution, setResolution] = useState(null); + const [resolution, setResolution] = useState(null); const anchorElementRef = useMenuAnchorRef( resolution, setResolution, @@ -706,7 +286,7 @@ export function LexicalTypeaheadMenuPlugin({ }, [onClose, resolution]); const openTypeahead = useCallback( - (res: Resolution) => { + (res: MenuResolution) => { setResolution(res); if (onOpen != null && resolution === null) { onOpen(res); @@ -769,116 +349,17 @@ export function LexicalTypeaheadMenuPlugin({ ]); return resolution === null || editor === null ? null : ( - ); } -type NodeMenuPluginProps = { - onSelectOption: ( - option: TOption, - textNodeContainingQuery: TextNode | null, - closeMenu: () => void, - matchingString: string, - ) => void; - options: Array; - nodeKey: NodeKey | null; - onClose?: () => void; - onOpen?: (resolution: Resolution) => void; - menuRenderFn: MenuRenderFn; - anchorClassName?: string; -}; - -export function LexicalNodeMenuPlugin({ - options, - nodeKey, - onClose, - onOpen, - onSelectOption, - menuRenderFn, - anchorClassName, -}: NodeMenuPluginProps): JSX.Element | null { - const [editor] = useLexicalComposerContext(); - const [resolution, setResolution] = useState(null); - const anchorElementRef = useMenuAnchorRef( - resolution, - setResolution, - anchorClassName, - ); - - const closeNodeMenu = useCallback(() => { - setResolution(null); - if (onClose != null && resolution !== null) { - onClose(); - } - }, [onClose, resolution]); - - const openNodeMenu = useCallback( - (res: Resolution) => { - setResolution(res); - if (onOpen != null && resolution === null) { - onOpen(res); - } - }, - [onOpen, resolution], - ); - - const positionOrCloseMenu = useCallback(() => { - if (nodeKey) { - editor.update(() => { - const node = $getNodeByKey(nodeKey); - const domElement = editor.getElementByKey(nodeKey); - if (node != null && domElement != null) { - const text = node.getTextContent(); - if (resolution == null || resolution.match.matchingString !== text) { - startTransition(() => - openNodeMenu({ - getRect: () => domElement.getBoundingClientRect(), - match: { - leadOffset: text.length, - matchingString: text, - replaceableString: text, - }, - }), - ); - } - } - }); - } else if (nodeKey == null && resolution != null) { - closeNodeMenu(); - } - }, [closeNodeMenu, editor, nodeKey, openNodeMenu, resolution]); - - useEffect(() => { - positionOrCloseMenu(); - }, [positionOrCloseMenu, nodeKey]); - - useEffect(() => { - if (nodeKey != null) { - return editor.registerUpdateListener(({dirtyElements}) => { - if (dirtyElements.get(nodeKey)) { - positionOrCloseMenu(); - } - }); - } - }, [editor, positionOrCloseMenu, nodeKey]); - - return resolution === null || editor === null ? null : ( - - ); -} +export {MenuOption, MenuRenderFn, MenuResolution, MenuTextMatch, TriggerFn}; diff --git a/packages/lexical-react/src/shared/LexicalMenu.ts b/packages/lexical-react/src/shared/LexicalMenu.ts new file mode 100644 index 00000000000..65bff506903 --- /dev/null +++ b/packages/lexical-react/src/shared/LexicalMenu.ts @@ -0,0 +1,549 @@ +/** + * 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 {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {mergeRegister} from '@lexical/utils'; +import { + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_LOW, + createCommand, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + KEY_TAB_COMMAND, + LexicalCommand, + LexicalEditor, + TextNode, +} from 'lexical'; +import { + MutableRefObject, + ReactPortal, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import useLayoutEffect from 'shared/useLayoutEffect'; + +export type MenuTextMatch = { + leadOffset: number; + matchingString: string; + replaceableString: string; +}; + +export type MenuResolution = { + match?: MenuTextMatch; + getRect: () => DOMRect; +}; + +export const PUNCTUATION = + '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; + +export class MenuOption { + key: string; + ref?: MutableRefObject; + + constructor(key: string) { + this.key = key; + this.ref = {current: null}; + this.setRefElement = this.setRefElement.bind(this); + } + + setRefElement(element: HTMLElement | null) { + this.ref = {current: element}; + } +} + +export type MenuRenderFn = ( + anchorElementRef: MutableRefObject, + 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) return; + + const typeaheadRect = typeaheadContainerNode.getBoundingClientRect(); + + if (typeaheadRect.top + typeaheadRect.height > window.innerHeight) { + typeaheadContainerNode.scrollIntoView({ + block: 'center', + }); + } + + if (typeaheadRect.top < 0) { + typeaheadContainerNode.scrollIntoView({ + block: 'center', + }); + } + + target.scrollIntoView({block: 'nearest'}); +}; + +/** + * Walk backwards along user input and forward through entity title to try + * and replace more of the user's text with entity. + */ +function getFullMatchOffset( + documentText: string, + entryText: string, + offset: number, +): number { + let triggerOffset = offset; + for (let i = triggerOffset; i <= entryText.length; i++) { + if (documentText.substr(-i) === entryText.substr(0, i)) { + triggerOffset = i; + } + } + return triggerOffset; +} + +/** + * Split Lexical TextNode and return a new TextNode only containing matched text. + * Common use cases include: removing the node, replacing with a new node. + */ +function $splitNodeContainingQuery(match: MenuTextMatch): TextNode | null { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return null; + } + const anchor = selection.anchor; + if (anchor.type !== 'text') { + return null; + } + const anchorNode = anchor.getNode(); + if (!anchorNode.isSimpleText()) { + return null; + } + const selectionOffset = anchor.offset; + const textContent = anchorNode.getTextContent().slice(0, selectionOffset); + const characterOffset = match.replaceableString.length; + const queryOffset = getFullMatchOffset( + textContent, + match.matchingString, + characterOffset, + ); + const startOffset = selectionOffset - queryOffset; + if (startOffset < 0) { + return null; + } + let newNode; + if (startOffset === 0) { + [newNode] = anchorNode.splitText(selectionOffset); + } else { + [, newNode] = anchorNode.splitText(startOffset, selectionOffset); + } + + return newNode; +} + +// Got from https://stackoverflow.com/a/42543908/2013580 +export function getScrollParent( + element: HTMLElement, + includeHidden: boolean, +): HTMLElement | HTMLBodyElement { + let style = getComputedStyle(element); + const excludeStaticParent = style.position === 'absolute'; + const overflowRegex = includeHidden + ? /(auto|scroll|hidden)/ + : /(auto|scroll)/; + if (style.position === 'fixed') { + return document.body; + } + for ( + let parent: HTMLElement | null = element; + (parent = parent.parentElement); + + ) { + style = getComputedStyle(parent); + if (excludeStaticParent && style.position === 'static') { + continue; + } + if ( + overflowRegex.test(style.overflow + style.overflowY + style.overflowX) + ) { + return parent; + } + } + return document.body; +} + +function isTriggerVisibleInNearestScrollContainer( + targetElement: HTMLElement, + containerElement: HTMLElement, +): boolean { + const tRect = targetElement.getBoundingClientRect(); + const cRect = containerElement.getBoundingClientRect(); + return tRect.top > cRect.top && tRect.top < cRect.bottom; +} + +// Reposition the menu on scroll, window resize, and element resize. +export function useDynamicPositioning( + resolution: MenuResolution | null, + targetElement: HTMLElement | null, + onReposition: () => void, + onVisibilityChange?: (isInView: boolean) => void, +) { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + if (targetElement != null && resolution != null) { + const rootElement = editor.getRootElement(); + const rootScrollParent = + rootElement != null + ? getScrollParent(rootElement, false) + : document.body; + let ticking = false; + let previousIsInView = isTriggerVisibleInNearestScrollContainer( + targetElement, + rootScrollParent, + ); + const handleScroll = function () { + if (!ticking) { + window.requestAnimationFrame(function () { + onReposition(); + ticking = false; + }); + ticking = true; + } + const isInView = isTriggerVisibleInNearestScrollContainer( + targetElement, + rootScrollParent, + ); + if (isInView !== previousIsInView) { + previousIsInView = isInView; + if (onVisibilityChange != null) { + onVisibilityChange(isInView); + } + } + }; + const resizeObserver = new ResizeObserver(onReposition); + window.addEventListener('resize', onReposition); + document.addEventListener('scroll', handleScroll, { + capture: true, + passive: true, + }); + resizeObserver.observe(targetElement); + return () => { + resizeObserver.unobserve(targetElement); + window.removeEventListener('resize', onReposition); + document.removeEventListener('scroll', handleScroll); + }; + } + }, [targetElement, editor, onVisibilityChange, onReposition, resolution]); +} + +export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ + index: number; + option: MenuOption; +}> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND'); + +export function LexicalMenu({ + close, + editor, + anchorElementRef, + resolution, + options, + menuRenderFn, + onSelectOption, + shouldSplitNodeWithQuery = false, +}: { + close: () => void; + editor: LexicalEditor; + anchorElementRef: MutableRefObject; + resolution: MenuResolution; + options: Array; + shouldSplitNodeWithQuery?: boolean; + menuRenderFn: MenuRenderFn; + onSelectOption: ( + option: TOption, + textNodeContainingQuery: TextNode | null, + closeMenu: () => void, + matchingString: string, + ) => void; +}): JSX.Element | null { + const [selectedIndex, setHighlightedIndex] = useState(null); + + const matchingString = resolution.match && resolution.match.matchingString; + + useEffect(() => { + setHighlightedIndex(0); + }, [matchingString]); + + const selectOptionAndCleanUp = useCallback( + (selectedEntry: TOption) => { + editor.update(() => { + const textNodeContainingQuery = + resolution.match != null && shouldSplitNodeWithQuery + ? $splitNodeContainingQuery(resolution.match) + : null; + + onSelectOption( + selectedEntry, + textNodeContainingQuery, + close, + resolution.match ? resolution.match.matchingString : '', + ); + }); + }, + [editor, shouldSplitNodeWithQuery, resolution.match, onSelectOption, close], + ); + + const updateSelectedIndex = useCallback( + (index: number) => { + const rootElem = editor.getRootElement(); + if (rootElem !== null) { + rootElem.setAttribute( + 'aria-activedescendant', + 'typeahead-item-' + index, + ); + setHighlightedIndex(index); + } + }, + [editor], + ); + + useEffect(() => { + return () => { + const rootElem = editor.getRootElement(); + if (rootElem !== null) { + rootElem.removeAttribute('aria-activedescendant'); + } + }; + }, [editor]); + + useLayoutEffect(() => { + if (options === null) { + setHighlightedIndex(null); + } else if (selectedIndex === null) { + updateSelectedIndex(0); + } + }, [options, selectedIndex, updateSelectedIndex]); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, + ({option}) => { + if (option.ref && option.ref.current != null) { + scrollIntoViewIfNeeded(option.ref.current); + return true; + } + + return false; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [editor, updateSelectedIndex]); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + KEY_ARROW_DOWN_COMMAND, + (payload) => { + const event = payload; + if (options !== null && options.length && selectedIndex !== null) { + const newSelectedIndex = + selectedIndex !== options.length - 1 ? selectedIndex + 1 : 0; + updateSelectedIndex(newSelectedIndex); + const option = options[newSelectedIndex]; + if (option.ref != null && option.ref.current) { + editor.dispatchCommand( + SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, + { + index: newSelectedIndex, + option, + }, + ); + } + event.preventDefault(); + event.stopImmediatePropagation(); + } + return true; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_ARROW_UP_COMMAND, + (payload) => { + const event = payload; + if (options !== null && options.length && selectedIndex !== null) { + const newSelectedIndex = + selectedIndex !== 0 ? selectedIndex - 1 : options.length - 1; + updateSelectedIndex(newSelectedIndex); + const option = options[newSelectedIndex]; + if (option.ref != null && option.ref.current) { + scrollIntoViewIfNeeded(option.ref.current); + } + event.preventDefault(); + event.stopImmediatePropagation(); + } + return true; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_ESCAPE_COMMAND, + (payload) => { + const event = payload; + event.preventDefault(); + event.stopImmediatePropagation(); + close(); + return true; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_TAB_COMMAND, + (payload) => { + const event = payload; + if ( + options === null || + selectedIndex === null || + options[selectedIndex] == null + ) { + return false; + } + event.preventDefault(); + event.stopImmediatePropagation(); + selectOptionAndCleanUp(options[selectedIndex]); + return true; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_ENTER_COMMAND, + (event: KeyboardEvent | null) => { + if ( + options === null || + selectedIndex === null || + options[selectedIndex] == null + ) { + return false; + } + if (event !== null) { + event.preventDefault(); + event.stopImmediatePropagation(); + } + selectOptionAndCleanUp(options[selectedIndex]); + return true; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [ + selectOptionAndCleanUp, + close, + editor, + options, + selectedIndex, + updateSelectedIndex, + ]); + + const listItemProps = useMemo( + () => ({ + options, + selectOptionAndCleanUp, + selectedIndex, + setHighlightedIndex, + }), + [selectOptionAndCleanUp, selectedIndex, options], + ); + + return menuRenderFn( + anchorElementRef, + listItemProps, + resolution.match ? resolution.match.matchingString : '', + ); +} + +export function useMenuAnchorRef( + resolution: MenuResolution | null, + setResolution: (r: MenuResolution | null) => void, + className?: string, +): MutableRefObject { + const [editor] = useLexicalComposerContext(); + const anchorElementRef = useRef(document.createElement('div')); + const positionMenu = useCallback(() => { + const rootElement = editor.getRootElement(); + const containerDiv = anchorElementRef.current; + + if (rootElement !== null && resolution !== null) { + const {left, top, width, height} = resolution.getRect(); + containerDiv.style.top = `${top + window.pageYOffset}px`; + containerDiv.style.left = `${left + window.pageXOffset}px`; + containerDiv.style.height = `${height}px`; + containerDiv.style.width = `${width}px`; + + if (!containerDiv.isConnected) { + if (className != null) { + containerDiv.className = className; + } + containerDiv.setAttribute('aria-label', 'Typeahead menu'); + containerDiv.setAttribute('id', 'typeahead-menu'); + containerDiv.setAttribute('role', 'listbox'); + containerDiv.style.display = 'block'; + containerDiv.style.position = 'absolute'; + document.body.append(containerDiv); + } + anchorElementRef.current = containerDiv; + rootElement.setAttribute('aria-controls', 'typeahead-menu'); + } + }, [editor, resolution, className]); + + useEffect(() => { + const rootElement = editor.getRootElement(); + if (resolution !== null) { + positionMenu(); + return () => { + if (rootElement !== null) { + rootElement.removeAttribute('aria-controls'); + } + + const containerDiv = anchorElementRef.current; + if (containerDiv !== null && containerDiv.isConnected) { + containerDiv.remove(); + } + }; + } + }, [editor, positionMenu, resolution]); + + const onVisibilityChange = useCallback( + (isInView: boolean) => { + if (resolution !== null) { + if (!isInView) { + setResolution(null); + } + } + }, + [resolution, setResolution], + ); + + useDynamicPositioning( + resolution, + anchorElementRef.current, + positionMenu, + onVisibilityChange, + ); + + return anchorElementRef; +} + +export type TriggerFn = ( + text: string, + editor: LexicalEditor, +) => MenuTextMatch | null; diff --git a/tsconfig.json b/tsconfig.json index 2aa866ffe00..121b501eb95 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -87,6 +87,10 @@ "@lexical/react/LexicalTypeaheadMenuPlugin": [ "packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx" ], + "@lexical/react/LexicalNodeMenuPlugin": [ + "packages/lexical-react/src/LexicalNodeMenuPlugin.tsx" + ], + "@lexical/react/LexicalHistoryPlugin": [ "./packages/lexical-react/src/LexicalHistoryPlugin.ts" ],