Skip to content

Commit

Permalink
Generalize Menu Logic (#4284)
Browse files Browse the repository at this point in the history
  • Loading branch information
tylerjbainbridge committed May 8, 2023
1 parent 639d636 commit ddcf5be
Show file tree
Hide file tree
Showing 13 changed files with 782 additions and 579 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {
LexicalTypeaheadMenuPlugin,
TypeaheadOption,
MenuOption,
useBasicTypeaheadTriggerMatch,
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import {
Expand All @@ -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<string>;
Expand Down
12 changes: 6 additions & 6 deletions packages/lexical-playground/src/plugins/MentionsPlugin/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -559,7 +559,7 @@ function checkForCapitalizedNameMentions(
function checkForAtSignMentions(
text: string,
minMatchLength: number,
): QueryMatch | null {
): MenuTextMatch | null {
let match = AtSignMentionsRegex.exec(text);

if (match === null) {
Expand All @@ -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;

Expand Down
1 change: 1 addition & 0 deletions packages/lexical-playground/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ const moduleResolution = [
'LexicalCollaborationPlugin',
'LexicalHistoryPlugin',
'LexicalTypeaheadMenuPlugin',
'LexicalNodeMenuPlugin',
'LexicalTablePlugin',
'LexicalLinkPlugin',
'LexicalListPlugin',
Expand Down
1 change: 1 addition & 0 deletions packages/lexical-playground/vite.prod.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ const moduleResolution = [
'LexicalCollaborationPlugin',
'LexicalHistoryPlugin',
'LexicalTypeaheadMenuPlugin',
'LexicalNodeMenuPlugin',
'LexicalTablePlugin',
'LexicalLinkPlugin',
'LexicalListPlugin',
Expand Down
4 changes: 2 additions & 2 deletions packages/lexical-react/flow/LexicalAutoEmbedPlugin.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -46,7 +46,7 @@ type LexicalAutoEmbedPluginProps<TEmbedConfig> = {
menuRenderFn: MenuRenderFn<AutoEmbedOption>,
};

declare export class AutoEmbedOption extends TypeaheadOption {
declare export class AutoEmbedOption extends MenuOption {
title: string;
icon: React.MixedElement;
onSelect: (targetNode: LexicalNode | null) => void;
Expand Down
64 changes: 64 additions & 0 deletions packages/lexical-react/flow/LexicalNodeMenuPlugin.js.flow
Original file line number Diff line number Diff line change
@@ -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<TOption> = (
anchorElementRef: {current: HTMLElement | null},
itemProps: {
selectedIndex: number | null,
selectOptionAndCleanUp: (option: TOption) => void,
setHighlightedIndex: (index: number) => void,
options: Array<TOption>,
},
matchingString: string,
) => React.Portal | React.MixedElement | null;

export type TriggerFn = (
text: string,
editor: LexicalEditor,
) => MenuTextMatch | null;

type NodeMenuPluginProps<TOption> = {
onSelectOption: (
option: TOption,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
matchingString: string,
) => void,
options: Array<TOption>,
nodeKey: NodeKey | null,
onClose?: () => void,
onOpen?: (resolution: MenuResolution) => void,
menuRenderFn: MenuRenderFn<TOption>,
anchorClassName?: string,
};

declare export function LexicalNodeMenuPlugin<TOption>(
options: NodeMenuPluginProps<TOption>,
): React.MixedElement | null;
33 changes: 7 additions & 26 deletions packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<TOption> = (
Expand Down Expand Up @@ -63,35 +63,16 @@ export type TypeaheadMenuPluginProps<TOption> = {
options: Array<TOption>,
menuRenderFn: MenuRenderFn<TOption>,
triggerFn: TriggerFn,
onOpen?: (resolution: Resolution) => void,
onOpen?: (resolution: MenuResolution) => void,
onClose?: () => void,
anchorClassName?: string,
};

export type TriggerFn = (
text: string,
editor: LexicalEditor,
) => QueryMatch | null;
) => MenuTextMatch | null;

declare export function LexicalTypeaheadMenuPlugin<TOption>(
options: TypeaheadMenuPluginProps<TOption>,
): React.MixedElement | null;

type NodeMenuPluginProps<TOption> = {
onSelectOption: (
option: TOption,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
matchingString: string,
) => void,
options: Array<TOption>,
nodeKey: NodeKey | null,
onClose?: () => void,
onOpen?: (resolution: Resolution) => void,
menuRenderFn: MenuRenderFn<TOption>,
anchorClassName?: string,
};

declare export function LexicalNodeMenuPlugin<TOption>(
options: NodeMenuPluginProps<TOption>,
): React.MixedElement | null;
6 changes: 3 additions & 3 deletions packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -54,7 +54,7 @@ export const URL_MATCHER =
export const INSERT_EMBED_COMMAND: LexicalCommand<EmbedConfig['type']> =
createCommand('INSERT_EMBED_COMMAND');

export class AutoEmbedOption extends TypeaheadOption {
export class AutoEmbedOption extends MenuOption {
title: string;
onSelect: (targetNode: LexicalNode | null) => void;
constructor(
Expand Down
122 changes: 122 additions & 0 deletions packages/lexical-react/src/LexicalNodeMenuPlugin.tsx
Original file line number Diff line number Diff line change
@@ -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<TOption extends MenuOption> = {
onSelectOption: (
option: TOption,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
matchingString: string,
) => void;
options: Array<TOption>;
nodeKey: NodeKey | null;
onClose?: () => void;
onOpen?: (resolution: MenuResolution) => void;
menuRenderFn: MenuRenderFn<TOption>;
anchorClassName?: string;
};

export function LexicalNodeMenuPlugin<TOption extends MenuOption>({
options,
nodeKey,
onClose,
onOpen,
onSelectOption,
menuRenderFn,
anchorClassName,
}: NodeMenuPluginProps<TOption>): JSX.Element | null {
const [editor] = useLexicalComposerContext();
const [resolution, setResolution] = useState<MenuResolution | null>(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 : (
<LexicalMenu
close={closeNodeMenu}
resolution={resolution}
editor={editor}
anchorElementRef={anchorElementRef}
options={options}
menuRenderFn={menuRenderFn}
onSelectOption={onSelectOption}
/>
);
}

export {MenuOption, MenuRenderFn, MenuResolution};

2 comments on commit ddcf5be

@vercel
Copy link

@vercel vercel bot commented on ddcf5be May 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

lexical – ./packages/lexical-website

lexical-git-main-fbopensource.vercel.app
lexicaljs.com
lexical-fbopensource.vercel.app
lexical.dev
lexicaljs.org
www.lexical.dev

@vercel
Copy link

@vercel vercel bot commented on ddcf5be May 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

lexical-playground – ./packages/lexical-playground

lexical-playground-git-main-fbopensource.vercel.app
lexical-playground.vercel.app
lexical-playground-fbopensource.vercel.app
playground.lexical.dev

Please sign in to comment.