@lexical/react
Version:
This package provides Lexical components and hooks for React applications.
204 lines (198 loc) • 7.11 kB
JavaScript
/**
* 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, useRef, 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.
*
*/
function $getCurrentLink(key, url) {
const linkNode = $getNodeByKey(key);
return $isLinkNode(linkNode) && linkNode.isAttached() && linkNode.getURL() === url ? linkNode : null;
}
function $isMenuDisabledForNode(linkNode, $disableMenuFor) {
return $disableMenuFor != null && $disableMenuFor(linkNode);
}
/**
* 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,
$disableMenuFor
}) {
const [editor] = useLexicalComposerContext();
const [embedMatch, setEmbedMatch] = useState(null);
const nodeKey = embedMatch === null ? null : embedMatch.key;
const activeEmbedConfig = embedMatch === null ? null : embedMatch.embedConfig;
// Only the most recent paste may open the menu, even when an older
// asynchronous parseUrl result resolves after a newer one.
const latestCheckRef = useRef(0);
const reset = useCallback(() => {
setEmbedMatch(null);
}, []);
const checkIfLinkNodeIsEmbeddable = useCallback(async key => {
const check = ++latestCheckRef.current;
const url = editor.read('latest', function () {
const linkNode = $getNodeByKey(key);
if ($isLinkNode(linkNode)) {
if ($isMenuDisabledForNode(linkNode, $disableMenuFor)) {
return undefined;
}
return linkNode.getURL();
}
});
if (url === undefined) {
return;
}
// When several configs match, the last one wins, so check them from
// the end and stop at the first match to call parseUrl no more often
// than needed.
for (let i = embedConfigs.length - 1; i >= 0; i--) {
const embedConfig = embedConfigs[i];
const result = await Promise.resolve(embedConfig.parseUrl(url));
if (check !== latestCheckRef.current) {
return;
}
if (result != null) {
// Edits made while parseUrl was pending may not be committed yet,
// and the link may have moved into a disabled location since
if (editor.read('pending', () => {
const linkNode = $getCurrentLink(key, url);
return linkNode !== null && !$isMenuDisabledForNode(linkNode, $disableMenuFor);
})) {
setEmbedMatch({
embedConfig,
key,
result,
url
});
}
return;
}
}
}, [$disableMenuFor, 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(() => {
if (embedMatch == null) {
return;
}
const {
embedConfig,
key,
result,
url
} = embedMatch;
editor.update(() => {
const linkNode = $getCurrentLink(key, url);
if (linkNode === null) {
return;
}
if (!$getSelection()) {
linkNode.selectEnd();
}
embedConfig.insertNode(editor, result);
if (linkNode.isAttached()) {
linkNode.remove();
}
});
}, [editor, embedMatch]);
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 };