UNPKG

@lexical/clipboard

Version:

This package provides the copy/paste functionality for Lexical.

1,164 lines (1,115 loc) 43.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 { configExtension, $getPeerDependency, getPeerDependencyFromEditor } from '@lexical/extension'; import { $generateNodesFromDOMViaExtension, contextValue, ImportSource, ImportSourceDataTransfer, $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html'; import { $sliceSelectedTextNodeContent } from '@lexical/selection'; import { objectKlassEquals } from '@lexical/utils'; import { defineExtension, $getEditor, shallowMergeConfig, safeCast, $isRangeSelection, tokenizeRawText, $getSelection, $createTabNode, $getRoot, $parseSerializedNode, SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, getDOMSelection, COPY_COMMAND, COMMAND_PRIORITY_CRITICAL, $isElementNode, $isTextNode, $splitAtPointCaretNext, $setSelectionFromCaretRange, $getCollapsedCaretRange, $caretFromPoint, $isTextPointCaret, $getCaretRange, $getChildCaret, isSelectionWithinEditor, $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. * */ /** @internal */ 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; } } /** * 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. * */ /** * A middleware function in a per-MIME-type clipboard-import stack. Mirrors * the shape of {@link ExportMimeTypeFunction} on the export side. * * - `data` is the non-empty string returned by `DataTransfer.getData(mime)` * for this MIME type. * - `selection` is the current editor selection at the insertion point. * - `$next` defers to the next-lower handler in the stack (i.e. the handler * that was registered earlier). Returns `true` if that handler claimed * the data; `false` if no handler accepted it. * - `dataTransfer` is the full {@link DataTransfer} the paste/drop came * from, so a handler can inspect companion MIME types or attached * files in addition to the slot it was invoked for (e.g. peek at * `'application/x-vscode-source'` while handling `'text/html'`). When * threading through the new pipeline, pass this into * `$generateNodesFromDOMViaExtension(dom, { * context: [contextValue(ImportSourceDataTransfer, dataTransfer)], * })` so rules and preprocessors can read it via * `ctx.get(ImportSourceDataTransfer)`. * * The function should return `true` if it consumed the data (the caller * stops trying further handlers for this MIME type and does not move on to * the next MIME type). Return `$next()` to delegate. Return `false` if the * function decided not to handle the data after inspecting it (e.g. the * JSON namespace didn't match) so a lower-priority handler — or the next * MIME type — gets a chance. * * @experimental */ /** * A mapping from MIME type to a stack of {@link ImportMimeTypeFunction}. * * Each entry is an ordered array; the function at the highest index runs * first and may call `next()` to fall through to the function below it. * The default config provides one handler each for * `'application/x-lexical-editor'`, `'text/html'`, and `'text/plain'` that * matches the legacy {@link $insertDataTransferForRichText} behavior. * * When {@link ClipboardImportExtension} merges a partial config, new * functions are appended to the existing array for each MIME type, so * later-registered handlers run before earlier ones (including the * defaults) and may delegate to them via `next()`. * * @experimental */ /** * Per-MIME-type ordering weights. Lower numbers run first. * * Composable across extensions: each extension contributes weights for * its MIME types without needing to coordinate. A partial config that * sets `{'application/vnd.myapp+json': 5}` slots its type between the * built-in `application/x-lexical-editor` (0) and `text/html` (10) — no * need to enumerate the full ordering. mergeConfig spreads pairs (later * keys override earlier ones for the same MIME type, so an extension * can also re-rank a built-in by repeating its key with a new weight). * * Iteration: every MIME type that has a handler stack and is present in * the dataTransfer (regardless of whether it has an explicit weight) is * tried; MIME types with no explicit weight sort to the end, behind all * weighted ones, in lexical order. * * @experimental */ /** * Configuration for {@link ClipboardImportExtension}. * * @experimental */ /** * Default per-MIME-type weights reproducing the legacy * `$insertDataTransferForRichText` ordering: * * `application/x-lexical-editor` (0) → `text/html` (10) → * `text/plain` (20) → `text/uri-list` (30). * * Gaps between weights let third-party MIME types slot in (e.g. weight * 5 to run between lexical and html). Apps can also override built-in * weights to demote them. * * @experimental */ const DEFAULT_IMPORT_MIME_TYPE_PRIORITY = { 'application/x-lexical-editor': 0, 'text/html': 10, 'text/plain': 20, 'text/uri-list': 30 }; function trustHTML(html) { if (window.trustedTypes && window.trustedTypes.createPolicy) { const policy = window.trustedTypes.createPolicy('lexical', { createHTML: input => input }); return policy.createHTML(html); } return html; } /** * Default handler for `'application/x-lexical-editor'`: parse the JSON, * verify the namespace, and insert the serialized nodes. */ const $defaultLexicalEditorImporter = (data, selection, $next) => { try { const editor = $getEditor(); const payload = JSON.parse(data); if (payload && payload.namespace === editor._config.namespace && Array.isArray(payload.nodes)) { const nodes = $generateNodesFromSerializedNodes(payload.nodes); $insertGeneratedNodes(editor, nodes, selection); return true; } } catch (error) { console.error(error); } return $next(); }; /** * Default handler for `'text/html'`: parse the HTML and run the legacy * `$generateNodesFromDOM`. Override (or stack a higher-priority handler * on top) to route HTML pastes through {@link DOMImportExtension} or any * custom pipeline. See {@link $generateNodesFromDOMViaExtension} for the * built-in `DOMImportExtension` adapter. */ const $defaultHtmlImporter = (data, selection, $next) => { try { const editor = $getEditor(); const parser = new DOMParser(); const dom = parser.parseFromString(trustHTML(data), 'text/html'); const nodes = $generateNodesFromDOM(editor, dom); $insertGeneratedNodes(editor, nodes, selection); return true; } catch (error) { console.error(error); return $next(); } }; /** * Default handler for `'text/plain'`. On a RangeSelection, drive the * insertion off {@link tokenizeRawText} so each `\n` becomes a real * paragraph break via `insertParagraph` (preserving current text * format / style on the surrounding `insertText` calls). For other * selection types, defer to the selection's own `insertRawText`. */ const $defaultPlainTextImporter = (data, selection) => { if (!$isRangeSelection(selection)) { selection.insertRawText(data); return true; } const withCurrentRange = fn => { const cur = $getSelection(); if ($isRangeSelection(cur)) { fn(cur); } }; tokenizeRawText(data, { linebreak: () => withCurrentRange(cur => cur.insertParagraph()), tab: () => withCurrentRange(cur => cur.insertNodes([$createTabNode()])), text: part => withCurrentRange(cur => cur.insertText(part)) }); return true; }; /** * The default per-MIME-type handler stacks reproducing the legacy * {@link $insertDataTransferForRichText} behavior exactly. Stacked * extensions append on top of these. * * @experimental */ const DEFAULT_IMPORT_MIME_TYPE = { 'application/x-lexical-editor': [$defaultLexicalEditorImporter], 'text/html': [$defaultHtmlImporter], 'text/plain': [$defaultPlainTextImporter], // `text/uri-list` is a Webkit-only payload that drops behave-like text; // reuse the plain-text handler so a URL drop on a rich-text editor // inserts as plain text rather than being ignored. 'text/uri-list': [$defaultPlainTextImporter] }; /** * Output of {@link ClipboardImportExtension}: the merged configuration * plus a self-contained {@link $insertDataTransfer} function that owns * the entire paste-side iteration over the priority list. Apps look this * up via peer-dependency and call it directly; {@link * $insertDataTransferForRichText} delegates to it. * * @experimental */ function $callImportMimeTypeFunctionStack(fns, data, selection, dataTransfer) { if (!fns) { return false; } const callAt = i => fns[i] ? fns[i](data, selection, callAt.bind(null, i - 1), dataTransfer) : false; return callAt(fns.length - 1); } /** * Sort the MIME types that have a registered handler stack by their * configured priority weight (ascending). Types with no explicit weight * sort after all weighted types, in lexical order, so unknown types * remain reachable but never preempt a known one. */ function orderedMimeTypes(config) { const mimes = Object.keys(config.$importMimeType).filter(k => config.$importMimeType[k] !== undefined); return mimes.sort((a, b) => { const wa = config.priority[a]; const wb = config.priority[b]; if (wa === undefined && wb === undefined) { return a < b ? -1 : a > b ? 1 : 0; } if (wa === undefined) { return 1; } if (wb === undefined) { return -1; } return wa - wb; }); } function $runImport(config, dataTransfer, selection) { // Read once for the iOS Safari heuristic that skips text/html when it // matches text/plain verbatim (iOS Safari autocorrect produces a // text/html payload identical to the plain text). const plainString = dataTransfer.getData('text/plain'); for (const mime of orderedMimeTypes(config)) { const data = dataTransfer.getData(mime); if (!data) { continue; } if (mime === 'text/html' && data === plainString) { continue; } if ($callImportMimeTypeFunctionStack(config.$importMimeType[mime], data, selection, dataTransfer)) { return true; } } return false; } const DEFAULT_OUTPUT = { $importMimeType: DEFAULT_IMPORT_MIME_TYPE, $insertDataTransfer: (dataTransfer, selection) => $runImport({ $importMimeType: DEFAULT_IMPORT_MIME_TYPE, priority: DEFAULT_IMPORT_MIME_TYPE_PRIORITY }, dataTransfer, selection), priority: DEFAULT_IMPORT_MIME_TYPE_PRIORITY }; /** * @internal * * Look up the {@link ClipboardImportOutput} on the active editor. Returns * a static default-backed output when no {@link ClipboardImportExtension} * is configured, so callers can always invoke `output.$insertDataTransfer` * regardless of whether the editor opted in. */ function $getImportOutput() { const dep = $getPeerDependency(ClipboardImportExtension.name); return dep ? dep.output : DEFAULT_OUTPUT; } /** * @experimental * * Mirror of {@link GetClipboardDataExtension} for the import direction. * Holds a per-MIME-type stack of {@link ImportMimeTypeFunction}s. * * @example * Route `text/html` pastes through {@link DOMImportExtension}, leaving the * defaults for other MIME types untouched: * ```ts * import {configExtension, defineExtension, $getEditor} from 'lexical'; * import { * ClipboardImportExtension, * $insertGeneratedNodes, * } from '@lexical/clipboard'; * import { * contextValue, * DOMImportExtension, * ImportSource, * ImportSourceDataTransfer, * $generateNodesFromDOMViaExtension, * } from '@lexical/html'; * * defineExtension({ * name: 'app', * dependencies: [ * DOMImportExtension, * configExtension(ClipboardImportExtension, { * $importMimeType: { * 'text/html': [ * (html, selection, _$next, dataTransfer) => { * const parser = new DOMParser(); * const dom = parser.parseFromString(html, 'text/html'); * const nodes = $generateNodesFromDOMViaExtension(dom, { * context: [ * contextValue(ImportSource, 'paste'), * contextValue(ImportSourceDataTransfer, dataTransfer), * ], * }); * $insertGeneratedNodes($getEditor(), nodes, selection); * return true; * }, * ], * }, * }), * ], * }); * ``` */ const ClipboardImportExtension = defineExtension({ build: (_editor, config) => ({ $importMimeType: config.$importMimeType, $insertDataTransfer: (dataTransfer, selection) => $runImport(config, dataTransfer, selection), priority: config.priority }), config: safeCast({ $importMimeType: DEFAULT_IMPORT_MIME_TYPE, priority: DEFAULT_IMPORT_MIME_TYPE_PRIORITY }), mergeConfig(config, partial) { const merged = shallowMergeConfig(config, partial); if (partial.$importMimeType) { const $importMimeType = { ...config.$importMimeType }; for (const [k, v] of Object.entries(partial.$importMimeType)) { if (v) { const prev = $importMimeType[k]; $importMimeType[k] = prev ? [...prev, ...v] : v; } } merged.$importMimeType = $importMimeType; } if (partial.priority) { // Spread-merge weights. Per-MIME-type keys in `partial` override // any matching key in `config` (so an extension can rerank a // built-in MIME type) and new keys are simply added (so multiple // extensions can each contribute their own MIME types without // having to coordinate). merged.priority = { ...config.priority, ...partial.priority }; } return merged; }, name: '@lexical/clipboard/Import' }); /** * @experimental * * Drop-in extension that routes `text/html` clipboard pastes and drops * through the {@link DOMImportExtension} pipeline (rules, schemas, * preprocessors, overlays) instead of the legacy * {@link $generateNodesFromDOM}. Add to your extension dependencies along * with the per-package import extensions you want active * ({@link CoreImportExtension}, {@link RichTextImportExtension}, etc.). * * The original {@link DataTransfer} and `'paste'` source kind are forwarded * into the import context so rules and preprocessors can read them via * `ctx.get(ImportSourceDataTransfer)` / `ctx.get(ImportSource)`. * * Equivalent to stacking this `text/html` handler manually via * `configExtension(ClipboardImportExtension, {...})`. * * @example * ```ts * import {defineExtension} from 'lexical'; * import {ClipboardDOMImportExtension} from '@lexical/clipboard'; * import {CoreImportExtension, RichTextImportExtension} from '@lexical/html'; * * defineExtension({ * name: 'app', * dependencies: [ * CoreImportExtension, * RichTextImportExtension, * ClipboardDOMImportExtension, * ], * }); * ``` */ const ClipboardDOMImportExtension = defineExtension({ dependencies: [configExtension(ClipboardImportExtension, { $importMimeType: { 'text/html': [(html, selection, _$next, dataTransfer) => { const parser = new DOMParser(); const dom = parser.parseFromString(trustHTML(html), 'text/html'); const nodes = $generateNodesFromDOMViaExtension(dom, { context: [contextValue(ImportSource, 'paste'), contextValue(ImportSourceDataTransfer, dataTransfer)] }); $insertGeneratedNodes($getEditor(), nodes, selection); return true; }] } })], name: '@lexical/clipboard/DOMImport' }); /** * 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); } } /** * Insert the contents of `dataTransfer` at `selection` using the rich-text * import pipeline (`application/x-lexical-editor` → `text/html` → `text/plain` * → `text/uri-list`, in descending order of priority). * * @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 unused; retained for backwards compatibility. Safe to * omit on new call sites. */ function $insertDataTransferForRichText(dataTransfer, selection, _editor) { $getImportOutput().$insertDataTransfer(dataTransfer, selection); } 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)); } /** * 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 (const serializedNode of serializedNodes) { nodes.push($parseSerializedNode(serializedNode)); } 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); } } } /** * A function that produces the serialized representation of a selection for * a single MIME type. Functions are arranged in a stack per MIME type (see * {@link ExportMimeTypeConfig}); the function at the top of the stack is * invoked first and may call `next()` to delegate to the previous function * in the stack (typically the default Lexical serializer). * * Returning `null` from the top-most function omits that MIME type from the * resulting {@link LexicalClipboardData}. * * @param selection - The selection to serialize, or `null` if there is none. * @param next - Calls the previous handler in the stack and returns its * result, or `null` if there is no previous handler. * @returns The serialized string for this MIME type, or `null` to omit it. */ /** * Configuration for {@link GetClipboardDataExtension}. */ /** * A mapping from MIME type to a stack of {@link ExportMimeTypeFunction}. * * Each entry is an ordered array; the function at the highest index runs * first and may call `next()` to fall through to the function below it. * The default config provides a single fallback handler for * `'application/x-lexical-editor'`, `'text/html'`, and `'text/plain'`. * * When {@link GetClipboardDataExtension} merges a partial config, new * functions are appended to the existing array for each MIME type, so * later-registered handlers run before earlier ones (including the * defaults) and may delegate to them via `next()`. To register a brand new * MIME type, supply a key not present in the default config; arbitrary * string keys are accepted in addition to the keys of * {@link LexicalClipboardData}. */ function $getExportConfig(editor = $getEditor()) { const dep = getPeerDependencyFromEditor(editor, GetClipboardDataExtension.name); return dep ? dep.output : 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)) { if (fns) { 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); } /** * Serialize the given selection for a single MIME type using the active * editor's configured {@link ExportMimeTypeConfig}. The configured stack is * read from {@link GetClipboardDataExtension} via the editor's peer * dependency lookup; if the extension was not built into the editor, the * default stack is used. * * Useful when only one MIME representation is needed rather than the full * {@link LexicalClipboardData} produced by * {@link $getClipboardDataFromSelection}. * * Must be called from within an editor update or read. * * @param mimeType - The MIME type to serialize, e.g. `'text/html'`, * `'application/x-lexical-editor'`, `'text/plain'`, or any custom key * registered in the {@link ExportMimeTypeConfig}. * @param selection - The selection to serialize (defaults to * `$getSelection()`). * @returns The serialized string for the requested MIME type, or `null` if * no handler is registered for it or every handler returned `null`. */ function $exportMimeTypeFromSelection(mimeType, selection = $getSelection()) { return callExportMimeTypeFunctionStack($getExportConfig()[mimeType] || [], selection); } /** * Lexical extension that controls how the current selection is serialized * into clipboard MIME types when copying or dragging out of the editor. * * The extension's config holds an {@link ExportMimeTypeConfig} — a stack of * {@link ExportMimeTypeFunction} per MIME type. Out of the box it provides * fallback serializers for `'application/x-lexical-editor'`, `'text/html'`, * and `'text/plain'` that defer to {@link $getLexicalContent}, * {@link $getHtmlContent}, and `selection.getTextContent()` respectively. * * Apps can layer additional handlers on top to customize an existing * payload (delegating to the default via `next()`) or to register an * entirely new MIME type. Functions provided through `mergeConfig` are * appended to the existing stack for each MIME type, so a newly registered * handler runs first and may fall through to the previously registered * handlers via its `next` argument. * * The extension's `output` is the resolved {@link ExportMimeTypeConfig}, * which {@link $getClipboardDataFromSelection} and * {@link $exportMimeTypeFromSelection} read via the editor's peer * dependency lookup. * * @example * ```ts * import {configExtension, defineExtension} from '@lexical/extension'; * import {GetClipboardDataExtension} from '@lexical/clipboard'; * * const MyClipboardExtension = defineExtension({ * name: 'my-app/clipboard', * dependencies: [ * configExtension(GetClipboardDataExtension, { * $exportMimeType: { * // Wrap the default HTML output with an app-specific marker. * 'text/html': [ * (selection, next) => { * const html = next(); * return html ? wrapWithMyAppMarker(html) : html; * }, * ], * // Add a brand-new MIME type. * 'application/vnd.myapp+json': [ * (selection) => * selection ? exportMyAppFormat(selection) : null, * ], * }, * }), * ], * }); * ``` */ 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)) { if (v) { const prev = $exportMimeType[k]; $exportMimeType[k] = prev ? [...prev, ...v] : v; } } merged.$exportMimeType = $exportMimeType; } return merged; }, name: '@lexical/clipboard/GetClipboardData' }); export { $exportMimeTypeFromSelection, $generateJSONFromSelectedNodes, $generateNodesFromSerializedNodes, $getClipboardDataFromSelection, $getHtmlContent, $getLexicalContent, $handlePlainTextDrop, $handleRichTextDrop, $insertDataTransferForPlainText, $insertDataTransferForRichText, $insertGeneratedNodes, $writeDragSourceToDataTransfer, ClipboardDOMImportExtension, ClipboardImportExtension, DEFAULT_IMPORT_MIME_TYPE, DEFAULT_IMPORT_MIME_TYPE_PRIORITY, GetClipboardDataExtension, caretFromPoint, copyToClipboard, setLexicalClipboardDataTransfer };