/** * 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, MenuTextMatch, TriggerFn, } from './shared/LexicalMenu'; import type {JSX} from 'react'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_LOW, CommandListenerPriority, createCommand, getDOMSelection, LexicalCommand, LexicalEditor, RangeSelection, TextNode, } from 'lexical'; import {useCallback, useEffect, useState} from 'react'; import {LexicalMenu, MenuOption, useMenuAnchorRef} from './shared/LexicalMenu'; import {startTransition} from './shared/reactPatches'; export const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; 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, ): boolean { const domSelection = getDOMSelection(editorWindow); if (domSelection === null || !domSelection.isCollapsed) { return false; } const anchorNode = domSelection.anchorNode; const startOffset = leadOffset; const endOffset = domSelection.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.getEditorState().read(() => { 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.getEditorState().read(() => { 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; }); } // 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; } export {useDynamicPositioning} from './shared/LexicalMenu'; export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ index: number; option: MenuOption; }> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND'); export function useBasicTypeaheadTriggerMatch( trigger: string, { minLength = 1, maxLength = 75, punctuation = PUNCTUATION, allowWhitespace = false, }: { minLength?: number; maxLength?: number; punctuation?: string; allowWhitespace?: boolean; }, ): TriggerFn { return useCallback( (text: string) => { const validCharsSuffix = allowWhitespace ? '' : '\\s'; const validChars = '[^' + trigger + punctuation + validCharsSuffix + ']'; 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; }, [allowWhitespace, trigger, punctuation, maxLength, minLength], ); } export type TypeaheadMenuPluginProps = { onQueryChange: (matchingString: string | null) => void; onSelectOption: ( option: TOption, textNodeContainingQuery: TextNode | null, closeMenu: () => void, matchingString: string, ) => void; options: Array; triggerFn: TriggerFn; menuRenderFn?: MenuRenderFn; onOpen?: (resolution: MenuResolution) => void; onClose?: () => void | PromiseLike; anchorClassName?: string; commandPriority?: CommandListenerPriority; parent?: HTMLElement; preselectFirstItem?: boolean; ignoreEntityBoundary?: boolean; }; 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.getEditorState().read(() => { // Check if editor is in read-only mode if (!editor.isEditable()) { closeTypeahead(); return; } if (editor.isComposing()) { return; } 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 ) { closeTypeahead(); 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, ); if (isRangePositioned !== null) { startTransition(() => openTypeahead({ getRect: () => range.getBoundingClientRect(), match, }), ); return; } } closeTypeahead(); }); }; 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};