@lexical/clipboard
Version:
This package provides the copy/paste functionality for Lexical.
431 lines (409 loc) • 16.6 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 { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html';
import { $addNodeStyle, $sliceSelectedTextNodeContent } from '@lexical/selection';
import { objectKlassEquals } from '@lexical/utils';
import { $isRangeSelection, $getSelection, $createTabNode, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, $getRoot, $parseSerializedNode, $isTextNode, getDOMSelection, COPY_COMMAND, COMMAND_PRIORITY_CRITICAL, isSelectionWithinEditor, $getEditor, $isElementNode, $cloneWithProperties } from 'lexical';
/**
* 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.
*
*/
// Do not require this module directly! Use normal `invariant` calls.
function formatDevErrorMessage(message) {
throw new Error(message);
}
/**
* Returns the *currently selected* Lexical content as an HTML string, relying on the
* logic defined in the exportDOM methods on the LexicalNode classes. Note that
* this will not return the HTML content of the entire editor (unless all the content is included
* in the current selection).
*
* @param editor - LexicalEditor instance to get HTML content from
* @param selection - The selection to use (default is $getSelection())
* @returns a string of HTML content
*/
function $getHtmlContent(editor, selection = $getSelection()) {
if (selection == null) {
{
formatDevErrorMessage(`Expected valid LexicalSelection`);
}
}
// If we haven't selected anything
if ($isRangeSelection(selection) && selection.isCollapsed() || selection.getNodes().length === 0) {
return '';
}
return $generateHtmlFromNodes(editor, selection);
}
/**
* Returns the *currently selected* Lexical content as a JSON string, relying on the
* logic defined in the exportJSON methods on the LexicalNode classes. Note that
* this will not return the JSON content of the entire editor (unless all the content is included
* in the current selection).
*
* @param editor - LexicalEditor instance to get the JSON content from
* @param selection - The selection to use (default is $getSelection())
* @returns
*/
function $getLexicalContent(editor, selection = $getSelection()) {
if (selection == null) {
{
formatDevErrorMessage(`Expected valid LexicalSelection`);
}
}
// If we haven't selected anything
if ($isRangeSelection(selection) && selection.isCollapsed() || selection.getNodes().length === 0) {
return null;
}
return JSON.stringify($generateJSONFromSelectedNodes(editor, selection));
}
/**
* Attempts to insert content of the mime-types text/plain or text/uri-list from
* the provided DataTransfer object into the editor at the provided selection.
* text/uri-list is only used if text/plain is not also provided.
*
* @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
* @param selection the selection to use as the insertion point for the content in the DataTransfer object
*/
function $insertDataTransferForPlainText(dataTransfer, selection) {
const text = dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list');
if (text != null) {
selection.insertRawText(text);
}
}
/**
* Attempts to insert content of the mime-types application/x-lexical-editor, text/html,
* text/plain, or text/uri-list (in descending order of priority) from the provided DataTransfer
* object into the editor at the provided selection.
*
* @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
* @param selection the selection to use as the insertion point for the content in the DataTransfer object
* @param editor the LexicalEditor the content is being inserted into.
*/
function $insertDataTransferForRichText(dataTransfer, selection, editor) {
const lexicalString = dataTransfer.getData('application/x-lexical-editor');
if (lexicalString) {
try {
const payload = JSON.parse(lexicalString);
if (payload.namespace === editor._config.namespace && Array.isArray(payload.nodes)) {
const nodes = $generateNodesFromSerializedNodes(payload.nodes);
return $insertGeneratedNodes(editor, nodes, selection);
}
} catch (_unused) {
// Fail silently.
}
}
const htmlString = dataTransfer.getData('text/html');
const plainString = dataTransfer.getData('text/plain');
// Skip HTML handling if it matches the plain text representation.
// This avoids unnecessary processing for plain text strings created by
// iOS Safari autocorrect, which incorrectly includes a `text/html` type.
if (htmlString && plainString !== htmlString) {
try {
const parser = new DOMParser();
const dom = parser.parseFromString(trustHTML(htmlString), 'text/html');
const nodes = $generateNodesFromDOM(editor, dom);
return $insertGeneratedNodes(editor, nodes, selection);
} catch (_unused2) {
// Fail silently.
}
}
// Multi-line plain text in rich text mode pasted as separate paragraphs
// instead of single paragraph with linebreaks.
// Webkit-specific: Supports read 'text/uri-list' in clipboard.
const text = plainString || dataTransfer.getData('text/uri-list');
if (text != null) {
if ($isRangeSelection(selection)) {
const parts = text.split(/(\r?\n|\t)/);
if (parts[parts.length - 1] === '') {
parts.pop();
}
for (let i = 0; i < parts.length; i++) {
const currentSelection = $getSelection();
if ($isRangeSelection(currentSelection)) {
const part = parts[i];
if (part === '\n' || part === '\r\n') {
currentSelection.insertParagraph();
} else if (part === '\t') {
currentSelection.insertNodes([$createTabNode()]);
} else {
currentSelection.insertText(part);
}
}
}
} else {
selection.insertRawText(text);
}
}
}
function trustHTML(html) {
if (window.trustedTypes && window.trustedTypes.createPolicy) {
const policy = window.trustedTypes.createPolicy('lexical', {
createHTML: input => input
});
return policy.createHTML(html);
}
return html;
}
/**
* Inserts Lexical nodes into the editor using different strategies depending on
* some simple selection-based heuristics. If you're looking for a generic way to
* to insert nodes into the editor at a specific selection point, you probably want
* {@link lexical.$insertNodes}
*
* @param editor LexicalEditor instance to insert the nodes into.
* @param nodes The nodes to insert.
* @param selection The selection to insert the nodes into.
*/
function $insertGeneratedNodes(editor, nodes, selection) {
if (!editor.dispatchCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, {
nodes,
selection
})) {
selection.insertNodes(nodes);
}
return;
}
function exportNodeToJSON(node) {
const serializedNode = node.exportJSON();
const nodeClass = node.constructor;
if (serializedNode.type !== nodeClass.getType()) {
{
formatDevErrorMessage(`LexicalNode: Node ${nodeClass.name} does not implement .exportJSON().`);
}
}
if ($isElementNode(node)) {
const serializedChildren = serializedNode.children;
if (!Array.isArray(serializedChildren)) {
{
formatDevErrorMessage(`LexicalNode: Node ${nodeClass.name} is an element but .exportJSON() does not have a children array.`);
}
}
}
return serializedNode;
}
function $appendNodesToJSON(editor, selection, currentNode, targetArray = []) {
let shouldInclude = selection !== null ? currentNode.isSelected(selection) : true;
const shouldExclude = $isElementNode(currentNode) && currentNode.excludeFromCopy('html');
let target = currentNode;
if (selection !== null) {
let clone = $cloneWithProperties(currentNode);
clone = $isTextNode(clone) && selection !== null ? $sliceSelectedTextNodeContent(selection, clone) : clone;
target = clone;
}
const children = $isElementNode(target) ? target.getChildren() : [];
const serializedNode = exportNodeToJSON(target);
// TODO: TextNode calls getTextContent() (NOT node.__text) within its exportJSON method
// which uses getLatest() to get the text from the original node with the same key.
// This is a deeper issue with the word "clone" here, it's still a reference to the
// same node as far as the LexicalEditor is concerned since it shares a key.
// We need a way to create a clone of a Node in memory with its own key, but
// until then this hack will work for the selected text extract use case.
if ($isTextNode(target)) {
const text = target.__text;
// If an uncollapsed selection ends or starts at the end of a line of specialized,
// TextNodes, such as code tokens, we will get a 'blank' TextNode here, i.e., one
// with text of length 0. We don't want this, it makes a confusing mess. Reset!
if (text.length > 0) {
serializedNode.text = text;
} else {
shouldInclude = false;
}
}
for (let i = 0; i < children.length; i++) {
const childNode = children[i];
const shouldIncludeChild = $appendNodesToJSON(editor, selection, childNode, serializedNode.children);
if (!shouldInclude && $isElementNode(currentNode) && shouldIncludeChild && currentNode.extractWithChild(childNode, selection, 'clone')) {
shouldInclude = true;
}
}
if (shouldInclude && !shouldExclude) {
targetArray.push(serializedNode);
} else if (Array.isArray(serializedNode.children)) {
for (let i = 0; i < serializedNode.children.length; i++) {
const serializedChildNode = serializedNode.children[i];
targetArray.push(serializedChildNode);
}
}
return shouldInclude;
}
// TODO why $ function with Editor instance?
/**
* Gets the Lexical JSON of the nodes inside the provided Selection.
*
* @param editor LexicalEditor to get the JSON content from.
* @param selection Selection to get the JSON content from.
* @returns an object with the editor namespace and a list of serializable nodes as JavaScript objects.
*/
function $generateJSONFromSelectedNodes(editor, selection) {
const nodes = [];
const root = $getRoot();
const topLevelChildren = root.getChildren();
for (let i = 0; i < topLevelChildren.length; i++) {
const topLevelNode = topLevelChildren[i];
$appendNodesToJSON(editor, selection, topLevelNode, nodes);
}
return {
namespace: editor._config.namespace,
nodes
};
}
/**
* This method takes an array of objects conforming to the BaseSerializedNode interface and returns
* an Array containing instances of the corresponding LexicalNode classes registered on the editor.
* Normally, you'd get an Array of BaseSerialized nodes from {@link $generateJSONFromSelectedNodes}
*
* @param serializedNodes an Array of objects conforming to the BaseSerializedNode interface.
* @returns an Array of Lexical Node objects.
*/
function $generateNodesFromSerializedNodes(serializedNodes) {
const nodes = [];
for (let i = 0; i < serializedNodes.length; i++) {
const serializedNode = serializedNodes[i];
const node = $parseSerializedNode(serializedNode);
if ($isTextNode(node)) {
$addNodeStyle(node);
}
nodes.push(node);
}
return nodes;
}
const EVENT_LATENCY = 50;
let clipboardEventTimeout = null;
// TODO custom selection
// TODO potentially have a node customizable version for plain text
/**
* Copies the content of the current selection to the clipboard in
* text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
* formats.
*
* @param editor the LexicalEditor instance to copy content from
* @param event the native browser ClipboardEvent to add the content to.
* @returns
*/
async function copyToClipboard(editor, event, data) {
if (clipboardEventTimeout !== null) {
// Prevent weird race conditions that can happen when this function is run multiple times
// synchronously. In the future, we can do better, we can cancel/override the previously running job.
return false;
}
if (event !== null) {
return new Promise((resolve, reject) => {
editor.update(() => {
resolve($copyToClipboardEvent(editor, event, data));
});
});
}
const rootElement = editor.getRootElement();
const editorWindow = editor._window || window;
const windowDocument = window.document;
const domSelection = getDOMSelection(editorWindow);
if (rootElement === null || domSelection === null) {
return false;
}
const element = windowDocument.createElement('span');
element.style.cssText = 'position: fixed; top: -1000px;';
element.append(windowDocument.createTextNode('#'));
rootElement.append(element);
const range = new Range();
range.setStart(element, 0);
range.setEnd(element, 1);
domSelection.removeAllRanges();
domSelection.addRange(range);
return new Promise((resolve, reject) => {
const removeListener = editor.registerCommand(COPY_COMMAND, secondEvent => {
if (objectKlassEquals(secondEvent, ClipboardEvent)) {
removeListener();
if (clipboardEventTimeout !== null) {
window.clearTimeout(clipboardEventTimeout);
clipboardEventTimeout = null;
}
resolve($copyToClipboardEvent(editor, secondEvent, data));
}
// Block the entire copy flow while we wait for the next ClipboardEvent
return true;
}, COMMAND_PRIORITY_CRITICAL);
// If the above hack execCommand hack works, this timeout code should never fire. Otherwise,
// the listener will be quickly freed so that the user can reuse it again
clipboardEventTimeout = window.setTimeout(() => {
removeListener();
clipboardEventTimeout = null;
resolve(false);
}, EVENT_LATENCY);
windowDocument.execCommand('copy');
element.remove();
});
}
// TODO shouldn't pass editor (pass namespace directly)
function $copyToClipboardEvent(editor, event, data) {
if (data === undefined) {
const domSelection = getDOMSelection(editor._window);
if (!domSelection) {
return false;
}
const anchorDOM = domSelection.anchorNode;
const focusDOM = domSelection.focusNode;
if (anchorDOM !== null && focusDOM !== null && !isSelectionWithinEditor(editor, anchorDOM, focusDOM)) {
return false;
}
const selection = $getSelection();
if (selection === null) {
return false;
}
data = $getClipboardDataFromSelection(selection);
}
event.preventDefault();
const clipboardData = event.clipboardData;
if (clipboardData === null) {
return false;
}
setLexicalClipboardDataTransfer(clipboardData, data);
return true;
}
const clipboardDataFunctions = [['text/html', $getHtmlContent], ['application/x-lexical-editor', $getLexicalContent]];
/**
* Serialize the content of the current selection to strings in
* text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
* formats (as available).
*
* @param selection the selection to serialize (defaults to $getSelection())
* @returns LexicalClipboardData
*/
function $getClipboardDataFromSelection(selection = $getSelection()) {
const clipboardData = {
'text/plain': selection ? selection.getTextContent() : ''
};
if (selection) {
const editor = $getEditor();
for (const [mimeType, $editorFn] of clipboardDataFunctions) {
const v = $editorFn(editor, selection);
if (v !== null) {
clipboardData[mimeType] = v;
}
}
}
return clipboardData;
}
/**
* Call setData on the given clipboardData for each MIME type present
* in the given data (from {@link $getClipboardDataFromSelection})
*
* @param clipboardData the event.clipboardData to populate from data
* @param data The lexical data
*/
function setLexicalClipboardDataTransfer(clipboardData, data) {
for (const k in data) {
const v = data[k];
if (v !== undefined) {
clipboardData.setData(k, v);
}
}
}
export { $generateJSONFromSelectedNodes, $generateNodesFromSerializedNodes, $getClipboardDataFromSelection, $getHtmlContent, $getLexicalContent, $insertDataTransferForPlainText, $insertDataTransferForRichText, $insertGeneratedNodes, copyToClipboard, setLexicalClipboardDataTransfer };