/** * 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 { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_LOW, type CommandListenerPriority, getDOMSelection, getDOMSelectionPoints, type LexicalEditor, type RangeSelection, type TextNode, } from 'lexical'; import { type JSX, startTransition, useCallback, useEffect, useState, } from 'react'; import { LexicalMenu, MenuOption, type MenuRenderFn, type MenuResolution, type MenuTextMatch, type TriggerFn, useMenuAnchorRef, } from './shared/LexicalMenu'; function getTextUpToAnchor(selection: RangeSelection): string | null { const anchor = selection.anchor; if (anchor.type !== 'text') { return null; } const anchorNode = anchor.getNode(); if (!anchorNode.isSimpleText()) { return null; } const anchorOffset = anchor.offset; return anchorNode.getTextContent().slice(0, anchorOffset); } function tryToPositionRange( leadOffset: number, range: Range, editorWindow: Window, rootElement: HTMLElement | null, ): boolean { const domSelection = getDOMSelection(editorWindow); if (domSelection === null || !domSelection.isCollapsed) { return false; } const points = getDOMSelectionPoints(domSelection, rootElement); const anchorNode = points.anchorNode; const startOffset = leadOffset; const endOffset = points.anchorOffset; if (anchorNode == null || endOffset == null) { return false; } try { range.setStart(anchorNode, startOffset); range.setEnd(anchorNode, endOffset); } catch (_error) { return false; } return true; } function getQueryTextForSearch(editor: LexicalEditor): string | null { let text = null; editor.read('latest', () => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return; } text = getTextUpToAnchor(selection); }); return text; } function isSelectionOnEntityBoundary( editor: LexicalEditor, offset: number, ): boolean { if (offset !== 0) { return false; } return editor.read('latest', () => { const selection = $getSelection(); if ($isRangeSelection(selection)) { const anchor = selection.anchor; const anchorNode = anchor.getNode(); const prevSibling = anchorNode.getPreviousSibling(); return $isTextNode(prevSibling) && prevSibling.isTextEntity(); } return false; }); } export {useDynamicPositioning} from './shared/LexicalMenu'; export { getScrollParent, PUNCTUATION, SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, useBasicTypeaheadTriggerMatch, } from '@lexical/react/LexicalTypeaheadMenuPluginUtils'; /** * Props for the {@link LexicalTypeaheadMenuPlugin} component. */ export type TypeaheadMenuPluginProps = { onQueryChange: (matchingString: string | null) => void; onSelectOption: ( option: TOption, textNodeContainingQuery: TextNode | null, closeMenu: () => void, matchingString: string, ) => void; options: TOption[]; triggerFn: TriggerFn; menuRenderFn?: MenuRenderFn; onOpen?: (resolution: MenuResolution) => void; onClose?: () => void | PromiseLike; anchorClassName?: string; commandPriority?: CommandListenerPriority; parent?: HTMLElement; preselectFirstItem?: boolean; ignoreEntityBoundary?: boolean; }; /** * Renders a floating menu (such as an `@`-mention or slash-command picker) while * the text before the cursor matches `triggerFn`. As the user types, the * current query is reported via `onQueryChange`; supply the `options` to show * and an `onSelectOption` handler to apply the chosen option. Use * {@link useBasicTypeaheadTriggerMatch} to build a simple `triggerFn`. * * @returns The floating menu element, or `null` when no query is active. */ export function LexicalTypeaheadMenuPlugin({ options, onQueryChange, onSelectOption, onOpen, onClose, menuRenderFn, triggerFn, anchorClassName, commandPriority = COMMAND_PRIORITY_LOW, parent, preselectFirstItem = true, ignoreEntityBoundary = false, }: TypeaheadMenuPluginProps): JSX.Element | null { const [editor] = useLexicalComposerContext(); const [resolution, setResolution] = useState(null); const anchorElementRef = useMenuAnchorRef( resolution, setResolution, anchorClassName, parent, ); const closeTypeahead = useCallback(() => { if (resolution === null) { return; } const finish = () => { setResolution(null); }; let result; try { result = onClose && onClose(); } finally { if (result) { result.then(finish, finish); } else { finish(); } } }, [onClose, resolution]); const openTypeahead = useCallback( (res: MenuResolution) => { setResolution(res); if (onOpen != null && resolution === null) { onOpen(res); } }, [onOpen, resolution], ); useEffect(() => { const updateListener = () => { editor.read('latest', () => { // Check if editor is in read-only mode if (!editor.isEditable()) { closeTypeahead(); return; } const closeUnlessComposing = () => { if (!editor.isComposing()) { closeTypeahead(); } }; const editorWindow = editor._window || window; const range = editorWindow.document.createRange(); const selection = $getSelection(); const text = getQueryTextForSearch(editor); if ( !$isRangeSelection(selection) || !selection.isCollapsed() || text === null || range === null ) { closeUnlessComposing(); return; } const match = triggerFn(text, editor); onQueryChange(match ? match.matchingString : null); if ( match !== null && (ignoreEntityBoundary || !isSelectionOnEntityBoundary(editor, match.leadOffset)) ) { const isRangePositioned = tryToPositionRange( match.leadOffset, range, editorWindow, editor.getRootElement(), ); if (isRangePositioned !== null) { startTransition(() => openTypeahead({ getRect: () => range.getBoundingClientRect(), match, }), ); return; } } closeUnlessComposing(); }); }; const removeUpdateListener = editor.registerUpdateListener(updateListener); return () => { removeUpdateListener(); }; }, [ editor, triggerFn, onQueryChange, resolution, closeTypeahead, openTypeahead, ignoreEntityBoundary, ]); useEffect( () => editor.registerEditableListener(isEditable => { if (!isEditable) { closeTypeahead(); } }), [editor, closeTypeahead], ); return resolution === null || editor === null || anchorElementRef.current === null ? null : ( ); } export {MenuOption, MenuRenderFn, MenuResolution, MenuTextMatch, TriggerFn};