UNPKG

@lexical/clipboard

Version:

This package provides the copy/paste functionality for Lexical.

702 lines (674 loc) 26.5 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 { LexicalBuilder, getExtensionDependencyFromEditor } from '@lexical/extension'; 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, $caretFromPoint, $isTextPointCaret, $getCaretRange, $getChildCaret, $getRoot, $isTextNode, $isElementNode, $parseSerializedNode, getDOMSelection, COPY_COMMAND, COMMAND_PRIORITY_CRITICAL, isSelectionWithinEditor, $getEditor, defineExtension, shallowMergeConfig, safeCast, $splitAtPointCaretNext, $setSelectionFromCaretRange, $getCollapsedCaretRange, $getNearestNodeFromDOMNode, $getTextPointCaret, $getChildCaretAtIndex, $getCaretRangeInDirection, $caretRangeFromSelection, $comparePointCaretNext } 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); } /** * 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 caretFromPoint(x, y) { if (typeof document.caretRangeFromPoint !== 'undefined') { const range = document.caretRangeFromPoint(x, y); if (range === null) { return null; } return { node: range.startContainer, offset: range.startOffset }; // @ts-ignore } else if (document.caretPositionFromPoint !== 'undefined') { // @ts-ignore FF - no types const range = document.caretPositionFromPoint(x, y); if (range === null) { return null; } return { node: range.offsetNode, offset: range.offset }; } else { // Gracefully handle IE return null; } } /** * 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 (error) { console.error(error); } } 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 (error) { console.error(error); } } // 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); } } } const LEXICAL_DRAG_MIME_TYPE = 'application/x-lexical-drag'; /** * Populate `dataTransfer` with a marker identifying the current editor as a * drag source. Pair this with {@link $handleRichTextDrop} or * {@link $handlePlainTextDrop} on the drop side to get cut-and-paste semantics * for drags that end in a different editor. * * Only the source editor's key needs to round-trip — the source's * RangeSelection itself is preserved on the source editor between drag start * and drop (Lexical suppresses selectionchange during drag), so the drop * handler reads it directly via `$getSelection()` on the resolved source * editor. * * Callers typically invoke this from a DRAGSTART_COMMAND handler alongside * {@link setLexicalClipboardDataTransfer} (so that the dragged content itself * round-trips with full node fidelity). */ function $writeDragSourceToDataTransfer(dataTransfer, editor) { const marker = { editorKey: editor.getKey() }; dataTransfer.setData(LEXICAL_DRAG_MIME_TYPE, JSON.stringify(marker)); } function isLexicalDragMarker(value) { return value !== null && typeof value === 'object' && 'editorKey' in value && typeof value.editorKey === 'string'; } function readDragMarker(dataTransfer) { const raw = dataTransfer.getData(LEXICAL_DRAG_MIME_TYPE); if (!raw) { return null; } let parsed; try { parsed = JSON.parse(raw); } catch (_unused) { return null; } return isLexicalDragMarker(parsed) ? parsed : null; } function findEditorRootByKey(key, doc) { const elements = doc.querySelectorAll('[data-lexical-editor="true"]'); for (const el of Array.from(elements)) { const editor = el.__lexicalEditor; if (editor && editor.getKey() === key) { return el; } } return null; } function $resolveDropPointCaret(event) { const hit = caretFromPoint(event.clientX, event.clientY); if (hit === null) { return null; } const node = $getNearestNodeFromDOMNode(hit.node); if (node === null) { return null; } if ($isTextNode(node)) { return $getTextPointCaret(node, 'next', hit.offset); } if ($isElementNode(node)) { return $getChildCaretAtIndex(node, hit.offset, 'next'); } const parent = node.getParent(); if (parent === null) { return null; } return $getChildCaretAtIndex(parent, node.getIndexWithinParent() + 1, 'next'); } function $isDropCaretInsideSelection(dropCaret, selection) { const { anchor: start, focus: end } = $getCaretRangeInDirection($caretRangeFromSelection(selection), 'next'); return $comparePointCaretNext(start, dropCaret) < 0 && $comparePointCaretNext(dropCaret, end) < 0; } function $doDrop(event, editor, $insertDataTransfer) { const dataTransfer = event.dataTransfer; if (dataTransfer === null) { return false; } // Drags that didn't originate in a Lexical editor (no marker) fall through // to the browser's native drag-and-drop flow; its beforeinput // insertFromDrop is already handled correctly by Lexical's existing // beforeinput logic. const marker = readDragMarker(dataTransfer); if (marker === null) { return false; } const dropCaret = $resolveDropPointCaret(event); if (dropCaret === null) { return false; } // Split at the drop caret so we have a stable NodeCaret boundary that // survives text-content mutations in its siblings. const stableDropCaret = $splitAtPointCaretNext(dropCaret); if (stableDropCaret === null) { return false; } const isSameEditorDrag = marker.editorKey === editor.getKey(); const currentSelection = $getSelection(); if (isSameEditorDrag) { // Same-editor drag: the destination's $getSelection() is the still- // selected dragged range, so Lexical's beforeinput handler would skip // applyDOMRange and route the insert to the source's location instead // of the drop point. Remove the dragged range ourselves, then insert // at the stable drop caret. if (!$isRangeSelection(currentSelection) || currentSelection.isCollapsed()) { return false; } if ($isDropCaretInsideSelection(dropCaret, currentSelection)) { event.preventDefault(); return true; } currentSelection.removeText(); } // If the drop caret's origin was swept away by the source removal, abort — // this can happen on a same-editor drag whose range covered the entire // text node we tried to split at. if (!stableDropCaret.origin.isAttached()) { event.preventDefault(); return true; } const dropSelection = $setSelectionFromCaretRange($getCollapsedCaretRange(stableDropCaret)); $insertDataTransfer(dataTransfer, dropSelection, editor); if (!isSameEditorDrag) { // Cross-editor drag. The native drag-out deletion that the browser // would normally fire (beforeinput deleteByDrag on the source) isn't // reliable when the source is a nested contenteditable of the // destination (e.g. an image caption inside the main editor), so we // dispatch it ourselves at the source editor's root. The source // editor's own beforeinput handler runs the deletion through its own // REMOVE_TEXT_COMMAND and SKIP_SELECTION_FOCUS_TAG path. const rootElement = editor.getRootElement(); const doc = rootElement ? rootElement.ownerDocument : null; const sourceRoot = doc ? findEditorRootByKey(marker.editorKey, doc) : null; if (sourceRoot !== null) { sourceRoot.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'deleteByDrag' })); } } event.preventDefault(); return true; } /** * Drop handler for rich-text editors. Inserts the DataTransfer payload via * {@link $insertDataTransferForRichText} at the drop caret and, when the drag * originated from a Lexical editor (marked via * {@link $writeDragSourceToDataTransfer} on DRAGSTART), removes the source * range — producing cut-and-paste semantics whether the drop is in the same * editor or a different one on the same page. */ function $handleRichTextDrop(event, editor) { return $doDrop(event, editor, $insertDataTransferForRichText); } /** * Drop handler for plain-text editors. Same semantics as * {@link $handleRichTextDrop} but inserts via * {@link $insertDataTransferForPlainText}. */ function $handlePlainTextDrop(event, editor) { return $doDrop(event, editor, (dataTransfer, selection) => $insertDataTransferForPlainText(dataTransfer, selection)); } 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); $updateSelectionOnInsert(selection); } return; } function $updateSelectionOnInsert(selection) { if ($isRangeSelection(selection) && selection.isCollapsed()) { const anchor = selection.anchor; let nodeToInspect = null; const anchorCaret = $caretFromPoint(anchor, 'previous'); if (anchorCaret) { if ($isTextPointCaret(anchorCaret)) { nodeToInspect = anchorCaret.origin; } else { const range = $getCaretRange(anchorCaret, $getChildCaret($getRoot(), 'next').getFlipped()); for (const caret of range) { if ($isTextNode(caret.origin)) { nodeToInspect = caret.origin; break; } else if ($isElementNode(caret.origin) && !caret.origin.isInline()) { break; } } } } if (nodeToInspect && $isTextNode(nodeToInspect)) { const newFormat = nodeToInspect.getFormat(); const newStyle = nodeToInspect.getStyle(); if (selection.format !== newFormat || selection.style !== newStyle) { selection.format = newFormat; selection.style = newStyle; selection.dirty = true; } } } } 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 && $isTextNode(target)) { target = $sliceSelectedTextNodeContent(selection, target, 'clone'); } const children = $isElementNode(target) ? target.getChildren() : []; const serializedNode = exportNodeToJSON(target); if ($isTextNode(target) && target.getTextContentSize() === 0) { // 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! 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 = editorWindow.document; const domSelection = getDOMSelection(editorWindow); if (rootElement === null || domSelection === null) { return false; } const element = windowDocument.createElement('span'); element.style.position = 'fixed'; element.style.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) { editorWindow.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 = editorWindow.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); const selection = $getSelection(); if (!selection || selection.isCollapsed()) { return false; } if (!domSelection) { return false; } const anchorDOM = domSelection.anchorNode; const focusDOM = domSelection.focusNode; if (anchorDOM !== null && focusDOM !== null && !isSelectionWithinEditor(editor, anchorDOM, focusDOM)) { 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()) { return $getClipboardDataWithConfigFromSelection($getExportConfig(), selection); } /** * 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] of clipboardDataFunctions) { if (data[k] === undefined) { clipboardData.setData(k, ''); } } for (const k in data) { const v = data[k]; if (v !== undefined) { clipboardData.setData(k, v); } } } function $getExportConfig() { const editor = $getEditor(); const builder = LexicalBuilder.maybeFromEditor(editor); if (builder && builder.hasExtensionByName(GetClipboardDataExtension.name)) { return getExtensionDependencyFromEditor(editor, GetClipboardDataExtension).output; } return DEFAULT_EXPORT_MIME_TYPE; } const DEFAULT_EXPORT_MIME_TYPE = { 'application/x-lexical-editor': [(sel, next) => sel ? $getLexicalContent($getEditor(), sel) : next()], 'text/html': [(sel, next) => sel ? $getHtmlContent($getEditor(), sel) : next()], 'text/plain': [(sel, next) => sel ? sel.getTextContent() : next()] }; function $getClipboardDataWithConfigFromSelection($exportMimeType, selection) { const clipboardData = { 'text/plain': '' }; for (const [k, fns] of Object.entries($exportMimeType)) { const v = callExportMimeTypeFunctionStack(fns, selection); if (v !== null) { clipboardData[k] = v; } } return clipboardData; } function callExportMimeTypeFunctionStack(fns, selection) { const callAt = i => fns[i] ? fns[i](selection, callAt.bind(null, i - 1)) : null; return callAt(fns.length - 1); } const GetClipboardDataExtension = defineExtension({ build(editor, config, state) { return config.$exportMimeType; }, config: safeCast({ $exportMimeType: DEFAULT_EXPORT_MIME_TYPE }), mergeConfig(config, partial) { const merged = shallowMergeConfig(config, partial); if (partial.$exportMimeType) { const $exportMimeType = { ...config.$exportMimeType }; for (const [k, v] of Object.entries(partial.$exportMimeType)) { $exportMimeType[k] = [...$exportMimeType[k], ...v]; } merged.$exportMimeType = $exportMimeType; } return merged; }, name: '@lexical/clipboard/GetClipboardData' }); export { $generateJSONFromSelectedNodes, $generateNodesFromSerializedNodes, $getClipboardDataFromSelection, $getHtmlContent, $getLexicalContent, $handlePlainTextDrop, $handleRichTextDrop, $insertDataTransferForPlainText, $insertDataTransferForRichText, $insertGeneratedNodes, $writeDragSourceToDataTransfer, copyToClipboard, setLexicalClipboardDataTransfer };