UNPKG

@lexical/react

Version:

This package provides Lexical components and hooks for React applications.

656 lines (635 loc) • 25.9 kB
/** * 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 { CAN_USE_DOM, getRootOwnerDocument, mergeRegister, COMMAND_PRIORITY_LOW, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND, KEY_TAB_COMMAND, KEY_ENTER_COMMAND, isDOMShadowRoot, isHTMLElement, getParentElement, getDOMShadowRoots, registerEventListener, $getSelection, $isRangeSelection, $getNodeByKey } from 'lexical'; import { useLayoutEffect, useEffect, useRef, useCallback, useState, useMemo, startTransition } from 'react'; import { SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND } from '@lexical/react/LexicalTypeaheadMenuPluginUtils'; import { getScrollParent } from '@lexical/utils'; import ReactDOM from 'react-dom'; import { jsx, jsxs } from 'react/jsx-runtime'; export { MenuOption } from '@lexical/react/LexicalMenuOption'; /** * 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. * */ // This workaround is no longer necessary in React 19, // but we currently support React >=18.x // https://github.com/facebook/react/pull/26395 const useLayoutEffectImpl = CAN_USE_DOM ? useLayoutEffect : useEffect; /** * 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. * */ /** * A render function for a menu's contents. It receives the anchor element ref, * the current item props (selected index, options, and helpers to select or * highlight an option), and the matching query string, and returns the menu * element (or portal) to render, or `null` to render nothing. Provide one to * fully customize a menu's appearance. */ const scrollIntoViewIfNeeded = target => { const typeaheadContainerNode = target.closest('#typeahead-menu'); if (!typeaheadContainerNode) { return; } const typeaheadRect = typeaheadContainerNode.getBoundingClientRect(); // eslint-disable-next-line no-restricted-syntax if (typeaheadRect.top + typeaheadRect.height > window.innerHeight) { typeaheadContainerNode.scrollIntoView({ block: 'center' }); } if (typeaheadRect.top < 0) { typeaheadContainerNode.scrollIntoView({ block: 'center' }); } target.scrollIntoView({ block: 'nearest' }); }; /** * 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, entryText, offset) { let triggerOffset = offset; for (let i = triggerOffset; i <= entryText.length; i++) { if (documentText.slice(-i) === entryText.substring(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) { 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 isTriggerVisibleInNearestScrollContainer(targetElement, containerElement) { const tRect = targetElement.getBoundingClientRect(); const cRect = containerElement.getBoundingClientRect(); const VISIBILITY_MARGIN_PX = 6; return tRect.top >= cRect.top - VISIBILITY_MARGIN_PX && tRect.top <= cRect.bottom + VISIBILITY_MARGIN_PX; } /** * Keeps an open menu aligned with its trigger by calling `onReposition` on * scroll, window resize, and target element resize while `resolution` is set. * Optionally calls `onVisibilityChange` when the trigger enters or leaves its * nearest scroll container's viewport. */ // Reposition the menu on scroll, window resize, and element resize. function useDynamicPositioning(resolution, targetElement, onReposition, onVisibilityChange) { const [editor] = useLexicalComposerContext(); useEffect(() => { if (targetElement != null && resolution != null) { const rootElement = editor.getRootElement(); const rootScrollParent = rootElement != null ? getScrollParent(rootElement, false) : // eslint-disable-next-line no-restricted-syntax document.body; let ticking = false; let previousIsInView = isTriggerVisibleInNearestScrollContainer(targetElement, rootScrollParent); const handleScroll = function () { if (!ticking) { // eslint-disable-next-line no-restricted-syntax 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); // Scroll events are non-composed and do not cross shadow boundaries, // so the document-level listener below never sees scrolls inside an // enclosing shadow tree. Key off the editor root rather than the // target — the target may be portaled into the light DOM while the // editor (and its scroll container) live inside a shadow tree, and // getDOMShadowRoots(target) would then return an empty list. Walk // out of the editor's enclosing shadow roots instead so internal // scrolls at any depth reposition the floating menu. const enclosingShadowRoots = getDOMShadowRoots(rootElement ?? targetElement); resizeObserver.observe(targetElement); return mergeRegister(registerEventListener(window, 'resize', onReposition), registerEventListener(document, 'scroll', handleScroll, { capture: true, passive: true }), ...enclosingShadowRoots.map(root => registerEventListener(root, 'scroll', handleScroll, { capture: true, passive: true })), () => resizeObserver.unobserve(targetElement)); } }, [targetElement, editor, onVisibilityChange, onReposition, resolution]); } function MenuItem({ index, isSelected, onClick, onMouseEnter, option }) { let className = 'item'; if (isSelected) { className += ' selected'; } return /*#__PURE__*/jsxs("li", { tabIndex: -1, className: className, ref: option.setRefElement, role: "option", "aria-selected": isSelected, id: 'typeahead-item-' + index, onMouseEnter: onMouseEnter, onClick: onClick, children: [option.icon, /*#__PURE__*/jsx("span", { className: "text", children: option.title })] }, option.key); } function LexicalMenu({ close, editor, anchorElementRef, resolution, options, menuRenderFn: menuRenderFnProp, onSelectOption, shouldSplitNodeWithQuery = false, commandPriority = COMMAND_PRIORITY_LOW, preselectFirstItem = true }) { const [rawSelectedIndex, setHighlightedIndex] = useState(null); // Clamp highlighted index if options list shrinks const selectedIndex = rawSelectedIndex !== null ? Math.min(options.length - 1, rawSelectedIndex) : null; const matchingString = resolution.match && resolution.match.matchingString; useEffect(() => { if (preselectFirstItem) { // eslint-disable-next-line react-hooks/set-state-in-effect setHighlightedIndex(0); } }, [matchingString, preselectFirstItem]); const selectOptionAndCleanUp = useCallback(selectedEntry => { editor.update(() => { const textNodeContainingQuery = resolution.match != null && shouldSplitNodeWithQuery ? $splitNodeContainingQuery(resolution.match) : null; onSelectOption(selectedEntry, textNodeContainingQuery, close, resolution.match ? resolution.match.matchingString : ''); }); }, [editor, shouldSplitNodeWithQuery, resolution.match, onSelectOption, close]); const updateSelectedIndex = useCallback(index => { const rootElem = editor.getRootElement(); if (rootElem !== null) { rootElem.setAttribute('aria-activedescendant', 'typeahead-item-' + index); setHighlightedIndex(index); } }, [editor]); const defaultMenuRenderFn = useCallback(() => { return anchorElementRef.current && options.length ? /*#__PURE__*/ReactDOM.createPortal(/*#__PURE__*/jsx("div", { className: "typeahead-popover mentions-menu", children: /*#__PURE__*/jsx("ul", { children: options.map((option, i) => /*#__PURE__*/jsx(MenuItem, { index: i, isSelected: selectedIndex === i, onClick: () => { setHighlightedIndex(i); selectOptionAndCleanUp(option); }, onMouseEnter: () => { setHighlightedIndex(i); }, option: option }, option.key)) }) }), anchorElementRef.current) : null; }, [anchorElementRef, options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex]); useEffect(() => { return () => { const rootElem = editor.getRootElement(); if (rootElem !== null) { rootElem.removeAttribute('aria-activedescendant'); } }; }, [editor]); useLayoutEffectImpl(() => { if (options === null) { setHighlightedIndex(null); } else if (selectedIndex === null && preselectFirstItem) { updateSelectedIndex(0); } }, [options, selectedIndex, updateSelectedIndex, preselectFirstItem]); // Whether this menu currently puts nothing on screen, and so must not // consume the keys that would otherwise reach the editor. Only true for the // default renderer: a `menuRenderFn` is called whatever the option list // looks like, and is free to draw a "no results" panel that the user still // has to be able to arrow through and dismiss. const rendersNothing = menuRenderFnProp == null && (options === null || !options.length); 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; }, commandPriority)); }, [editor, updateSelectedIndex, commandPriority]); useEffect(() => { return mergeRegister(editor.registerCommand(KEY_ARROW_DOWN_COMMAND, payload => { const event = payload; if (rendersNothing) { // There is nothing to move through, and the default renderer draws // no menu for an empty list, so the key has to keep propagating to // whatever would otherwise move the caret. return false; } const newSelectedIndex = selectedIndex === null ? 0 : selectedIndex !== options.length - 1 ? selectedIndex + 1 : 0; updateSelectedIndex(newSelectedIndex); const option = options[newSelectedIndex]; if (!option) { updateSelectedIndex(-1); event.preventDefault(); event.stopImmediatePropagation(); return true; } if (option.ref && option.ref.current) { editor.dispatchCommand(SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, { index: newSelectedIndex, option }); } event.preventDefault(); event.stopImmediatePropagation(); return true; }, commandPriority), editor.registerCommand(KEY_ARROW_UP_COMMAND, payload => { const event = payload; if (rendersNothing) { // See KEY_ARROW_DOWN_COMMAND above. return false; } const newSelectedIndex = selectedIndex === null ? options.length - 1 : selectedIndex !== 0 ? selectedIndex - 1 : options.length - 1; updateSelectedIndex(newSelectedIndex); const option = options[newSelectedIndex]; if (!option) { updateSelectedIndex(-1); event.preventDefault(); event.stopImmediatePropagation(); return true; } if (option.ref && option.ref.current) { scrollIntoViewIfNeeded(option.ref.current); } event.preventDefault(); event.stopImmediatePropagation(); return true; }, commandPriority), editor.registerCommand(KEY_ESCAPE_COMMAND, payload => { const event = payload; if (rendersNothing) { // See KEY_ARROW_DOWN_COMMAND above: with nothing on screen there // is no menu to dismiss, so Escape has to keep propagating to // whatever would otherwise handle it. return false; } event.preventDefault(); event.stopImmediatePropagation(); close(); return true; }, commandPriority), 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; }, commandPriority), editor.registerCommand(KEY_ENTER_COMMAND, event => { if (options === null || selectedIndex === null || options[selectedIndex] == null || // Shift+Enter must reach rich-text line-break handling event && event.shiftKey) { return false; } if (event !== null) { event.preventDefault(); event.stopImmediatePropagation(); } selectOptionAndCleanUp(options[selectedIndex]); return true; }, commandPriority)); }, [selectOptionAndCleanUp, close, editor, options, rendersNothing, selectedIndex, updateSelectedIndex, commandPriority]); const listItemProps = useMemo(() => ({ options, selectOptionAndCleanUp, selectedIndex, setHighlightedIndex }), [selectOptionAndCleanUp, selectedIndex, options]); if (menuRenderFnProp != null) { return menuRenderFnProp(anchorElementRef, listItemProps, resolution.match ? resolution.match.matchingString : ''); } return defaultMenuRenderFn(); } function setContainerDivAttributes(containerDiv, className) { if (className != null) { containerDiv.className = className; } containerDiv.setAttribute('aria-label', 'Typeahead menu'); containerDiv.setAttribute('role', 'listbox'); containerDiv.style.display = 'block'; containerDiv.style.position = 'absolute'; } /** * Whether an element establishes the containing block that an absolutely * positioned descendant resolves its offsets against. Being positioned is the * usual reason, but a transform, filter, containment or a `will-change` naming * one of those does it too, on an otherwise statically positioned element. */ function establishesContainingBlock(style) { if (style.position !== 'static') { return true; } const willChange = style.willChange; return style.transform !== 'none' || style.perspective !== 'none' || style.filter !== 'none' || style.backdropFilter !== 'none' || style.contain.includes('paint') || style.contain.includes('layout') || style.contain.includes('strict') || style.contain.includes('content') || willChange.includes('transform') || willChange.includes('perspective') || willChange.includes('filter') || willChange.includes('contain'); } /** * The anchor is absolutely positioned, so its `top`/`left` are resolved * against its containing block. That is the initial containing block — i.e. * document coordinates, which is why the page scroll offsets are added — only * while the anchor's ancestors are all statically positioned, as is the case * for the default `document.body` parent. A `parent` passed to * {@link useMenuAnchorRef} is usually positioned so that it can contain the * menu, and document coordinates then place the menu at the parent's own * offset instead of at the caret. * * Resolved by walking up from the element the anchor is appended to rather * than from the anchor's own `offsetParent`: the anchor is removed from the * DOM every time the menu closes and is positioned again before it is * re-attached, so at this point it is usually detached, and a detached element * has no `offsetParent` to read. * * @returns The viewport coordinates of the origin that the anchor's `top`/ * `left` are measured from, or `null` when that origin is the initial * containing block and document coordinates apply. */ function getContainingBlockOrigin(parent) { // An anchor inside a shadow tree is laid out against the flat tree, so the // walk continues at the host. const start = isDOMShadowRoot(parent) ? parent.host : parent; for (let element = isHTMLElement(start) ? start : null; element !== null; element = getParentElement(element)) { const view = element.ownerDocument.defaultView; if (view === null) { break; } if (establishesContainingBlock(view.getComputedStyle(element))) { const rect = element.getBoundingClientRect(); return { left: rect.left + element.clientLeft - element.scrollLeft, top: rect.top + element.clientTop - element.scrollTop }; } } // Nothing at or above the insertion point is positioned, so the containing // block is still the initial one and document coordinates apply. return null; } function resolveMenuParent(editor) { if (!CAN_USE_DOM) { return undefined; } const rootElement = editor.getRootElement(); if (rootElement !== null) { const root = rootElement.getRootNode(); if (isDOMShadowRoot(root)) { return root; } return rootElement.ownerDocument.body; } // eslint-disable-next-line no-restricted-syntax -- rootElement is null, no ownerDocument available return document.body; } function useMenuAnchorRef(resolution, setResolution, className, parent, shouldIncludePageYOffset__EXPERIMENTAL = true) { const [editor] = useLexicalComposerContext(); const resolvedParent = parent ?? resolveMenuParent(editor); const initialAnchorElement = CAN_USE_DOM ? getRootOwnerDocument(editor.getRootElement()).createElement('div') : null; const anchorElementRef = useRef(initialAnchorElement); const positionMenu = useCallback(() => { if (anchorElementRef.current === null || resolvedParent === undefined) { return; } anchorElementRef.current.style.top = anchorElementRef.current.style.bottom; const rootElement = editor.getRootElement(); const containerDiv = anchorElementRef.current; const menuEle = containerDiv.firstChild; if (rootElement !== null && resolution !== null) { const { left, top, width, height } = resolution.getRect(); const anchorHeight = anchorElementRef.current.offsetHeight; // use to position under anchor // `left`/`top` from getRect() are viewport coordinates; translate them // into the coordinate space the anchor is actually positioned in. const origin = getContainingBlockOrigin(resolvedParent); const toAnchorLeft = viewportLeft => origin !== null ? viewportLeft - origin.left : // eslint-disable-next-line no-restricted-syntax viewportLeft + window.pageXOffset; const toAnchorTop = viewportTop => origin !== null ? viewportTop - origin.top : viewportTop + ( // eslint-disable-next-line no-restricted-syntax shouldIncludePageYOffset__EXPERIMENTAL ? window.pageYOffset : 0); containerDiv.style.top = `${toAnchorTop(top + anchorHeight + 3)}px`; containerDiv.style.left = `${toAnchorLeft(left)}px`; containerDiv.style.height = `${height}px`; containerDiv.style.width = `${width}px`; if (menuEle !== null) { menuEle.style.top = `${top}`; const menuRect = menuEle.getBoundingClientRect(); const menuHeight = menuRect.height; const menuWidth = menuRect.width; const rootElementRect = rootElement.getBoundingClientRect(); if (left + menuWidth > rootElementRect.right) { containerDiv.style.left = `${toAnchorLeft(rootElementRect.right - menuWidth)}px`; } if ( // eslint-disable-next-line no-restricted-syntax (top + menuHeight > window.innerHeight || top + menuHeight > rootElementRect.bottom) && top - rootElementRect.top > menuHeight + height) { containerDiv.style.top = `${toAnchorTop(top - menuHeight - height)}px`; } } if (!containerDiv.isConnected) { setContainerDivAttributes(containerDiv, className); resolvedParent.append(containerDiv); } containerDiv.setAttribute('id', 'typeahead-menu'); rootElement.setAttribute('aria-controls', 'typeahead-menu'); } }, [editor, resolution, shouldIncludePageYOffset__EXPERIMENTAL, className, resolvedParent]); useEffect(() => { const rootElement = editor.getRootElement(); if (resolution !== null) { positionMenu(); } return () => { if (rootElement !== null) { rootElement.removeAttribute('aria-controls'); } // eslint-disable-next-line react-hooks/exhaustive-deps const containerDiv = anchorElementRef.current; if (containerDiv !== null && containerDiv.isConnected) { containerDiv.remove(); containerDiv.removeAttribute('id'); } }; }, [editor, positionMenu, resolution]); const onVisibilityChange = useCallback(isInView => { if (resolution !== null) { if (!isInView) { setResolution(null); } } }, [resolution, setResolution]); useDynamicPositioning(resolution, anchorElementRef.current, positionMenu, onVisibilityChange); // Append the context for the menu immediately if (initialAnchorElement != null && initialAnchorElement === anchorElementRef.current) { setContainerDivAttributes(initialAnchorElement, className); if (resolvedParent != null) { resolvedParent.append(initialAnchorElement); } } return anchorElementRef; } /** * Detects whether the text before the cursor should open a typeahead menu. * Given the current `text` and `editor`, it returns a {@link MenuTextMatch} * describing the match, or `null` if there is none. See * {@link useBasicTypeaheadTriggerMatch} for a common implementation. */ /** * 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. * */ /** * Renders a floating menu anchored to a specific node (identified by * `nodeKey`), for example to offer actions on a just-inserted node. It is the * node-anchored counterpart to {@link LexicalTypeaheadMenuPlugin}: provide the * `options` to show and an `onSelectOption` handler, and the menu opens while * `nodeKey` refers to a node and closes when it becomes `null`. * * @returns The floating menu element, or `null` when the menu is closed. */ function LexicalNodeMenuPlugin({ options, nodeKey, onClose, onOpen, onSelectOption, menuRenderFn, anchorClassName, commandPriority = COMMAND_PRIORITY_LOW, parent }) { const [editor] = useLexicalComposerContext(); const [resolution, setResolution] = useState(null); const anchorElementRef = useMenuAnchorRef(resolution, setResolution, anchorClassName, parent); const closeNodeMenu = useCallback(() => { setResolution(null); if (onClose != null && resolution !== null) { onClose(); } }, [onClose, resolution]); const openNodeMenu = useCallback(res => { 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) { if (resolution == null) { startTransition(() => openNodeMenu({ getRect: () => domElement.getBoundingClientRect() })); } } }); } else if (nodeKey == null && resolution != null) { closeNodeMenu(); } }, [closeNodeMenu, editor, nodeKey, openNodeMenu, resolution]); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect positionOrCloseMenu(); }, [positionOrCloseMenu, nodeKey]); useEffect(() => { if (nodeKey != null) { return editor.registerUpdateListener(({ dirtyElements }) => { if (dirtyElements.get(nodeKey)) { positionOrCloseMenu(); } }); } }, [editor, positionOrCloseMenu, nodeKey]); return anchorElementRef.current === null || resolution === null || editor === null ? null : /*#__PURE__*/jsx(LexicalMenu, { close: closeNodeMenu, resolution: resolution, editor: editor, anchorElementRef: anchorElementRef, options: options, menuRenderFn: menuRenderFn, onSelectOption: onSelectOption, commandPriority: commandPriority }); } export { LexicalNodeMenuPlugin };