Lexical: Autosuggest: Still needs cleanup

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2023-03-27 23:49:37 +02:00
parent 8e31499763
commit 750306ee0e
2 changed files with 35 additions and 699 deletions

View file

@ -7,11 +7,6 @@
*/ */
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
QueryMatch,
TypeaheadOption,
useBasicTypeaheadTriggerMatch,
} from '@lexical/react/LexicalTypeaheadMenuPlugin';
import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity'; import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity';
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
@ -29,153 +24,6 @@ import type { RangeSelection, TextNode } from 'lexical';
const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i'); const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i');
const PUNCTUATION =
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';
const NAME = '\\b[A-Z][^\\s' + PUNCTUATION + ']';
const DocumentMentionsRegex = {
NAME,
PUNCTUATION,
};
const CapitalizedNameMentionsRegex = new RegExp(
'(^|[^#])((?:' + DocumentMentionsRegex.NAME + '{' + 1 + ',})$)',
);
const PUNC = DocumentMentionsRegex.PUNCTUATION;
const TRIGGERS = ['@'].join('');
// Chars we expect to see in a mention (non-space, non-punctuation).
const VALID_CHARS = '[^' + TRIGGERS + PUNC + '\\s]';
const AtSignMentionsRegex = REGEX;
// 50 is the longest alias length limit.
const ALIAS_LENGTH_LIMIT = 50;
// Regex used to match alias.
const AtSignMentionsRegexAliasRegex = new RegExp(
'(^|\\s|\\()(' +
'[' +
TRIGGERS +
']' +
'((?:' +
VALID_CHARS +
'){0,' +
ALIAS_LENGTH_LIMIT +
'})' +
')$',
);
const mentionsCache = new Map();
const dummyMentionsData = ['Test'];
const dummyLookupService = {
search(string: string, callback: (results: Array<string>) => void): void {
setTimeout(() => {
const results = dummyMentionsData.filter((mention) =>
mention.toLowerCase().includes(string.toLowerCase()),
);
callback(results);
}, 500);
},
};
const useMentionLookupService = (mentionString: string | null) => {
const [results, setResults] = useState<Array<string>>([]);
useEffect(() => {
const cachedResults = mentionsCache.get(mentionString);
if (mentionString === null) {
setResults([]);
return;
}
if (cachedResults === null) {
return;
} else if (cachedResults !== undefined) {
setResults(cachedResults);
return;
}
mentionsCache.set(mentionString, null);
dummyLookupService.search(mentionString, (newResults) => {
mentionsCache.set(mentionString, newResults);
setResults(newResults);
});
}, [mentionString]);
return results;
};
const checkForCapitalizedNameMentions = (
text: string,
minMatchLength: number,
): QueryMatch | null => {
const match = CapitalizedNameMentionsRegex.exec(text);
if (match !== null) {
// The strategy ignores leading whitespace but we need to know it's
// length to add it to the leadOffset
const maybeLeadingWhitespace = match[1];
const matchingString = match[2];
if (matchingString !== null && matchingString.length >= minMatchLength) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: matchingString,
};
}
}
return null;
};
const checkForAtSignMentions = (
text: string,
minMatchLength: number,
): QueryMatch | null => {
let match = AtSignMentionsRegex.exec(text);
if (match === null) {
match = AtSignMentionsRegexAliasRegex.exec(text);
}
if (match !== null) {
// The strategy ignores leading whitespace but we need to know it's
// length to add it to the leadOffset
const maybeLeadingWhitespace = match[1];
const matchingString = match[3];
if (matchingString.length >= minMatchLength) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[2],
};
}
}
return null;
};
const getPossibleQueryMatch = (text: string): QueryMatch | null => {
const match = checkForAtSignMentions(text, 1);
return match === null ? checkForCapitalizedNameMentions(text, 3) : match;
};
class MentionTypeaheadOption extends TypeaheadOption {
name: string;
picture: JSX.Element;
constructor(name: string, picture: JSX.Element) {
super(name);
this.name = name;
this.picture = picture;
}
}
export const MentionPlugin: React.FC<{ export const MentionPlugin: React.FC<{
composeId: string composeId: string
suggestionsHidden: boolean suggestionsHidden: boolean
@ -188,19 +36,8 @@ export const MentionPlugin: React.FC<{
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
const [queryString, setQueryString] = useState<string | null>(null);
const [selectedSuggestion] = useState(0); const [selectedSuggestion] = useState(0);
const results = useMentionLookupService(queryString);
const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
minLength: 0,
});
const options = [new MentionTypeaheadOption('', <i className='icon user' />)];
const onSelectOption = useCallback(() => { }, [editor]);
const onSelectSuggestion: React.MouseEventHandler<HTMLDivElement> = (e) => { const onSelectSuggestion: React.MouseEventHandler<HTMLDivElement> = (e) => {
e.preventDefault(); e.preventDefault();
@ -220,15 +57,6 @@ export const MentionPlugin: React.FC<{
}); });
}; };
const checkForMentionMatch = useCallback(
(text: string) => {
const mentionMatch = getPossibleQueryMatch(text);
const slashMatch = checkForSlashTriggerMatch(text, editor);
return !slashMatch && mentionMatch ? mentionMatch : null;
},
[checkForSlashTriggerMatch, editor],
);
useEffect(() => { useEffect(() => {
if (!editor.hasNodes([MentionNode])) { if (!editor.hasNodes([MentionNode])) {
throw new Error('MentionPlugin: MentionNode not registered on editor'); throw new Error('MentionPlugin: MentionNode not registered on editor');
@ -239,14 +67,17 @@ export const MentionPlugin: React.FC<{
return $createMentionNode(textNode.getTextContent()); return $createMentionNode(textNode.getTextContent());
}, []); }, []);
const getMentionMatch = useCallback((text: string) => { const getMentionMatch = (text: string) => {
const matchArr = REGEX.exec(text); const matchArr = REGEX.exec(text);
if (matchArr === null) { if (!matchArr) return null;
return null; return matchArr;
} };
dispatch(fetchComposeSuggestions(composeId, matchArr[0])); const getEntityMatch = useCallback((text: string) => {
const matchArr = getMentionMatch(text);
if (!matchArr) return null;
const mentionLength = matchArr[3].length + 1; const mentionLength = matchArr[3].length + 1;
const startOffset = matchArr.index + matchArr[1].length; const startOffset = matchArr.index + matchArr[1].length;
@ -257,8 +88,21 @@ export const MentionPlugin: React.FC<{
}; };
}, []); }, []);
const checkForMentionMatch = useCallback((text: string) => {
const matchArr = getMentionMatch(text);
if (!matchArr) return null;
dispatch(fetchComposeSuggestions(composeId, matchArr[0]));
return {
leadOffset: matchArr.index,
matchingString: matchArr[0],
};
}, []);
useLexicalTextEntity<MentionNode>( useLexicalTextEntity<MentionNode>(
getMentionMatch, getEntityMatch,
MentionNode, MentionNode,
createMentionNode, createMentionNode,
); );
@ -289,19 +133,12 @@ export const MentionPlugin: React.FC<{
}, [suggestions]); }, [suggestions]);
return ( return (
<TypeaheadMenuPlugin<MentionTypeaheadOption> <TypeaheadMenuPlugin
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForMentionMatch} triggerFn={checkForMentionMatch}
options={options} menuRenderFn={(anchorElementRef) =>
menuRenderFn={( anchorElementRef.current
anchorElementRef,
{ selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) =>
anchorElementRef.current && results.length
? ReactDOM.createPortal( ? ReactDOM.createPortal(
<div <div
// style={setPortalPosition()}
className={clsx({ className={clsx({
'mt-6 fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true, 'mt-6 fixed z-1000 shadow bg-white dark:bg-gray-900 rounded-lg py-1 space-y-0 dark:ring-2 dark:ring-primary-700 focus:outline-none': true,
hidden: suggestionsHidden || suggestions.isEmpty(), hidden: suggestionsHidden || suggestions.isEmpty(),

View file

@ -7,32 +7,18 @@
*/ */
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { mergeRegister } from '@lexical/utils';
import { import {
$getNodeByKey,
$getSelection, $getSelection,
$isRangeSelection, $isRangeSelection,
$isTextNode, $isTextNode,
COMMAND_PRIORITY_LOW,
createCommand,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
KEY_TAB_COMMAND,
LexicalCommand,
LexicalEditor, LexicalEditor,
NodeKey,
RangeSelection, RangeSelection,
TextNode,
} from 'lexical'; } from 'lexical';
import React, { import React, {
MutableRefObject, MutableRefObject,
ReactPortal, ReactPortal,
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect,
useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
@ -40,7 +26,6 @@ import React, {
export type QueryMatch = { export type QueryMatch = {
leadOffset: number leadOffset: number
matchingString: string matchingString: string
replaceableString: string
}; };
export type Resolution = { export type Resolution = {
@ -48,61 +33,10 @@ export type Resolution = {
getRect: () => DOMRect getRect: () => DOMRect
}; };
export const PUNCTUATION = export type MenuRenderFn = (
'\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;';
export class TypeaheadOption {
key: string;
ref?: MutableRefObject<HTMLElement | null>;
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<TOption extends TypeaheadOption> = (
anchorElementRef: MutableRefObject<HTMLElement | null>, anchorElementRef: MutableRefObject<HTMLElement | null>,
itemProps: {
selectedIndex: number | null
selectOptionAndCleanUp: (option: TOption) => void
setHighlightedIndex: (index: number) => void
options: Array<TOption>
},
matchingString: string,
) => ReactPortal | JSX.Element | null; ) => ReactPortal | JSX.Element | null;
const scrollIntoViewIfNeeded = (target: HTMLElement) => {
const container = document.getElementById('typeahead-menu');
if (!container) return;
const typeaheadContainerNode = container.querySelector('.typeahead-popover');
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 tryToPositionRange(leadOffset: number, range: Range): boolean { function tryToPositionRange(leadOffset: number, range: Range): boolean {
const domSelection = window.getSelection(); const domSelection = window.getSelection();
if (domSelection === null || !domSelection.isCollapsed) { if (domSelection === null || !domSelection.isCollapsed) {
@ -135,63 +69,6 @@ function getQueryTextForSearch(editor: LexicalEditor): string | null {
return null; return null;
} }
/**
* 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: QueryMatch): TextNode | null {
const selection = $getSelection();
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return null;
}
const anchor = selection.anchor;
if (!['mention', 'text'].includes(anchor.type)) {
return null;
}
const anchorNode = anchor.getNode();
if (anchor.type === 'text' && !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( function isSelectionOnEntityBoundary(
editor: LexicalEditor, editor: LexicalEditor,
offset: number, offset: number,
@ -314,265 +191,19 @@ export function useDynamicPositioning(
}, [targetElement, editor, onVisibilityChange, onReposition, resolution]); }, [targetElement, editor, onVisibilityChange, onReposition, resolution]);
} }
export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ function LexicalPopoverMenu({
index: number
option: TypeaheadOption
}> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND');
function LexicalPopoverMenu<TOption extends TypeaheadOption>({
close,
editor,
anchorElementRef, anchorElementRef,
resolution,
options,
menuRenderFn, menuRenderFn,
onSelectOption,
}: { }: {
close: () => void
editor: LexicalEditor
anchorElementRef: MutableRefObject<HTMLElement> anchorElementRef: MutableRefObject<HTMLElement>
resolution: Resolution menuRenderFn: MenuRenderFn
options: Array<TOption>
menuRenderFn: MenuRenderFn<TOption>
onSelectOption: (
option: TOption,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
matchingString: string,
) => void
}): JSX.Element | null { }): JSX.Element | null {
const [selectedIndex, setHighlightedIndex] = useState<null | number>(null); return menuRenderFn(anchorElementRef);
useEffect(() => {
setHighlightedIndex(0);
}, [resolution.match.matchingString]);
const selectOptionAndCleanUp = useCallback(
(selectedEntry: TOption) => {
editor.update(() => {
const textNodeContainingQuery = splitNodeContainingQuery(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) {
scrollIntoViewIfNeeded(option.ref.current);
return true;
}
return false;
},
COMMAND_PRIORITY_LOW,
),
);
}, [editor, updateSelectedIndex]);
useEffect(() => {
return mergeRegister(
editor.registerCommand<KeyboardEvent>(
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 && 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<KeyboardEvent>(
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 && option.ref.current) {
scrollIntoViewIfNeeded(option.ref.current);
}
event.preventDefault();
event.stopImmediatePropagation();
}
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<KeyboardEvent>(
KEY_ESCAPE_COMMAND,
(payload) => {
const event = payload;
event.preventDefault();
event.stopImmediatePropagation();
close();
return true;
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<KeyboardEvent>(
KEY_TAB_COMMAND,
(payload) => {
const event = payload;
if (
options === null ||
selectedIndex === null ||
!options[selectedIndex]
) {
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]
) {
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},
): TriggerFn {
return useCallback(
(text: string) => {
const validChars = '[^' + trigger + PUNCTUATION + '\\s]';
const TypeaheadTriggerRegex = new RegExp(
'(^|\\s|\\()(' +
'[' +
trigger +
']' +
'((?:' +
validChars +
'){0,' +
maxLength +
'})' +
')$',
);
const match = TypeaheadTriggerRegex.exec(text);
if (match !== null) {
const maybeLeadingWhitespace = match[1];
const matchingString = match[3];
if (matchingString.length >= minLength) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[2],
};
}
}
return null;
},
[maxLength, minLength, trigger],
);
} }
function useMenuAnchorRef( function useMenuAnchorRef(
resolution: Resolution | null, resolution: Resolution | null,
setResolution: (r: Resolution | null) => void, setResolution: (r: Resolution | null) => void,
className?: string,
): MutableRefObject<HTMLElement> { ): MutableRefObject<HTMLElement> {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
const anchorElementRef = useRef<HTMLElement>(document.createElement('div')); const anchorElementRef = useRef<HTMLElement>(document.createElement('div'));
@ -588,9 +219,6 @@ function useMenuAnchorRef(
containerDiv.style.width = `${width}px`; containerDiv.style.width = `${width}px`;
if (!containerDiv.isConnected) { if (!containerDiv.isConnected) {
if (className) {
containerDiv.className = className;
}
containerDiv.setAttribute('aria-label', 'Typeahead menu'); containerDiv.setAttribute('aria-label', 'Typeahead menu');
containerDiv.setAttribute('id', 'typeahead-menu'); containerDiv.setAttribute('id', 'typeahead-menu');
containerDiv.setAttribute('role', 'listbox'); containerDiv.setAttribute('role', 'listbox');
@ -601,7 +229,7 @@ function useMenuAnchorRef(
anchorElementRef.current = containerDiv; anchorElementRef.current = containerDiv;
rootElement.setAttribute('aria-controls', 'typeahead-menu'); rootElement.setAttribute('aria-controls', 'typeahead-menu');
} }
}, [editor, resolution, className]); }, [editor, resolution]);
useEffect(() => { useEffect(() => {
const rootElement = editor.getRootElement(); const rootElement = editor.getRootElement();
@ -641,20 +269,11 @@ function useMenuAnchorRef(
return anchorElementRef; return anchorElementRef;
} }
export type TypeaheadMenuPluginProps<TOption extends TypeaheadOption> = { export type TypeaheadMenuPluginProps = {
onQueryChange: (matchingString: string | null) => void menuRenderFn: MenuRenderFn
onSelectOption: (
option: TOption,
textNodeContainingQuery: TextNode | null,
closeMenu: () => void,
matchingString: string,
) => void
options: Array<TOption>
menuRenderFn: MenuRenderFn<TOption>
triggerFn: TriggerFn triggerFn: TriggerFn
onOpen?: (resolution: Resolution) => void onOpen?: (resolution: Resolution) => void
onClose?: () => void onClose?: () => void
anchorClassName?: string
}; };
export type TriggerFn = ( export type TriggerFn = (
@ -662,22 +281,17 @@ export type TriggerFn = (
editor: LexicalEditor, editor: LexicalEditor,
) => QueryMatch | null; ) => QueryMatch | null;
export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({ export function TypeaheadMenuPlugin({
options,
onQueryChange,
onSelectOption,
onOpen, onOpen,
onClose, onClose,
menuRenderFn, menuRenderFn,
triggerFn, triggerFn,
anchorClassName, }: TypeaheadMenuPluginProps): JSX.Element | null {
}: TypeaheadMenuPluginProps<TOption>): JSX.Element | null {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
const [resolution, setResolution] = useState<Resolution | null>(null); const [resolution, setResolution] = useState<Resolution | null>(null);
const anchorElementRef = useMenuAnchorRef( const anchorElementRef = useMenuAnchorRef(
resolution, resolution,
setResolution, setResolution,
anchorClassName,
); );
const closeTypeahead = useCallback(() => { const closeTypeahead = useCallback(() => {
@ -701,21 +315,14 @@ export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({
const updateListener = () => { const updateListener = () => {
editor.getEditorState().read(() => { editor.getEditorState().read(() => {
const range = document.createRange(); const range = document.createRange();
const selection = $getSelection();
const text = getQueryTextForSearch(editor); const text = getQueryTextForSearch(editor);
if ( if (!text) {
!$isRangeSelection(selection) ||
!selection.isCollapsed() ||
text === null ||
range === null
) {
closeTypeahead(); closeTypeahead();
return; return;
} }
const match = triggerFn(text, editor); const match = triggerFn(text, editor);
onQueryChange(match ? match.matchingString : null);
if ( if (
match !== null && match !== null &&
@ -744,7 +351,6 @@ export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({
}, [ }, [
editor, editor,
triggerFn, triggerFn,
onQueryChange,
resolution, resolution,
closeTypeahead, closeTypeahead,
openTypeahead, openTypeahead,
@ -752,115 +358,8 @@ export function TypeaheadMenuPlugin<TOption extends TypeaheadOption>({
return resolution === null || editor === null ? null : ( return resolution === null || editor === null ? null : (
<LexicalPopoverMenu <LexicalPopoverMenu
close={closeTypeahead}
resolution={resolution}
editor={editor}
anchorElementRef={anchorElementRef} anchorElementRef={anchorElementRef}
options={options}
menuRenderFn={menuRenderFn} menuRenderFn={menuRenderFn}
onSelectOption={onSelectOption}
/>
);
}
type NodeMenuPluginProps<TOption extends TypeaheadOption> = {
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
};
export function LexicalNodeMenuPlugin<TOption extends TypeaheadOption>({
options,
nodeKey,
onClose,
onOpen,
onSelectOption,
menuRenderFn,
anchorClassName,
}: NodeMenuPluginProps<TOption>): JSX.Element | null {
const [editor] = useLexicalComposerContext();
const [resolution, setResolution] = useState<Resolution | null>(null);
const anchorElementRef = useMenuAnchorRef(
resolution,
setResolution,
anchorClassName,
);
const closeNodeMenu = useCallback(() => {
setResolution(null);
if (onClose && resolution !== null) {
onClose();
}
}, [onClose, resolution]);
const openNodeMenu = useCallback(
(res: Resolution) => {
setResolution(res);
if (onOpen && resolution === null) {
onOpen(res);
}
},
[onOpen, resolution],
);
const positionOrCloseMenu = useCallback(() => {
if (nodeKey) {
editor.update(() => {
const node = $getNodeByKey(nodeKey);
const domElement = editor.getElementByKey(nodeKey);
if (node && domElement) {
const text = node.getTextContent();
if (!resolution || resolution.match.matchingString !== text) {
startTransition(() =>
openNodeMenu({
getRect: () => domElement.getBoundingClientRect(),
match: {
leadOffset: text.length,
matchingString: text,
replaceableString: text,
},
}),
);
}
}
});
} else if (!nodeKey && resolution) {
closeNodeMenu();
}
}, [closeNodeMenu, editor, nodeKey, openNodeMenu, resolution]);
useEffect(() => {
positionOrCloseMenu();
}, [positionOrCloseMenu, nodeKey]);
useEffect(() => {
if (nodeKey) {
return editor.registerUpdateListener(({ dirtyElements }) => {
if (dirtyElements.get(nodeKey)) {
positionOrCloseMenu();
}
});
}
}, [editor, positionOrCloseMenu, nodeKey]);
return resolution === null || editor === null ? null : (
<LexicalPopoverMenu
close={closeNodeMenu}
resolution={resolution}
editor={editor}
anchorElementRef={anchorElementRef}
options={options}
menuRenderFn={menuRenderFn}
onSelectOption={onSelectOption}
/> />
); );
} }