diff --git a/.flowconfig b/.flowconfig index 2722b15ff97..5c7e768445e 100644 --- a/.flowconfig +++ b/.flowconfig @@ -66,6 +66,7 @@ module.name_mapper='^@lexical/react/useLexicalTextEntity' -> '/pac # Composer Plugins module.name_mapper='^@lexical/react/LexicalPlainTextPlugin' -> '/packages/lexical-react/flow/LexicalPlainTextPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalRichTextPlugin' -> '/packages/lexical-react/flow/LexicalRichTextPlugin.js.flow' +module.name_mapper='^@lexical/react/LexicalTypeaheadMenuPlugin' -> '/packages/lexical-react/flow/LexicalTypeaheadMenuPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalHistoryPlugin' -> '/packages/lexical-react/flow/LexicalHistoryPlugin.js.flow' module.name_mapper='^@lexical/react/LexicalOnChangePlugin' -> '/packages/lexical-react/flow/LexicalOnChangePlugin.js.flow' module.name_mapper='^@lexical/react/LexicalHashtagPlugin' -> '/packages/lexical-react/flow/LexicalHashtagPlugin.js.flow' diff --git a/packages/lexical-playground/__tests__/e2e/ClearFormatting.spec.mjs b/packages/lexical-playground/__tests__/e2e/ClearFormatting.spec.mjs index 16a11c05cb1..05e359b4932 100644 --- a/packages/lexical-playground/__tests__/e2e/ClearFormatting.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/ClearFormatting.spec.mjs @@ -134,7 +134,7 @@ test.describe('Clear All Formatting', () => { await page.keyboard.type('Luke'); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await assertHTML( page, html` diff --git a/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs b/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs index 1cd6eb8d4d5..ff41ac92487 100644 --- a/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs @@ -438,7 +438,7 @@ test.describe('Composition', () => { await enableCompositionKeyEvents(page); await page.keyboard.type('Luke'); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention'); @@ -492,7 +492,7 @@ test.describe('Composition', () => { await enableCompositionKeyEvents(page); await page.keyboard.type('Luke'); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention'); diff --git a/packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs b/packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs index ac018e92b31..da2d0a86ece 100644 --- a/packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs @@ -35,7 +35,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await assertHTML( page, html` @@ -107,7 +107,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await assertHTML( page, html` @@ -198,7 +198,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await assertHTML( page, html` @@ -270,7 +270,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await assertHTML( page, html` @@ -342,7 +342,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await assertHTML( page, html` @@ -437,7 +437,7 @@ test.describe('Mentions', () => { await page.keyboard.type('Luke'); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await assertHTML( page, html` @@ -513,7 +513,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention'); @@ -522,7 +522,7 @@ test.describe('Mentions', () => { await page.keyboard.type('Luke'); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention:nth-child(1)'); @@ -531,7 +531,7 @@ test.describe('Mentions', () => { await page.keyboard.type('Luke'); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention:nth-child(3)'); @@ -540,7 +540,7 @@ test.describe('Mentions', () => { await page.keyboard.type('Luke'); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention:nth-child(5)'); @@ -822,7 +822,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention'); diff --git a/packages/lexical-playground/__tests__/regression/379-backspace-with-mentions.spec.mjs b/packages/lexical-playground/__tests__/regression/379-backspace-with-mentions.spec.mjs index 5400d3b7963..251d6e79855 100644 --- a/packages/lexical-playground/__tests__/regression/379-backspace-with-mentions.spec.mjs +++ b/packages/lexical-playground/__tests__/regression/379-backspace-with-mentions.spec.mjs @@ -24,7 +24,7 @@ test.describe('Regression test #379', () => { }) => { await focusEditor(page); await page.keyboard.type('Luke'); - await waitForSelector(page, '#mentions-typeahead ul li'); + await waitForSelector(page, '#typeahead-menu ul li'); await page.keyboard.press('Enter'); await assertHTML( page, diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 5610805428b..ec6658a91d5 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -32,6 +32,7 @@ import ClickableLinkPlugin from './plugins/ClickableLinkPlugin'; import CodeActionMenuPlugin from './plugins/CodeActionMenuPlugin'; import CodeHighlightPlugin from './plugins/CodeHighlightPlugin'; import CommentPlugin from './plugins/CommentPlugin'; +import ComponentPickerPlugin from './plugins/ComponentPickerPlugin'; import EmojisPlugin from './plugins/EmojisPlugin'; import EquationsPlugin from './plugins/EquationsPlugin'; import ExcalidrawPlugin from './plugins/ExcalidrawPlugin'; @@ -91,6 +92,7 @@ export default function Editor(): JSX.Element { {isMaxLength && } + diff --git a/packages/lexical-playground/src/images/icons/user.svg b/packages/lexical-playground/src/images/icons/user.svg new file mode 100644 index 00000000000..767355baff0 --- /dev/null +++ b/packages/lexical-playground/src/images/icons/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index 68e994cd506..601543b136e 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -231,7 +231,7 @@ pre::-webkit-scrollbar-thumb { background-image: url(images/icons/download.svg); } -#mentions-typeahead { +#typeahead-menu { position: fixed; background: #fff; box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3); @@ -239,15 +239,25 @@ pre::-webkit-scrollbar-thumb { z-index: 3; } -#mentions-typeahead ul { +#typeahead-menu ul { padding: 0; list-style: none; margin: 0; border-radius: 8px; + max-height: 200px; + overflow-y: scroll; } -#mentions-typeahead ul li { - padding: 10px 15px; +#typeahead-menu ul::-webkit-scrollbar { + display: none; +} + +#typeahead-menu ul { + -ms-overflow-style: none; + scrollbar-width: none; +} + +#typeahead-menu ul li { margin: 0; min-width: 180px; font-size: 14px; @@ -256,10 +266,63 @@ pre::-webkit-scrollbar-thumb { border-radius: 8px; } -#mentions-typeahead ul li.selected { +#typeahead-menu ul li.selected { background: #eee; } +#typeahead-menu li { + margin: 0 8px 0 8px; + padding: 8px; + color: #050505; + cursor: pointer; + line-height: 16px; + font-size: 15px; + display: flex; + align-content: center; + flex-direction: row; + flex-shrink: 0; + background-color: #fff; + border-radius: 8px; + border: 0; + max-width: 250px; +} + +#typeahead-menu li.active { + display: flex; + width: 20px; + height: 20px; + background-size: contain; +} + +#typeahead-menu li:first-child { + border-radius: 8px 8px 0px 0px; +} + +#typeahead-menu li:last-child { + border-radius: 0px 0px 8px 8px; +} + +#typeahead-menu li:hover { + background-color: #eee; +} + +#typeahead-menu li .text { + display: flex; + line-height: 20px; + flex-grow: 1; + min-width: 150px; +} + +#typeahead-menu li .icon { + display: flex; + width: 20px; + height: 20px; + user-select: none; + margin-right: 8px; + line-height: 16px; + background-size: contain; +} + .link-editor { position: absolute; z-index: 10; @@ -442,6 +505,10 @@ i.diagram-2 { background-image: url(images/icons/diagram-2.svg); } +i.user { + background-image: url(images/icons/user.svg); +} + i.equation { background-image: url(images/icons/plus-slash-minus.svg); } diff --git a/packages/lexical-playground/src/plugins/ComponentPickerPlugin.tsx b/packages/lexical-playground/src/plugins/ComponentPickerPlugin.tsx new file mode 100644 index 00000000000..8531da51c65 --- /dev/null +++ b/packages/lexical-playground/src/plugins/ComponentPickerPlugin.tsx @@ -0,0 +1,393 @@ +/** + * 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 {$createCodeNode} from '@lexical/code'; +import { + INSERT_CHECK_LIST_COMMAND, + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, +} from '@lexical/list'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {INSERT_HORIZONTAL_RULE_COMMAND} from '@lexical/react/LexicalHorizontalRuleNode'; +import { + LexicalTypeaheadMenuPlugin, + TypeaheadOption, + useBasicTypeaheadTriggerMatch, +} from '@lexical/react/src/LexicalTypeaheadMenuPlugin'; +import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text'; +import {$wrapLeafNodesInElements} from '@lexical/selection'; +import {INSERT_TABLE_COMMAND} from '@lexical/table'; +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + FORMAT_ELEMENT_COMMAND, + TextNode, +} from 'lexical'; +import {useCallback, useMemo, useState} from 'react'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +import useModal from '../hooks/useModal'; +import catTypingGif from '../images/cat-typing.gif'; +import {INSERT_EXCALIDRAW_COMMAND} from './ExcalidrawPlugin'; +import {INSERT_IMAGE_COMMAND} from './ImagesPlugin'; +import { + InsertEquationDialog, + InsertImageDialog, + InsertPollDialog, + InsertTableDialog, + InsertTweetDialog, +} from './ToolbarPlugin'; + +class ComponentPickerOption extends TypeaheadOption { + // What shows up in the editor + title: string; + // Icon for display + icon?: JSX.Element; + // For extra searching. + keywords: Array; + // TBD + keyboardShortcut?: string; + // What happens when you select this option? + onSelect: (queryString: string) => void; + + constructor( + title: string, + options: { + icon?: JSX.Element; + keywords?: Array; + keyboardShortcut?: string; + onSelect: (queryString: string) => void; + }, + ) { + super(title); + this.title = title; + this.keywords = options.keywords || []; + this.icon = options.icon; + this.keyboardShortcut = options.keyboardShortcut; + this.onSelect = options.onSelect.bind(this); + } +} + +function ComponentPickerMenuItem({ + index, + isSelected, + onClick, + onMouseEnter, + option, +}: { + index: number; + isSelected: boolean; + onClick: () => void; + onMouseEnter: () => void; + option: ComponentPickerOption; +}) { + let className = 'item'; + if (isSelected) { + className += ' selected'; + } + return ( +
  • + {option.icon} + {option.title} +
  • + ); +} + +export default function ComponentPickerMenuPlugin(): JSX.Element { + const [editor] = useLexicalComposerContext(); + const [modal, showModal] = useModal(); + const [queryString, setQueryString] = useState(null); + + const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { + minLength: 0, + }); + + const getDynamicOptions = useCallback(() => { + const options: Array = []; + + if (queryString == null) { + return options; + } + + const fullTableRegex = new RegExp(/([1-9]|10)x([1-9]|10)$/); + const partialTableRegex = new RegExp(/([1-9]|10)x?$/); + + const fullTableMatch = fullTableRegex.exec(queryString); + const partialTableMatch = partialTableRegex.exec(queryString); + + if (fullTableMatch) { + const [rows, columns] = fullTableMatch[0] + .split('x') + .map((n: string) => parseInt(n, 10)); + + options.push( + new ComponentPickerOption(`${rows}x${columns} Table`, { + icon: , + keywords: ['table'], + onSelect: () => + // @ts-ignore Correct types, but since they're dynamic TS doesn't like it. + editor.dispatchCommand(INSERT_TABLE_COMMAND, {columns, rows}), + }), + ); + } else if (partialTableMatch) { + const rows = parseInt(partialTableMatch[0], 10); + + options.push( + ...Array.from({length: 5}, (_, i) => i + 1).map( + (columns) => + new ComponentPickerOption(`${rows}x${columns} Table`, { + icon: , + keywords: ['table'], + onSelect: () => + // @ts-ignore Correct types, but since they're dynamic TS doesn't like it. + editor.dispatchCommand(INSERT_TABLE_COMMAND, {columns, rows}), + }), + ), + ); + } + + return options; + }, [editor, queryString]); + + const options = useMemo(() => { + const baseOptions = [ + new ComponentPickerOption('Paragraph', { + icon: , + keywords: ['normal', 'paragraph', 'p', 'text'], + onSelect: () => + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $wrapLeafNodesInElements(selection, () => $createParagraphNode()); + } + }), + }), + ...Array.from({length: 3}, (_, i) => i + 1).map( + (n) => + new ComponentPickerOption(`Heading ${n}`, { + icon: , + keywords: ['heading', 'header', `h${n}`], + onSelect: () => + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $wrapLeafNodesInElements(selection, () => + // @ts-ignore Correct types, but since they're dynamic TS doesn't like it. + $createHeadingNode(`h${n}`), + ); + } + }), + }), + ), + new ComponentPickerOption('Table', { + icon: , + keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'], + onSelect: () => + showModal('Insert Table', (onClose) => ( + + )), + }), + new ComponentPickerOption('Numbered List', { + icon: , + keywords: ['numbered list', 'ordered list', 'ol'], + onSelect: () => + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined), + }), + new ComponentPickerOption('Bulleted List', { + icon: , + keywords: ['bulleted list', 'unordered list', 'ul'], + onSelect: () => + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined), + }), + new ComponentPickerOption('Check List', { + icon: , + keywords: ['check list', 'todo list'], + onSelect: () => + editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined), + }), + new ComponentPickerOption('Quote', { + icon: , + keywords: ['block quote'], + onSelect: () => + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $wrapLeafNodesInElements(selection, () => $createQuoteNode()); + } + }), + }), + new ComponentPickerOption('Code', { + icon: , + keywords: ['javascript', 'python', 'js', 'codeblock'], + onSelect: () => + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + if (selection.isCollapsed()) { + $wrapLeafNodesInElements(selection, () => $createCodeNode()); + } else { + const textContent = selection.getTextContent(); + const codeNode = $createCodeNode(); + selection.insertNodes([codeNode]); + selection.insertRawText(textContent); + } + } + }), + }), + new ComponentPickerOption('Divider', { + icon: , + keywords: ['horizontal rule', 'divider', 'hr'], + onSelect: () => + editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined), + }), + new ComponentPickerOption('Excalidraw', { + icon: , + keywords: ['excalidraw', 'diagram', 'drawing'], + onSelect: () => + editor.dispatchCommand(INSERT_EXCALIDRAW_COMMAND, undefined), + }), + new ComponentPickerOption('Poll', { + icon: , + keywords: ['poll', 'vote'], + onSelect: () => + showModal('Insert Poll', (onClose) => ( + + )), + }), + new ComponentPickerOption('Tweet', { + icon: , + keywords: ['twitter', 'embed', 'tweet'], + onSelect: () => + showModal('Insert Tweet', (onClose) => ( + + )), + }), + new ComponentPickerOption('Equation', { + icon: , + keywords: ['equation', 'latex', 'math'], + onSelect: () => + showModal('Insert Equation', (onClose) => ( + + )), + }), + new ComponentPickerOption('GIF', { + icon: , + keywords: ['gif', 'animate', 'image', 'file'], + onSelect: () => + editor.dispatchCommand(INSERT_IMAGE_COMMAND, { + altText: 'Cat typing on a laptop', + src: catTypingGif, + }), + }), + new ComponentPickerOption('Image', { + icon: , + keywords: ['image', 'photo', 'picture', 'file'], + onSelect: () => + showModal('Insert Image', (onClose) => ( + + )), + }), + ...['left', 'center', 'right', 'justify'].map( + (alignment) => + new ComponentPickerOption(`Align ${alignment}`, { + icon: , + keywords: ['align', 'justify', alignment], + onSelect: () => + // @ts-ignore Correct types, but since they're dynamic TS doesn't like it. + editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, alignment), + }), + ), + ]; + + const dynamicOptions = getDynamicOptions(); + + return queryString + ? [ + ...dynamicOptions, + ...baseOptions.filter((option) => { + const queryRegex = new RegExp(queryString, 'gi'); + return queryRegex.exec(option.title) || option.keywords != null + ? option.keywords.some((keyword) => { + queryRegex.lastIndex = 0; + return queryRegex.exec(keyword); + }) + : false; + }), + ] + : baseOptions; + }, [editor, getDynamicOptions, queryString, showModal]); + + const onSelectOption = useCallback( + ( + selectedOption: ComponentPickerOption, + nodeToRemove: TextNode | null, + closeMenu: () => void, + matchingString: string, + ) => { + editor.update(() => { + if (nodeToRemove) { + nodeToRemove.remove(); + } + selectedOption.onSelect(matchingString); + closeMenu(); + }); + }, + [editor], + ); + + return ( + <> + {modal} + + onQueryChange={setQueryString} + onSelectOption={onSelectOption} + triggerFn={checkForTriggerMatch} + options={options} + menuRenderFn={( + anchorElement, + {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex}, + ) => + anchorElement && options.length + ? ReactDOM.createPortal( +
      + {options.map((option, i: number) => ( + { + setHighlightedIndex(i); + selectOptionAndCleanUp(option); + }} + onMouseEnter={() => { + setHighlightedIndex(i); + }} + key={option.key} + option={option} + /> + ))} +
    , + anchorElement, + ) + : null + } + /> + + ); +} diff --git a/packages/lexical-playground/src/plugins/MentionsPlugin.tsx b/packages/lexical-playground/src/plugins/MentionsPlugin.tsx index 85858e0bcfa..e61f4bd98a9 100644 --- a/packages/lexical-playground/src/plugins/MentionsPlugin.tsx +++ b/packages/lexical-playground/src/plugins/MentionsPlugin.tsx @@ -6,45 +6,19 @@ * */ -import type {LexicalEditor, RangeSelection} from 'lexical'; - import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {mergeRegister} from '@lexical/utils'; -import { - $getSelection, - $isRangeSelection, - $isTextNode, - COMMAND_PRIORITY_LOW, - KEY_ARROW_DOWN_COMMAND, - KEY_ARROW_UP_COMMAND, - KEY_ENTER_COMMAND, - KEY_ESCAPE_COMMAND, - KEY_TAB_COMMAND, -} from 'lexical'; import { - ReactPortal, - startTransition, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; + LexicalTypeaheadMenuPlugin, + QueryMatch, + TypeaheadOption, + useBasicTypeaheadTriggerMatch, +} from '@lexical/react/LexicalTypeaheadMenuPlugin'; +import {TextNode} from 'lexical'; +import {useCallback, useEffect, useMemo, useState} from 'react'; import * as React from 'react'; -import {createPortal} from 'react-dom'; -import useLayoutEffect from 'shared/useLayoutEffect'; - -import {$createMentionNode, MentionNode} from '../nodes/MentionNode'; - -type MentionMatch = { - leadOffset: number; - matchingString: string; - replaceableString: string; -}; +import * as ReactDOM from 'react-dom'; -type Resolution = { - match: MentionMatch; - range: Range; -}; +import {$createMentionNode} from '../nodes/MentionNode'; const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; @@ -61,7 +35,7 @@ const CapitalizedNameMentionsRegex = new RegExp( const PUNC = DocumentMentionsRegex.PUNCTUATION; -const TRIGGERS = ['@', '\\uff20'].join(''); +const TRIGGERS = ['@'].join(''); // Chars we expect to see in a mention (non-space, non-punctuation). const VALID_CHARS = '[^' + TRIGGERS + PUNC + '\\s]'; @@ -522,29 +496,27 @@ const dummyMentionsData = [ ]; const dummyLookupService = { - search( - string: string, - callback: (results: Array | null) => void, - ): void { + search(string: string, callback: (results: Array) => void): void { setTimeout(() => { const results = dummyMentionsData.filter((mention) => mention.toLowerCase().includes(string.toLowerCase()), ); - if (results.length === 0) { - callback(null); - } else { - callback(results); - } + callback(results); }, 500); }, }; -function useMentionLookupService(mentionString: string) { - const [results, setResults] = useState | null>(null); +function useMentionLookupService(mentionString: string | null) { + const [results, setResults] = useState>([]); useEffect(() => { const cachedResults = mentionsCache.get(mentionString); + if (mentionString == null) { + setResults([]); + return; + } + if (cachedResults === null) { return; } else if (cachedResults !== undefined) { @@ -562,239 +534,10 @@ function useMentionLookupService(mentionString: string) { return results; } -function MentionsTypeaheadItem({ - index, - isSelected, - onClick, - onMouseEnter, - result, -}: { - index: number; - isSelected: boolean; - onClick: () => void; - onMouseEnter: () => void; - result: string; -}) { - const liRef = useRef(null); - - let className = 'item'; - if (isSelected) { - className += ' selected'; - } - - return ( -
  • - {result} -
  • - ); -} - -function MentionsTypeahead({ - close, - editor, - resolution, -}: { - close: () => void; - editor: LexicalEditor; - resolution: Resolution; -}): JSX.Element | null { - const divRef = useRef(null); - const match = resolution.match; - const results = useMentionLookupService(match.matchingString); - const [selectedIndex, setSelectedIndex] = useState(null); - - useEffect(() => { - const div = divRef.current; - const rootElement = editor.getRootElement(); - if (results !== null && div !== null && rootElement !== null) { - const range = resolution.range; - const {left, top, height} = range.getBoundingClientRect(); - div.style.top = `${top + height + 2}px`; - div.style.left = `${left - 14}px`; - div.style.display = 'block'; - rootElement.setAttribute('aria-controls', 'mentions-typeahead'); - - return () => { - div.style.display = 'none'; - rootElement.removeAttribute('aria-controls'); - }; - } - }, [editor, resolution, results]); - - const applyCurrentSelected = useCallback(() => { - if (results === null || selectedIndex === null) { - return; - } - const selectedEntry = results[selectedIndex]; - - close(); - - createMentionNodeFromSearchResult(editor, selectedEntry, match); - }, [close, match, editor, results, selectedIndex]); - - const updateSelectedIndex = useCallback( - (index: number) => { - const rootElem = editor.getRootElement(); - if (rootElem !== null) { - rootElem.setAttribute( - 'aria-activedescendant', - 'typeahead-item-' + index, - ); - setSelectedIndex(index); - } - }, - [editor], - ); - - useEffect(() => { - return () => { - const rootElem = editor.getRootElement(); - if (rootElem !== null) { - rootElem.removeAttribute('aria-activedescendant'); - } - }; - }, [editor]); - - useLayoutEffect(() => { - if (results === null) { - setSelectedIndex(null); - } else if (selectedIndex === null) { - updateSelectedIndex(0); - } - }, [results, selectedIndex, updateSelectedIndex]); - - useEffect(() => { - return mergeRegister( - editor.registerCommand( - KEY_ARROW_DOWN_COMMAND, - (payload) => { - const event = payload; - if (results !== null && selectedIndex !== null) { - if ( - selectedIndex < SUGGESTION_LIST_LENGTH_LIMIT - 1 && - selectedIndex !== results.length - 1 - ) { - updateSelectedIndex(selectedIndex + 1); - } - event.preventDefault(); - event.stopImmediatePropagation(); - } - return true; - }, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_ARROW_UP_COMMAND, - (payload) => { - const event = payload; - if (results !== null && selectedIndex !== null) { - if (selectedIndex !== 0) { - updateSelectedIndex(selectedIndex - 1); - } - event.preventDefault(); - event.stopImmediatePropagation(); - } - return true; - }, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_ESCAPE_COMMAND, - (payload) => { - const event = payload; - if (results === null || selectedIndex === null) { - return false; - } - event.preventDefault(); - event.stopImmediatePropagation(); - close(); - return true; - }, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_TAB_COMMAND, - (payload) => { - const event = payload; - if (results === null || selectedIndex === null) { - return false; - } - event.preventDefault(); - event.stopImmediatePropagation(); - applyCurrentSelected(); - return true; - }, - COMMAND_PRIORITY_LOW, - ), - editor.registerCommand( - KEY_ENTER_COMMAND, - (event: KeyboardEvent | null) => { - if (results === null || selectedIndex === null) { - return false; - } - if (event !== null) { - event.preventDefault(); - event.stopImmediatePropagation(); - } - applyCurrentSelected(); - return true; - }, - COMMAND_PRIORITY_LOW, - ), - ); - }, [ - applyCurrentSelected, - close, - editor, - results, - selectedIndex, - updateSelectedIndex, - ]); - - if (results === null) { - return null; - } - - return ( -
    -
      - {results.slice(0, SUGGESTION_LIST_LENGTH_LIMIT).map((result, i) => ( - { - setSelectedIndex(i); - applyCurrentSelected(); - }} - onMouseEnter={() => { - setSelectedIndex(i); - }} - key={result} - result={result} - /> - ))} -
    -
    - ); -} - function checkForCapitalizedNameMentions( text: string, minMatchLength: number, -): MentionMatch | null { +): QueryMatch | null { const match = CapitalizedNameMentionsRegex.exec(text); if (match !== null) { // The strategy ignores leading whitespace but we need to know it's @@ -816,7 +559,7 @@ function checkForCapitalizedNameMentions( function checkForAtSignMentions( text: string, minMatchLength: number, -): MentionMatch | null { +): QueryMatch | null { let match = AtSignMentionsRegex.exec(text); if (match === null) { @@ -839,224 +582,138 @@ function checkForAtSignMentions( return null; } -function getPossibleMentionMatch(text: string): MentionMatch | null { +function getPossibleQueryMatch(text: string): QueryMatch | null { const match = checkForAtSignMentions(text, 1); return match === null ? checkForCapitalizedNameMentions(text, 3) : match; } -function getTextUpToAnchor(selection: RangeSelection): string | null { - const anchor = selection.anchor; - if (anchor.type !== 'text') { - return null; - } - const anchorNode = anchor.getNode(); - // We should not be attempting to extract mentions out of nodes - // that are already being used for other core things. This is - // especially true for token nodes, which can't be mutated at all. - if (!anchorNode.isSimpleText()) { - return null; - } - const anchorOffset = anchor.offset; - return anchorNode.getTextContent().slice(0, anchorOffset); -} +class MentionTypeaheadOption extends TypeaheadOption { + name: string; + picture: JSX.Element; -function tryToPositionRange(match: MentionMatch, range: Range): boolean { - const domSelection = window.getSelection(); - if (domSelection === null || !domSelection.isCollapsed) { - return false; + constructor(name: string, picture: JSX.Element) { + super(name); + this.name = name; + this.picture = picture; } - const anchorNode = domSelection.anchorNode; - const startOffset = match.leadOffset; - const endOffset = domSelection.anchorOffset; - try { - if (anchorNode) { - range.setStart(anchorNode, startOffset); - range.setEnd(anchorNode, endOffset); - } - } catch (error) { - return false; - } - - return true; -} - -function getMentionsTextToSearch(editor: LexicalEditor): string | null { - let text = null; - editor.getEditorState().read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return; - } - text = getTextUpToAnchor(selection); - }); - return text; } -/** - * Walk backwards along user input and forward through entity title to try - * and replace more of the user's text with entity. - * - * E.g. User types "Hello Sarah Smit" and we match "Smit" to "Sarah Smith". - * Replacing just the match would give us "Hello Sarah Sarah Smith". - * Instead we find the string "Sarah Smit" and replace all of it. - */ -function getMentionOffset( - documentText: string, - entryText: string, - offset: number, -): number { - let triggerOffset = offset; - for (let ii = triggerOffset; ii <= entryText.length; ii++) { - if (documentText.substr(-ii) === entryText.substr(0, ii)) { - triggerOffset = ii; - } +function MentionsTypeaheadMenuItem({ + index, + isSelected, + onClick, + onMouseEnter, + option, +}: { + index: number; + isSelected: boolean; + onClick: () => void; + onMouseEnter: () => void; + option: MentionTypeaheadOption; +}) { + let className = 'item'; + if (isSelected) { + className += ' selected'; } - - return triggerOffset; + return ( +
  • + {option.picture} + {option.name} +
  • + ); } -/** - * From a Typeahead Search Result, replace plain text from search offset and - * render a newly created MentionNode. - */ -function createMentionNodeFromSearchResult( - editor: LexicalEditor, - entryText: string, - match: MentionMatch, -): void { - editor.update(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return; - } - const anchor = selection.anchor; - if (anchor.type !== 'text') { - return; - } - const anchorNode = anchor.getNode(); - // We should not be attempting to extract mentions out of nodes - // that are already being used for other core things. This is - // especially true for token nodes, which can't be mutated at all. - if (!anchorNode.isSimpleText()) { - return; - } - const selectionOffset = anchor.offset; - const textContent = anchorNode.getTextContent().slice(0, selectionOffset); - const characterOffset = match.replaceableString.length; - - // Given a known offset for the mention match, look backward in the - // text to see if there's a longer match to replace. - const mentionOffset = getMentionOffset( - textContent, - entryText, - characterOffset, - ); - const startOffset = selectionOffset - mentionOffset; - if (startOffset < 0) { - return; - } +export default function NewMentionsPlugin(): JSX.Element | null { + const [editor] = useLexicalComposerContext(); - let nodeToReplace; - if (startOffset === 0) { - [nodeToReplace] = anchorNode.splitText(selectionOffset); - } else { - [, nodeToReplace] = anchorNode.splitText(startOffset, selectionOffset); - } + const [queryString, setQueryString] = useState(null); - const mentionNode = $createMentionNode(entryText); - nodeToReplace.replace(mentionNode); - mentionNode.select(); - }); -} + const results = useMentionLookupService(queryString); -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; + const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', { + minLength: 0, }); -} - -function useMentions(editor: LexicalEditor): ReactPortal | null { - const [resolution, setResolution] = useState(null); - - useEffect(() => { - if (!editor.hasNodes([MentionNode])) { - throw new Error('MentionsPlugin: MentionNode not registered on editor'); - } - }, [editor]); - - useEffect(() => { - let activeRange: Range | null = document.createRange(); - let previousText: string | null = null; - const updateListener = () => { - const range = activeRange; - const text = getMentionsTextToSearch(editor); - - if (text === previousText || range === null) { - return; - } - previousText = text; + const options = useMemo( + () => + results + .map( + (result) => + new MentionTypeaheadOption(result, ), + ) + .slice(0, SUGGESTION_LIST_LENGTH_LIMIT), + [results], + ); - if (text === null) { - return; - } - const match = getPossibleMentionMatch(text); - if ( - match !== null && - !isSelectionOnEntityBoundary(editor, match.leadOffset) - ) { - const isRangePositioned = tryToPositionRange(match, range); - if (isRangePositioned !== null) { - startTransition(() => - setResolution({ - match, - range, - }), - ); - return; + const onSelectOption = useCallback( + ( + selectedOption: MentionTypeaheadOption, + nodeToReplace: TextNode | null, + closeMenu: () => void, + ) => { + editor.update(() => { + const mentionNode = $createMentionNode(selectedOption.name); + if (nodeToReplace) { + nodeToReplace.replace(mentionNode); } - } - startTransition(() => setResolution(null)); - }; - - const removeUpdateListener = editor.registerUpdateListener(updateListener); - - return () => { - activeRange = null; - removeUpdateListener(); - }; - }, [editor]); - - const closeTypeahead = useCallback(() => { - setResolution(null); - }, []); + mentionNode.select(); + closeMenu(); + }); + }, + [editor], + ); - return resolution === null || editor === null - ? null - : createPortal( - , - document.body, - ); -} + const checkForMentionMatch = useCallback( + (text: string) => { + const mentionMatch = getPossibleQueryMatch(text); + const slashMatch = checkForSlashTriggerMatch(text); + return !slashMatch && mentionMatch ? mentionMatch : null; + }, + [checkForSlashTriggerMatch], + ); -export default function MentionsPlugin(): ReactPortal | null { - const [editor] = useLexicalComposerContext(); - return useMentions(editor); + return ( + + onQueryChange={setQueryString} + onSelectOption={onSelectOption} + triggerFn={checkForMentionMatch} + options={options} + menuRenderFn={( + anchorElement, + {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex}, + ) => + anchorElement && results.length + ? ReactDOM.createPortal( +
      + {options.map((option, i: number) => ( + { + setHighlightedIndex(i); + selectOptionAndCleanUp(option); + }} + onMouseEnter={() => { + setHighlightedIndex(i); + }} + key={option.key} + option={option} + /> + ))} +
    , + anchorElement, + ) + : null + } + /> + ); } diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin.tsx b/packages/lexical-playground/src/plugins/ToolbarPlugin.tsx index 21c4865a214..d1f32beece3 100644 --- a/packages/lexical-playground/src/plugins/ToolbarPlugin.tsx +++ b/packages/lexical-playground/src/plugins/ToolbarPlugin.tsx @@ -330,7 +330,7 @@ function FloatingLinkEditor({editor}: {editor: LexicalEditor}): JSX.Element { ); } -function InsertImageUriDialogBody({ +export function InsertImageUriDialogBody({ onClick, }: { onClick: (payload: InsertImagePayload) => void; @@ -368,7 +368,7 @@ function InsertImageUriDialogBody({ ); } -function InsertImageUploadedDialogBody({ +export function InsertImageUploadedDialogBody({ onClick, }: { onClick: (payload: InsertImagePayload) => void; @@ -418,7 +418,7 @@ function InsertImageUploadedDialogBody({ ); } -function InsertImageDialog({ +export function InsertImageDialog({ activeEditor, onClose, }: { @@ -464,7 +464,7 @@ function InsertImageDialog({ ); } -function InsertTableDialog({ +export function InsertTableDialog({ activeEditor, onClose, }: { @@ -492,7 +492,7 @@ function InsertTableDialog({ ); } -function InsertPollDialog({ +export function InsertPollDialog({ activeEditor, onClose, }: { @@ -520,7 +520,7 @@ function InsertPollDialog({ const VALID_TWITTER_URL = /twitter.com\/[0-9a-zA-Z]{1,20}\/status\/([0-9]*)/g; -function InsertTweetDialog({ +export function InsertTweetDialog({ activeEditor, onClose, }: { @@ -604,7 +604,7 @@ function InsertYouTubeDialog({ ); } -function InsertEquationDialog({ +export function InsertEquationDialog({ activeEditor, onClose, }: { diff --git a/packages/lexical-playground/vite.config.js b/packages/lexical-playground/vite.config.js index 6856eef5e38..6d45f1d39b0 100644 --- a/packages/lexical-playground/vite.config.js +++ b/packages/lexical-playground/vite.config.js @@ -129,6 +129,7 @@ const moduleResolution = [ 'LexicalClearEditorPlugin', 'LexicalCollaborationPlugin', 'LexicalHistoryPlugin', + 'LexicalTypeaheadMenuPlugin', 'LexicalTablePlugin', 'LexicalLinkPlugin', 'LexicalListPlugin', diff --git a/packages/lexical-playground/vite.prod.config.js b/packages/lexical-playground/vite.prod.config.js index fc468e361cc..e08c3ffa612 100644 --- a/packages/lexical-playground/vite.prod.config.js +++ b/packages/lexical-playground/vite.prod.config.js @@ -129,6 +129,7 @@ const moduleResolution = [ 'LexicalClearEditorPlugin', 'LexicalCollaborationPlugin', 'LexicalHistoryPlugin', + 'LexicalTypeaheadMenuPlugin', 'LexicalTablePlugin', 'LexicalLinkPlugin', 'LexicalListPlugin', diff --git a/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx b/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx new file mode 100644 index 00000000000..785a1f7e400 --- /dev/null +++ b/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx @@ -0,0 +1,572 @@ +/** + * 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, + $isTextNode, + COMMAND_PRIORITY_LOW, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + KEY_TAB_COMMAND, + LexicalEditor, + RangeSelection, + TextNode, +} from 'lexical'; +import { + MutableRefObject, + ReactPortal, + startTransition, + useCallback, + useEffect, + useMemo, + useRef, + 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; + range: Range; +}; + +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}; + } +} + +declare type MenuRenderFn = ( + anchorElement: HTMLElement | null, + itemProps: { + selectedIndex: number | null; + selectOptionAndCleanUp: (option: TOption) => void; + setHighlightedIndex: (index: number) => void; + }, + matchingString: string, +) => ReactPortal | JSX.Element | null; + +const scrollIntoViewIfNeeded = (target: HTMLElement) => { + const container = document.getElementById('typeahead-menu'); + if (container) { + const containerRect = container.getBoundingClientRect(); + const targetRect = target.getBoundingClientRect(); + if (targetRect.bottom > containerRect.bottom) { + target.scrollIntoView(false); + } else if (targetRect.top < containerRect.top) { + target.scrollIntoView(); + } + } +}; + +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): boolean { + const domSelection = window.getSelection(); + 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; +} + +/** + * 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 Lexica 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, +): 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; + }); +} + +function ShortcutTypeahead({ + close, + editor, + resolution, + options, + menuRenderFn, + onSelectOption, +}: { + close: () => void; + editor: LexicalEditor; + 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); + const anchorElementRef = useRef(document.createElement('div')); + + useEffect(() => { + setHighlightedIndex(0); + }, [resolution.match.matchingString]); + + useEffect(() => { + const rootElement = editor.getRootElement(); + + function positionMenu() { + const containerDiv = anchorElementRef.current; + containerDiv.setAttribute('aria-label', 'Typeahead menu'); + containerDiv.setAttribute('id', 'typeahead-menu'); + containerDiv.setAttribute('role', 'listbox'); + if (rootElement !== null) { + const range = resolution.range; + const {left, top, height} = range.getBoundingClientRect(); + containerDiv.style.top = `${top + height + window.pageYOffset}px`; + containerDiv.style.left = `${left + window.pageXOffset}px`; + containerDiv.style.display = 'block'; + containerDiv.style.position = 'absolute'; + if (!containerDiv.isConnected) { + document.body.append(containerDiv); + } + anchorElementRef.current = containerDiv; + rootElement.setAttribute('aria-controls', 'typeahead-menu'); + } + } + positionMenu(); + window.addEventListener('resize', positionMenu); + return () => { + window.removeEventListener('resize', positionMenu); + if (rootElement !== null) { + rootElement.removeAttribute('aria-controls'); + } + }; + }, [editor, resolution, options]); + + const selectOptionAndCleanUp = useCallback( + async (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( + KEY_ARROW_DOWN_COMMAND, + (payload) => { + const event = payload; + if (options !== null && selectedIndex !== null) { + const newSelectedIndex = + selectedIndex !== options.length - 1 ? selectedIndex + 1 : 0; + 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_ARROW_UP_COMMAND, + (payload) => { + const event = payload; + if (options !== null && 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; + if (options === null || selectedIndex === null) { + return false; + } + 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( + () => ({ + selectOptionAndCleanUp, + selectedIndex, + setHighlightedIndex, + }), + [selectOptionAndCleanUp, selectedIndex], + ); + + return menuRenderFn( + anchorElementRef.current, + 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], + ); +} + +type TypeaheadMenuPluginArgs = { + onQueryChange: (matchingString: string | null) => void; + onSelectOption: ( + option: TOption, + textNodeContainingQuery: TextNode | null, + closeMenu: () => void, + matchingString: string, + ) => void; + options: Array; + menuRenderFn: MenuRenderFn; + triggerFn: TriggerFn; +}; + +type TriggerFn = (text: string) => QueryMatch | null; + +export function LexicalTypeaheadMenuPlugin({ + options, + onQueryChange, + onSelectOption, + menuRenderFn, + triggerFn, +}: TypeaheadMenuPluginArgs): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + + const [resolution, setResolution] = useState(null); + + useEffect(() => { + let activeRange: Range | null = document.createRange(); + let previousText: string | null = null; + + const updateListener = () => { + editor.getEditorState().read(() => { + const range = activeRange; + const selection = $getSelection(); + const text = getQueryTextForSearch(editor); + + if ( + !$isRangeSelection(selection) || + !selection.isCollapsed() || + text === previousText || + text === null || + range === null + ) { + startTransition(() => setResolution(null)); + return; + } + previousText = text; + + const match = triggerFn(text); + onQueryChange(match ? match.matchingString : null); + + if ( + match !== null && + !isSelectionOnEntityBoundary(editor, match.leadOffset) + ) { + const isRangePositioned = tryToPositionRange(match.leadOffset, range); + if (isRangePositioned !== null) { + startTransition(() => + setResolution({ + match, + range, + }), + ); + return; + } + } + startTransition(() => setResolution(null)); + }); + }; + + const removeUpdateListener = editor.registerUpdateListener(updateListener); + + return () => { + activeRange = null; + removeUpdateListener(); + }; + }, [editor, triggerFn, onQueryChange, resolution]); + + const closeTypeahead = useCallback(() => { + setResolution(null); + }, []); + + return resolution === null || editor === null ? null : ( + + ); +} diff --git a/tsconfig.json b/tsconfig.json index f2241642f95..fb767f546dc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -105,6 +105,9 @@ "@lexical/react/LexicalRichTextPlugin": [ "./packages/lexical-react/src/LexicalRichTextPlugin.tsx" ], + "@lexical/react/LexicalTypeaheadMenuPlugin": [ + "packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx" + ], "@lexical/react/LexicalHistoryPlugin": [ "./packages/lexical-react/src/LexicalHistoryPlugin.ts" ],