UNPKG

@lexical/clipboard

Version:

This package provides the copy/paste functionality for Lexical.

431 lines (409 loc) 16.6 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 { $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 };