@lexical/clipboard
Version:
This package provides the copy/paste functionality for Lexical.
1,164 lines (1,115 loc) • 43.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 { 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 };