From 750306ee0ea9ae3d81f0df239dbabf4ecb95d11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Mon, 27 Mar 2023 23:49:37 +0200 Subject: [PATCH] Lexical: Autosuggest: Still needs cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- .../compose/editor/plugins/mention-plugin.tsx | 213 +------ .../editor/plugins/typeahead-menu-plugin.tsx | 521 +----------------- 2 files changed, 35 insertions(+), 699 deletions(-) diff --git a/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx b/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx index ab95f26a99..e20ccffa40 100644 --- a/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx +++ b/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx @@ -7,11 +7,6 @@ */ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { - QueryMatch, - TypeaheadOption, - useBasicTypeaheadTriggerMatch, -} from '@lexical/react/LexicalTypeaheadMenuPlugin'; import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity'; import clsx from 'clsx'; 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 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) => 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>([]); - - 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<{ composeId: string suggestionsHidden: boolean @@ -188,19 +36,8 @@ export const MentionPlugin: React.FC<{ const [editor] = useLexicalComposerContext(); - const [queryString, setQueryString] = useState(null); const [selectedSuggestion] = useState(0); - const results = useMentionLookupService(queryString); - - const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', { - minLength: 0, - }); - - const options = [new MentionTypeaheadOption('', )]; - - const onSelectOption = useCallback(() => { }, [editor]); - const onSelectSuggestion: React.MouseEventHandler = (e) => { 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(() => { if (!editor.hasNodes([MentionNode])) { throw new Error('MentionPlugin: MentionNode not registered on editor'); @@ -239,14 +67,17 @@ export const MentionPlugin: React.FC<{ return $createMentionNode(textNode.getTextContent()); }, []); - const getMentionMatch = useCallback((text: string) => { + const getMentionMatch = (text: string) => { const matchArr = REGEX.exec(text); - if (matchArr === null) { - return null; - } + if (!matchArr) 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 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( - getMentionMatch, + getEntityMatch, MentionNode, createMentionNode, ); @@ -289,19 +133,12 @@ export const MentionPlugin: React.FC<{ }, [suggestions]); return ( - - onQueryChange={setQueryString} - onSelectOption={onSelectOption} + - anchorElementRef.current && results.length + menuRenderFn={(anchorElementRef) => + anchorElementRef.current ? ReactDOM.createPortal(
DOMRect }; -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 }; - } - -} - -export type MenuRenderFn = ( +export type MenuRenderFn = ( anchorElementRef: MutableRefObject, - itemProps: { - selectedIndex: number | null - selectOptionAndCleanUp: (option: TOption) => void - setHighlightedIndex: (index: number) => void - options: Array - }, - matchingString: string, ) => 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 { const domSelection = window.getSelection(); if (domSelection === null || !domSelection.isCollapsed) { @@ -135,63 +69,6 @@ function getQueryTextForSearch(editor: LexicalEditor): string | 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( editor: LexicalEditor, offset: number, @@ -314,265 +191,19 @@ export function useDynamicPositioning( }, [targetElement, editor, onVisibilityChange, onReposition, resolution]); } -export const SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND: LexicalCommand<{ - index: number - option: TypeaheadOption -}> = createCommand('SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND'); - -function LexicalPopoverMenu({ - close, - editor, +function LexicalPopoverMenu({ anchorElementRef, - resolution, - options, menuRenderFn, - onSelectOption, }: { - close: () => void - editor: LexicalEditor anchorElementRef: MutableRefObject - resolution: Resolution - options: Array - menuRenderFn: MenuRenderFn - onSelectOption: ( - option: TOption, - textNodeContainingQuery: TextNode | null, - closeMenu: () => void, - matchingString: string, - ) => void + menuRenderFn: MenuRenderFn }): JSX.Element | null { - const [selectedIndex, setHighlightedIndex] = useState(null); - - 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( - 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( - 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( - KEY_ESCAPE_COMMAND, - (payload) => { - const event = payload; - 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] - ) { - 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], - ); + return menuRenderFn(anchorElementRef); } function useMenuAnchorRef( resolution: Resolution | null, setResolution: (r: Resolution | null) => void, - className?: string, ): MutableRefObject { const [editor] = useLexicalComposerContext(); const anchorElementRef = useRef(document.createElement('div')); @@ -588,9 +219,6 @@ function useMenuAnchorRef( containerDiv.style.width = `${width}px`; if (!containerDiv.isConnected) { - if (className) { - containerDiv.className = className; - } containerDiv.setAttribute('aria-label', 'Typeahead menu'); containerDiv.setAttribute('id', 'typeahead-menu'); containerDiv.setAttribute('role', 'listbox'); @@ -601,7 +229,7 @@ function useMenuAnchorRef( anchorElementRef.current = containerDiv; rootElement.setAttribute('aria-controls', 'typeahead-menu'); } - }, [editor, resolution, className]); + }, [editor, resolution]); useEffect(() => { const rootElement = editor.getRootElement(); @@ -641,20 +269,11 @@ function useMenuAnchorRef( return anchorElementRef; } -export type TypeaheadMenuPluginProps = { - onQueryChange: (matchingString: string | null) => void - onSelectOption: ( - option: TOption, - textNodeContainingQuery: TextNode | null, - closeMenu: () => void, - matchingString: string, - ) => void - options: Array - menuRenderFn: MenuRenderFn +export type TypeaheadMenuPluginProps = { + menuRenderFn: MenuRenderFn triggerFn: TriggerFn onOpen?: (resolution: Resolution) => void onClose?: () => void - anchorClassName?: string }; export type TriggerFn = ( @@ -662,22 +281,17 @@ export type TriggerFn = ( editor: LexicalEditor, ) => QueryMatch | null; -export function TypeaheadMenuPlugin({ - options, - onQueryChange, - onSelectOption, +export function TypeaheadMenuPlugin({ onOpen, onClose, menuRenderFn, triggerFn, - anchorClassName, -}: TypeaheadMenuPluginProps): JSX.Element | null { +}: TypeaheadMenuPluginProps): JSX.Element | null { const [editor] = useLexicalComposerContext(); const [resolution, setResolution] = useState(null); const anchorElementRef = useMenuAnchorRef( resolution, setResolution, - anchorClassName, ); const closeTypeahead = useCallback(() => { @@ -701,21 +315,14 @@ export function TypeaheadMenuPlugin({ const updateListener = () => { editor.getEditorState().read(() => { const range = document.createRange(); - const selection = $getSelection(); const text = getQueryTextForSearch(editor); - if ( - !$isRangeSelection(selection) || - !selection.isCollapsed() || - text === null || - range === null - ) { + if (!text) { closeTypeahead(); return; } const match = triggerFn(text, editor); - onQueryChange(match ? match.matchingString : null); if ( match !== null && @@ -744,7 +351,6 @@ export function TypeaheadMenuPlugin({ }, [ editor, triggerFn, - onQueryChange, resolution, closeTypeahead, openTypeahead, @@ -752,115 +358,8 @@ export function TypeaheadMenuPlugin({ return resolution === null || editor === null ? null : ( - ); -} - -type NodeMenuPluginProps = { - onSelectOption: ( - option: TOption, - textNodeContainingQuery: TextNode | null, - closeMenu: () => void, - matchingString: string, - ) => void - options: Array - nodeKey: NodeKey | null - onClose?: () => void - onOpen?: (resolution: Resolution) => void - menuRenderFn: MenuRenderFn - anchorClassName?: string -}; - -export function LexicalNodeMenuPlugin({ - options, - nodeKey, - onClose, - onOpen, - onSelectOption, - menuRenderFn, - anchorClassName, -}: NodeMenuPluginProps): JSX.Element | null { - const [editor] = useLexicalComposerContext(); - const [resolution, setResolution] = useState(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 : ( - ); }