/** * 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 clsx from 'clsx'; import { $getSelection, $isRangeSelection, $isTextNode, LexicalEditor, RangeSelection, } from 'lexical'; import React, { MutableRefObject, ReactPortal, useCallback, useEffect, useRef, useState, } from 'react'; import ReactDOM from 'react-dom'; import { fetchComposeSuggestions } from 'soapbox/actions/compose'; import { useEmoji } from 'soapbox/actions/emojis'; import AutosuggestEmoji from 'soapbox/components/autosuggest-emoji'; import { isNativeEmoji } from 'soapbox/features/emoji'; import { useAppDispatch, useCompose } from 'soapbox/hooks'; import { textAtCursorMatchesToken } from 'soapbox/utils/suggestions'; import AutosuggestAccount from '../../components/autosuggest-account'; import { $createEmojiNode } from '../nodes/emoji-node'; import type { AutoSuggestion } from 'soapbox/components/autosuggest-input'; type QueryMatch = { leadOffset: number matchingString: string }; type Resolution = { match: QueryMatch getRect: () => DOMRect }; type MenuRenderFn = ( anchorElementRef: MutableRefObject, ) => ReactPortal | JSX.Element | null; const 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 || !endOffset) { return false; } try { range.setStart(anchorNode, startOffset); range.setEnd(anchorNode, endOffset); } catch (error) { return false; } return true; }; const 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 startTransition = (callback: () => void) => { if (React.startTransition) { React.startTransition(callback); } else { callback(); } }; // Got from https://stackoverflow.com/a/42543908/2013580 const 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; }; const 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. const useDynamicPositioning = ( resolution: Resolution | null, targetElement: HTMLElement | null, onReposition: () => void, onVisibilityChange?: (isInView: boolean) => void, ) => { const [editor] = useLexicalComposerContext(); useEffect(() => { if (targetElement && resolution) { const rootElement = editor.getRootElement(); const rootScrollParent = rootElement ? getScrollParent(rootElement, false) : document.body; let ticking = false; let previousIsInView = isTriggerVisibleInNearestScrollContainer( targetElement, rootScrollParent, ); const handleScroll = () => { if (!ticking) { window.requestAnimationFrame(() => { onReposition(); ticking = false; }); ticking = true; } const isInView = isTriggerVisibleInNearestScrollContainer( targetElement, rootScrollParent, ); if (isInView !== previousIsInView) { previousIsInView = isInView; if (onVisibilityChange) { 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]); }; const LexicalPopoverMenu = ({ anchorElementRef, menuRenderFn }: { anchorElementRef: MutableRefObject menuRenderFn: MenuRenderFn }): JSX.Element | null => menuRenderFn(anchorElementRef); const useMenuAnchorRef = ( resolution: Resolution | null, setResolution: (r: Resolution | null) => void, ): 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) { 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]); 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; }; type AutosuggestPluginProps = { composeId: string suggestionsHidden: boolean setSuggestionsHidden: (value: boolean) => void }; const AutosuggestPlugin = ({ composeId, suggestionsHidden, setSuggestionsHidden, }: AutosuggestPluginProps): JSX.Element | null => { const { suggestions } = useCompose(composeId); const dispatch = useAppDispatch(); const [editor] = useLexicalComposerContext(); const [resolution, setResolution] = useState(null); const [selectedSuggestion] = useState(0); const anchorElementRef = useMenuAnchorRef( resolution, setResolution, ); const onSelectSuggestion: React.MouseEventHandler = (e) => { e.preventDefault(); const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index') as any) as AutoSuggestion; editor.update(() => { dispatch((dispatch, getState) => { const state = editor.getEditorState(); const node = (state._selection as RangeSelection)?.anchor?.getNode(); if (typeof suggestion === 'object' && suggestion.id) { dispatch(useEmoji(suggestion)); // eslint-disable-line react-hooks/rules-of-hooks const { leadOffset, matchingString } = resolution!.match; if (isNativeEmoji(suggestion)) { node.spliceText(leadOffset - 1, matchingString.length, `${suggestion.native} `, true); } else { const completion = suggestion.colons; let emojiText; if (leadOffset === 1) emojiText = node; else [, emojiText] = node.splitText(leadOffset - 1); [emojiText] = emojiText.splitText(matchingString.length); emojiText?.replace($createEmojiNode(completion, suggestion.imageUrl)); } } else if ((suggestion as string)[0] === '#') { node.setTextContent(`${suggestion} `); node.select(); } else { const content = getState().accounts.get(suggestion)!.acct; node.setTextContent(`@${content} `); node.select(); } }); }); }; const getQueryTextForSearch = (editor: LexicalEditor) => { const state = editor.getEditorState(); const node = (state._selection as RangeSelection)?.anchor?.getNode(); if (!node) return null; if (['mention', 'hashtag'].includes(node.getType())) { const matchingString = node.getTextContent(); return { leadOffset: 0, matchingString }; } if (node.getType() === 'text') { const [leadOffset, matchingString] = textAtCursorMatchesToken(node.getTextContent(), (state._selection as RangeSelection)?.anchor?.offset, [':']); if (!leadOffset || !matchingString) return null; return { leadOffset, matchingString }; } return null; }; const renderSuggestion = (suggestion: AutoSuggestion, i: number) => { let inner; let key; if (typeof suggestion === 'object') { inner = ; key = suggestion.id; } else if (suggestion[0] === '#') { inner = suggestion; key = suggestion; } else { inner = ; key = suggestion; } return (
{inner}
); }; const closeTypeahead = useCallback(() => { setResolution(null); }, [resolution]); const openTypeahead = useCallback( (res: Resolution) => { setResolution(res); }, [resolution], ); useEffect(() => { const updateListener = () => { editor.getEditorState().read(() => { const range = document.createRange(); const match = getQueryTextForSearch(editor); if (!match) { closeTypeahead(); return; } dispatch(fetchComposeSuggestions(composeId, match.matchingString.trim())); if ( !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, resolution, closeTypeahead, openTypeahead, ]); useEffect(() => { if (suggestions && suggestions.size > 0) setSuggestionsHidden(false); }, [suggestions]); return resolution === null || editor === null ? null : ( anchorElementRef.current ? ReactDOM.createPortal(
{suggestions.map(renderSuggestion)}
, anchorElementRef.current, ) : null } /> ); }; export default AutosuggestPlugin;