2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
10 import {$addNodeStyle, $sliceSelectedTextNodeContent} from '@lexical/selection';
11 import {objectKlassEquals} from '@lexical/utils';
23 COMMAND_PRIORITY_CRITICAL,
25 isSelectionWithinEditor,
28 SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
29 SerializedElementNode,
32 import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
33 import invariant from 'lexical/shared/invariant';
35 const getDOMSelection = (targetWindow: Window | null): Selection | null =>
36 CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
38 export interface LexicalClipboardData {
39 'text/html'?: string | undefined;
40 'application/x-lexical-editor'?: string | undefined;
45 * Returns the *currently selected* Lexical content as an HTML string, relying on the
46 * logic defined in the exportDOM methods on the LexicalNode classes. Note that
47 * this will not return the HTML content of the entire editor (unless all the content is included
48 * in the current selection).
50 * @param editor - LexicalEditor instance to get HTML content from
51 * @param selection - The selection to use (default is $getSelection())
52 * @returns a string of HTML content
54 export function $getHtmlContent(
55 editor: LexicalEditor,
56 selection = $getSelection(),
58 if (selection == null) {
59 invariant(false, 'Expected valid LexicalSelection');
62 // If we haven't selected anything
64 ($isRangeSelection(selection) && selection.isCollapsed()) ||
65 selection.getNodes().length === 0
70 return $generateHtmlFromNodes(editor, selection);
74 * Returns the *currently selected* Lexical content as a JSON string, relying on the
75 * logic defined in the exportJSON methods on the LexicalNode classes. Note that
76 * this will not return the JSON content of the entire editor (unless all the content is included
77 * in the current selection).
79 * @param editor - LexicalEditor instance to get the JSON content from
80 * @param selection - The selection to use (default is $getSelection())
83 export function $getLexicalContent(
84 editor: LexicalEditor,
85 selection = $getSelection(),
87 if (selection == null) {
88 invariant(false, 'Expected valid LexicalSelection');
91 // If we haven't selected anything
93 ($isRangeSelection(selection) && selection.isCollapsed()) ||
94 selection.getNodes().length === 0
99 return JSON.stringify($generateJSONFromSelectedNodes(editor, selection));
103 * Attempts to insert content of the mime-types text/plain or text/uri-list from
104 * the provided DataTransfer object into the editor at the provided selection.
105 * text/uri-list is only used if text/plain is not also provided.
107 * @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
108 * @param selection the selection to use as the insertion point for the content in the DataTransfer object
110 export function $insertDataTransferForPlainText(
111 dataTransfer: DataTransfer,
112 selection: BaseSelection,
115 dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list');
118 selection.insertRawText(text);
123 * Attempts to insert content of the mime-types application/x-lexical-editor, text/html,
124 * text/plain, or text/uri-list (in descending order of priority) from the provided DataTransfer
125 * object into the editor at the provided selection.
127 * @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
128 * @param selection the selection to use as the insertion point for the content in the DataTransfer object
129 * @param editor the LexicalEditor the content is being inserted into.
131 export function $insertDataTransferForRichText(
132 dataTransfer: DataTransfer,
133 selection: BaseSelection,
134 editor: LexicalEditor,
136 const lexicalString = dataTransfer.getData('application/x-lexical-editor');
140 const payload = JSON.parse(lexicalString);
142 payload.namespace === editor._config.namespace &&
143 Array.isArray(payload.nodes)
145 const nodes = $generateNodesFromSerializedNodes(payload.nodes);
146 return $insertGeneratedNodes(editor, nodes, selection);
153 const htmlString = dataTransfer.getData('text/html');
156 const parser = new DOMParser();
157 const dom = parser.parseFromString(htmlString, 'text/html');
158 const nodes = $generateNodesFromDOM(editor, dom);
159 return $insertGeneratedNodes(editor, nodes, selection);
165 // Multi-line plain text in rich text mode pasted as separate paragraphs
166 // instead of single paragraph with linebreaks.
167 // Webkit-specific: Supports read 'text/uri-list' in clipboard.
169 dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list');
171 if ($isRangeSelection(selection)) {
172 const parts = text.split(/(\r?\n|\t)/);
173 if (parts[parts.length - 1] === '') {
176 for (let i = 0; i < parts.length; i++) {
177 const currentSelection = $getSelection();
178 if ($isRangeSelection(currentSelection)) {
179 const part = parts[i];
180 if (part === '\n' || part === '\r\n') {
181 currentSelection.insertParagraph();
182 } else if (part === '\t') {
183 currentSelection.insertNodes([$createTabNode()]);
185 currentSelection.insertText(part);
190 selection.insertRawText(text);
196 * Inserts Lexical nodes into the editor using different strategies depending on
197 * some simple selection-based heuristics. If you're looking for a generic way to
198 * to insert nodes into the editor at a specific selection point, you probably want
199 * {@link lexical.$insertNodes}
201 * @param editor LexicalEditor instance to insert the nodes into.
202 * @param nodes The nodes to insert.
203 * @param selection The selection to insert the nodes into.
205 export function $insertGeneratedNodes(
206 editor: LexicalEditor,
207 nodes: Array<LexicalNode>,
208 selection: BaseSelection,
211 !editor.dispatchCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, {
216 selection.insertNodes(nodes);
221 export interface BaseSerializedNode {
222 children?: Array<BaseSerializedNode>;
227 function exportNodeToJSON<T extends LexicalNode>(node: T): BaseSerializedNode {
228 const serializedNode = node.exportJSON();
229 const nodeClass = node.constructor;
231 if (serializedNode.type !== nodeClass.getType()) {
234 'LexicalNode: Node %s does not implement .exportJSON().',
239 if ($isElementNode(node)) {
240 const serializedChildren = (serializedNode as SerializedElementNode)
242 if (!Array.isArray(serializedChildren)) {
245 'LexicalNode: Node %s is an element but .exportJSON() does not have a children array.',
251 return serializedNode;
254 function $appendNodesToJSON(
255 editor: LexicalEditor,
256 selection: BaseSelection | null,
257 currentNode: LexicalNode,
258 targetArray: Array<BaseSerializedNode> = [],
261 selection !== null ? currentNode.isSelected(selection) : true;
262 const shouldExclude =
263 $isElementNode(currentNode) && currentNode.excludeFromCopy('html');
264 let target = currentNode;
266 if (selection !== null) {
267 let clone = $cloneWithProperties(currentNode);
269 $isTextNode(clone) && selection !== null
270 ? $sliceSelectedTextNodeContent(selection, clone)
274 const children = $isElementNode(target) ? target.getChildren() : [];
276 const serializedNode = exportNodeToJSON(target);
278 // TODO: TextNode calls getTextContent() (NOT node.__text) within its exportJSON method
279 // which uses getLatest() to get the text from the original node with the same key.
280 // This is a deeper issue with the word "clone" here, it's still a reference to the
281 // same node as far as the LexicalEditor is concerned since it shares a key.
282 // We need a way to create a clone of a Node in memory with its own key, but
283 // until then this hack will work for the selected text extract use case.
284 if ($isTextNode(target)) {
285 const text = target.__text;
286 // If an uncollapsed selection ends or starts at the end of a line of specialized,
287 // TextNodes, such as code tokens, we will get a 'blank' TextNode here, i.e., one
288 // with text of length 0. We don't want this, it makes a confusing mess. Reset!
289 if (text.length > 0) {
290 (serializedNode as SerializedTextNode).text = text;
292 shouldInclude = false;
296 for (let i = 0; i < children.length; i++) {
297 const childNode = children[i];
298 const shouldIncludeChild = $appendNodesToJSON(
302 serializedNode.children,
307 $isElementNode(currentNode) &&
308 shouldIncludeChild &&
309 currentNode.extractWithChild(childNode, selection, 'clone')
311 shouldInclude = true;
315 if (shouldInclude && !shouldExclude) {
316 targetArray.push(serializedNode);
317 } else if (Array.isArray(serializedNode.children)) {
318 for (let i = 0; i < serializedNode.children.length; i++) {
319 const serializedChildNode = serializedNode.children[i];
320 targetArray.push(serializedChildNode);
324 return shouldInclude;
327 // TODO why $ function with Editor instance?
329 * Gets the Lexical JSON of the nodes inside the provided Selection.
331 * @param editor LexicalEditor to get the JSON content from.
332 * @param selection Selection to get the JSON content from.
333 * @returns an object with the editor namespace and a list of serializable nodes as JavaScript objects.
335 export function $generateJSONFromSelectedNodes<
336 SerializedNode extends BaseSerializedNode,
338 editor: LexicalEditor,
339 selection: BaseSelection | null,
342 nodes: Array<SerializedNode>;
344 const nodes: Array<SerializedNode> = [];
345 const root = $getRoot();
346 const topLevelChildren = root.getChildren();
347 for (let i = 0; i < topLevelChildren.length; i++) {
348 const topLevelNode = topLevelChildren[i];
349 $appendNodesToJSON(editor, selection, topLevelNode, nodes);
352 namespace: editor._config.namespace,
358 * This method takes an array of objects conforming to the BaseSeralizedNode interface and returns
359 * an Array containing instances of the corresponding LexicalNode classes registered on the editor.
360 * Normally, you'd get an Array of BaseSerialized nodes from {@link $generateJSONFromSelectedNodes}
362 * @param serializedNodes an Array of objects conforming to the BaseSerializedNode interface.
363 * @returns an Array of Lexical Node objects.
365 export function $generateNodesFromSerializedNodes(
366 serializedNodes: Array<BaseSerializedNode>,
367 ): Array<LexicalNode> {
369 for (let i = 0; i < serializedNodes.length; i++) {
370 const serializedNode = serializedNodes[i];
371 const node = $parseSerializedNode(serializedNode);
372 if ($isTextNode(node)) {
380 const EVENT_LATENCY = 50;
381 let clipboardEventTimeout: null | number = null;
383 // TODO custom selection
384 // TODO potentially have a node customizable version for plain text
386 * Copies the content of the current selection to the clipboard in
387 * text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
390 * @param editor the LexicalEditor instance to copy content from
391 * @param event the native browser ClipboardEvent to add the content to.
394 export async function copyToClipboard(
395 editor: LexicalEditor,
396 event: null | ClipboardEvent,
397 data?: LexicalClipboardData,
398 ): Promise<boolean> {
399 if (clipboardEventTimeout !== null) {
400 // Prevent weird race conditions that can happen when this function is run multiple times
401 // synchronously. In the future, we can do better, we can cancel/override the previously running job.
404 if (event !== null) {
405 return new Promise((resolve, reject) => {
406 editor.update(() => {
407 resolve($copyToClipboardEvent(editor, event, data));
412 const rootElement = editor.getRootElement();
413 const windowDocument =
414 editor._window == null ? window.document : editor._window.document;
415 const domSelection = getDOMSelection(editor._window);
416 if (rootElement === null || domSelection === null) {
419 const element = windowDocument.createElement('span');
420 element.style.cssText = 'position: fixed; top: -1000px;';
421 element.append(windowDocument.createTextNode('#'));
422 rootElement.append(element);
423 const range = new Range();
424 range.setStart(element, 0);
425 range.setEnd(element, 1);
426 domSelection.removeAllRanges();
427 domSelection.addRange(range);
428 return new Promise((resolve, reject) => {
429 const removeListener = editor.registerCommand(
432 if (objectKlassEquals(secondEvent, ClipboardEvent)) {
434 if (clipboardEventTimeout !== null) {
435 window.clearTimeout(clipboardEventTimeout);
436 clipboardEventTimeout = null;
439 $copyToClipboardEvent(editor, secondEvent as ClipboardEvent, data),
442 // Block the entire copy flow while we wait for the next ClipboardEvent
445 COMMAND_PRIORITY_CRITICAL,
447 // If the above hack execCommand hack works, this timeout code should never fire. Otherwise,
448 // the listener will be quickly freed so that the user can reuse it again
449 clipboardEventTimeout = window.setTimeout(() => {
451 clipboardEventTimeout = null;
454 windowDocument.execCommand('copy');
459 // TODO shouldn't pass editor (pass namespace directly)
460 function $copyToClipboardEvent(
461 editor: LexicalEditor,
462 event: ClipboardEvent,
463 data?: LexicalClipboardData,
465 if (data === undefined) {
466 const domSelection = getDOMSelection(editor._window);
470 const anchorDOM = domSelection.anchorNode;
471 const focusDOM = domSelection.focusNode;
473 anchorDOM !== null &&
475 !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
479 const selection = $getSelection();
480 if (selection === null) {
483 data = $getClipboardDataFromSelection(selection);
485 event.preventDefault();
486 const clipboardData = event.clipboardData;
487 if (clipboardData === null) {
490 setLexicalClipboardDataTransfer(clipboardData, data);
494 const clipboardDataFunctions = [
495 ['text/html', $getHtmlContent],
496 ['application/x-lexical-editor', $getLexicalContent],
500 * Serialize the content of the current selection to strings in
501 * text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
502 * formats (as available).
504 * @param selection the selection to serialize (defaults to $getSelection())
505 * @returns LexicalClipboardData
507 export function $getClipboardDataFromSelection(
508 selection: BaseSelection | null = $getSelection(),
509 ): LexicalClipboardData {
510 const clipboardData: LexicalClipboardData = {
511 'text/plain': selection ? selection.getTextContent() : '',
514 const editor = $getEditor();
515 for (const [mimeType, $editorFn] of clipboardDataFunctions) {
516 const v = $editorFn(editor, selection);
518 clipboardData[mimeType] = v;
522 return clipboardData;
526 * Call setData on the given clipboardData for each MIME type present
527 * in the given data (from {@link $getClipboardDataFromSelection})
529 * @param clipboardData the event.clipboardData to populate from data
530 * @param data The lexical data
532 export function setLexicalClipboardDataTransfer(
533 clipboardData: DataTransfer,
534 data: LexicalClipboardData,
536 for (const k in data) {
537 const v = data[k as keyof LexicalClipboardData];
538 if (v !== undefined) {
539 clipboardData.setData(k, v);