UNPKG

@lexical/react

Version:

This package provides Lexical components and hooks for React applications.

170 lines (165 loc) • 5.96 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 { $isLinkNode, LinkNode, AutoLinkNode } from '@lexical/link'; import { INSERT_EMBED_COMMAND } from '@lexical/react/LexicalAutoEmbedPluginUtils'; export { AutoEmbedOption, INSERT_EMBED_COMMAND, URL_MATCHER } from '@lexical/react/LexicalAutoEmbedPluginUtils'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { LexicalNodeMenuPlugin } from '@lexical/react/LexicalNodeMenuPlugin'; import { objectKlassEquals } from '@lexical/utils'; import { $getNodeByKey, mergeRegister, PASTE_COMMAND, $onUpdate, COMMAND_PRIORITY_BEFORE_EDITOR, COMMAND_PRIORITY_EDITOR, $getSelection, COMMAND_PRIORITY_LOW, PASTE_TAG } from 'lexical'; import { useState, useCallback, useEffect, useMemo } from 'react'; import { jsx } from 'react/jsx-runtime'; /** * 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. * */ /** * Watches for pasted AutoLink nodes that match any of the provided embed configurations (e.g., YouTube, Twitter URLs). * When a match is found, it shows a menu offering to replace the link with an embedded node. * * You can pass a generic type to the plugin to extend {@link EmbedConfig} * with additional data in {@link EmbedMatchResult} that will be passed to the callbacks * * @example * Usage * ```tsx * interface CustomEmbedConfig extends EmbedConfig<{ * domain: string; * oid?: string; * }> { * // Icon for display. * icon?: JSX.Element; * // Embed a Figma Project. * description?: string; * }; * * return ( * <LexicalAutoEmbedPlugin<CustomEmbedConfig> * embedConfigs={EmbedConfigs} * getMenuOptions={getMenuOptions} * /> * ); * ``` */ function LexicalAutoEmbedPlugin({ embedConfigs, onOpenEmbedModalForConfig, getMenuOptions, menuRenderFn, menuCommandPriority = COMMAND_PRIORITY_LOW }) { const [editor] = useLexicalComposerContext(); const [nodeKey, setNodeKey] = useState(null); const [activeEmbedConfig, setActiveEmbedConfig] = useState(null); const reset = useCallback(() => { setNodeKey(null); setActiveEmbedConfig(null); }, []); const checkIfLinkNodeIsEmbeddable = useCallback(async key => { const url = editor.read('latest', function () { const linkNode = $getNodeByKey(key); if ($isLinkNode(linkNode)) { return linkNode.getURL(); } }); if (url === undefined) { return; } for (const embedConfig of embedConfigs) { const urlMatch = await Promise.resolve(embedConfig.parseUrl(url)); if (urlMatch != null) { setActiveEmbedConfig(embedConfig); setNodeKey(key); } } }, [editor, embedConfigs]); useEffect(() => { let isSingleTokenPaste = false; const listener = (nodeMutations, { updateTags }) => { for (const [key, mutation] of nodeMutations) { if (mutation === 'created' && updateTags.has(PASTE_TAG) && isSingleTokenPaste) { checkIfLinkNodeIsEmbeddable(key); } else if (key === nodeKey) { reset(); } } }; return mergeRegister(editor.registerCommand(PASTE_COMMAND, event => { isSingleTokenPaste = objectKlassEquals(event, ClipboardEvent) && event.clipboardData !== null && /^\S+$/.test(event.clipboardData.getData('text/plain')); if (isSingleTokenPaste) { $onUpdate(() => { isSingleTokenPaste = false; }); } return false; }, COMMAND_PRIORITY_BEFORE_EDITOR), ...[LinkNode, AutoLinkNode].map(Klass => editor.registerMutationListener(Klass, listener, { skipInitialization: true }))); }, [checkIfLinkNodeIsEmbeddable, editor, nodeKey, reset]); useEffect(() => { if (!onOpenEmbedModalForConfig) return; return editor.registerCommand(INSERT_EMBED_COMMAND, embedConfigType => { const embedConfig = embedConfigs.find(({ type }) => type === embedConfigType); if (embedConfig) { onOpenEmbedModalForConfig(embedConfig); return true; } return false; }, COMMAND_PRIORITY_EDITOR); }, [editor, embedConfigs, onOpenEmbedModalForConfig]); const embedLinkViaActiveEmbedConfig = useCallback(async function () { if (activeEmbedConfig != null && nodeKey != null) { const linkNode = editor.read('latest', () => { const node = $getNodeByKey(nodeKey); if ($isLinkNode(node)) { return node; } return null; }); if ($isLinkNode(linkNode)) { const result = await Promise.resolve(activeEmbedConfig.parseUrl(linkNode.__url)); if (result != null) { editor.update(() => { if (!$getSelection()) { linkNode.selectEnd(); } activeEmbedConfig.insertNode(editor, result); if (linkNode.isAttached()) { linkNode.remove(); } }); } } } }, [activeEmbedConfig, editor, nodeKey]); const options = useMemo(() => { return activeEmbedConfig != null && nodeKey != null ? getMenuOptions(activeEmbedConfig, embedLinkViaActiveEmbedConfig, reset) : []; }, [activeEmbedConfig, embedLinkViaActiveEmbedConfig, getMenuOptions, nodeKey, reset]); const onSelectOption = useCallback((selectedOption, targetNode, closeMenu) => { editor.update(() => { selectedOption.onSelect(targetNode); closeMenu(); }); }, [editor]); return nodeKey != null ? /*#__PURE__*/jsx(LexicalNodeMenuPlugin, { nodeKey: nodeKey, onClose: reset, onSelectOption: onSelectOption, options: options, menuRenderFn: menuRenderFn, commandPriority: menuCommandPriority }) : null; } export { LexicalAutoEmbedPlugin };