From 9ce079ce7ee263f4707b7ac3b7c34f28ed1dedc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Mon, 13 Mar 2023 20:16:37 +0100 Subject: [PATCH] wip 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 | 5 +- .../editor/plugins/typeahead-menu-plugin.tsx | 890 ++++++++++++++++++ 2 files changed, 893 insertions(+), 2 deletions(-) create mode 100644 app/soapbox/features/compose/editor/plugins/typeahead-menu-plugin.tsx diff --git a/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx b/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx index d1d34495a..8825ba88c 100644 --- a/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx +++ b/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx @@ -8,7 +8,6 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { - LexicalTypeaheadMenuPlugin, QueryMatch, TypeaheadOption, useBasicTypeaheadTriggerMatch, @@ -19,6 +18,8 @@ import ReactDOM from 'react-dom'; import { $createMentionNode, MentionNode } from '../nodes/mention-node'; +import { TypeaheadMenuPlugin } from './typeahead-menu-plugin'; + import type { TextNode } from 'lexical'; const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i'); @@ -318,7 +319,7 @@ export function MentionPlugin(): JSX.Element | null { ); return ( - + onQueryChange={setQueryString} onSelectOption={onSelectOption} triggerFn={checkForMentionMatch} diff --git a/app/soapbox/features/compose/editor/plugins/typeahead-menu-plugin.tsx b/app/soapbox/features/compose/editor/plugins/typeahead-menu-plugin.tsx new file mode 100644 index 000000000..6e92c9dd3 --- /dev/null +++ b/app/soapbox/features/compose/editor/plugins/typeahead-menu-plugin.tsx @@ -0,0 +1,890 @@ +/** + * 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 { + $getNodeByKey, + $getSelection, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_LOW, + createCommand, + KEY_ARROW_DOWN_COMMAND, + KEY_ARROW_UP_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + KEY_TAB_COMMAND, + LexicalCommand, + LexicalEditor, + NodeKey, + RangeSelection, + TextNode, +} from 'lexical'; +import React, { + MutableRefObject, + ReactPortal, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +export type QueryMatch = { + leadOffset: number + matchingString: string + replaceableString: string +}; + +export type Resolution = { + match: QueryMatch + getRect: () => 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 = ( + 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 getTextUpToAnchor(selection: RangeSelection): string | 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 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(() => { + console.log(editor.getEditorState().toJSON()); + const selection = $getSelection(); + console.log(selection); + 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 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( + editor: LexicalEditor, + 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, +): 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 startTransition(callback: () => void) { + if (React.startTransition) { + React.startTransition(callback); + } else { + callback(); + } +} + +// 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; +} + +function isTriggerVisibleInNearestScrollContainer( + targetElement: HTMLElement, + containerElement: HTMLElement, +): boolean { + const tRect = targetElement.getBoundingClientRect(); + const cRect = containerElement.getBoundingClientRect(); + return tRect.top > cRect.top && tRect.top < cRect.bottom; +} + +// Reposition the menu on scroll, window resize, and element resize. +export function useDynamicPositioning( + resolution: Resolution | null, + targetElement: HTMLElement | null, + onReposition: () => void, + onVisibilityChange?: (isInView: boolean) => void, +) { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + if (targetElement != null && resolution != null) { + const rootElement = editor.getRootElement(); + const rootScrollParent = + rootElement != null + ? getScrollParent(rootElement, false) + : document.body; + let ticking = false; + let previousIsInView = isTriggerVisibleInNearestScrollContainer( + targetElement, + rootScrollParent, + ); + const handleScroll = function () { + if (!ticking) { + window.requestAnimationFrame(function () { + onReposition(); + ticking = false; + }); + ticking = true; + } + const isInView = isTriggerVisibleInNearestScrollContainer( + targetElement, + rootScrollParent, + ); + if (isInView !== previousIsInView) { + previousIsInView = isInView; + if (onVisibilityChange != null) { + onVisibilityChange(isInView); + } + } + }; + const resizeObserver = new ResizeObserver(onReposition); + window.addEventListener('resize', onReposition); + document.addEventListener('scroll', handleScroll, { + capture: true, + passive: true, + }); + resizeObserver.observe(targetElement); + return () => { + resizeObserver.unobserve(targetElement); + window.removeEventListener('resize', onReposition); + document.removeEventListener('scroll', handleScroll); + }; + } + }, [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, + 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 +}): JSX.Element | null { + const [selectedIndex, setHighlightedIndex] = useState(null); + + useEffect(() => { + setHighlightedIndex(0); + }, [resolution.match.matchingString]); + + const selectOptionAndCleanUp = useCallback( + (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( + SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, + ({ option }) => { + if (option.ref && option.ref.current != null) { + 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 != null && 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 != 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; + 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( + () => ({ + 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( + resolution: Resolution | null, + setResolution: (r: Resolution | null) => void, + className?: string, +): MutableRefObject { + const [editor] = useLexicalComposerContext(); + const anchorElementRef = useRef(document.createElement('div')); + const positionMenu = useCallback(() => { + const rootElement = editor.getRootElement(); + const containerDiv = anchorElementRef.current; + + if (rootElement !== null && resolution !== null) { + const { left, top, width, height } = resolution.getRect(); + containerDiv.style.top = `${top + window.pageYOffset}px`; + containerDiv.style.left = `${left + window.pageXOffset}px`; + containerDiv.style.height = `${height}px`; + containerDiv.style.width = `${width}px`; + + if (!containerDiv.isConnected) { + if (className != null) { + containerDiv.className = className; + } + containerDiv.setAttribute('aria-label', 'Typeahead menu'); + containerDiv.setAttribute('id', 'typeahead-menu'); + containerDiv.setAttribute('role', 'listbox'); + containerDiv.style.display = 'block'; + containerDiv.style.position = 'absolute'; + document.body.append(containerDiv); + } + anchorElementRef.current = containerDiv; + rootElement.setAttribute('aria-controls', 'typeahead-menu'); + } + }, [editor, resolution, className]); + + useEffect(() => { + const rootElement = editor.getRootElement(); + if (resolution !== null) { + positionMenu(); + return () => { + if (rootElement !== null) { + rootElement.removeAttribute('aria-controls'); + } + + const containerDiv = anchorElementRef.current; + if (containerDiv !== null && containerDiv.isConnected) { + containerDiv.remove(); + } + }; + } + }, [editor, positionMenu, resolution]); + + const onVisibilityChange = useCallback( + (isInView: boolean) => { + if (resolution !== null) { + if (!isInView) { + setResolution(null); + } + } + }, + [resolution, setResolution], + ); + + useDynamicPositioning( + resolution, + anchorElementRef.current, + positionMenu, + onVisibilityChange, + ); + + 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 + triggerFn: TriggerFn + onOpen?: (resolution: Resolution) => void + onClose?: () => void + anchorClassName?: string +}; + +export type TriggerFn = ( + text: string, + editor: LexicalEditor, +) => QueryMatch | null; + +export function TypeaheadMenuPlugin({ + options, + onQueryChange, + onSelectOption, + onOpen, + onClose, + menuRenderFn, + triggerFn, + anchorClassName, +}: TypeaheadMenuPluginProps): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + const [resolution, setResolution] = useState(null); + const anchorElementRef = useMenuAnchorRef( + resolution, + setResolution, + anchorClassName, + ); + + const closeTypeahead = useCallback(() => { + setResolution(null); + if (onClose != null && resolution !== null) { + onClose(); + } + }, [onClose, resolution]); + + const openTypeahead = useCallback( + (res: Resolution) => { + setResolution(res); + if (onOpen != null && resolution === null) { + onOpen(res); + } + }, + [onOpen, resolution], + ); + + useEffect(() => { + 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 + ) { + closeTypeahead(); + return; + } + + const match = triggerFn(text, editor); + onQueryChange(match ? match.matchingString : null); + + if ( + match !== null && + !isSelectionOnEntityBoundary(editor, match.leadOffset) + ) { + const isRangePositioned = tryToPositionRange(match.leadOffset, range); + if (isRangePositioned !== null) { + startTransition(() => + openTypeahead({ + getRect: () => range.getBoundingClientRect(), + match, + }), + ); + return; + } + } + closeTypeahead(); + }); + }; + + const removeUpdateListener = editor.registerUpdateListener(updateListener); + + return () => { + removeUpdateListener(); + }; + }, [ + editor, + triggerFn, + onQueryChange, + resolution, + closeTypeahead, + openTypeahead, + ]); + + 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 != null && resolution !== null) { + onClose(); + } + }, [onClose, resolution]); + + const openNodeMenu = useCallback( + (res: Resolution) => { + setResolution(res); + if (onOpen != null && resolution === null) { + onOpen(res); + } + }, + [onOpen, resolution], + ); + + const positionOrCloseMenu = useCallback(() => { + if (nodeKey) { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + const domElement = editor.getElementByKey(nodeKey); + if (node != null && domElement != null) { + const text = node.getTextContent(); + if (resolution == null || resolution.match.matchingString !== text) { + startTransition(() => + openNodeMenu({ + getRect: () => domElement.getBoundingClientRect(), + match: { + leadOffset: text.length, + matchingString: text, + replaceableString: text, + }, + }), + ); + } + } + }); + } else if (nodeKey == null && resolution != null) { + closeNodeMenu(); + } + }, [closeNodeMenu, editor, nodeKey, openNodeMenu, resolution]); + + useEffect(() => { + positionOrCloseMenu(); + }, [positionOrCloseMenu, nodeKey]); + + useEffect(() => { + if (nodeKey != null) { + return editor.registerUpdateListener(({ dirtyElements }) => { + if (dirtyElements.get(nodeKey)) { + positionOrCloseMenu(); + } + }); + } + }, [editor, positionOrCloseMenu, nodeKey]); + + return resolution === null || editor === null ? null : ( + + ); +}