543 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
		
		
			
		
	
	
			543 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
|  | /** | ||
|  |  * Copyright (c) Meta Platforms, Inc. and affiliates. | ||
|  |  * | ||
|  |  * This source code is licensed under the MIT license found in the | ||
|  |  * LICENSE file in the root directory of this source tree. | ||
|  |  * | ||
|  |  */ | ||
|  | 
 | ||
|  | import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; | ||
|  | import {$addNodeStyle, $sliceSelectedTextNodeContent} from '@lexical/selection'; | ||
|  | import {objectKlassEquals} from '@lexical/utils'; | ||
|  | import { | ||
|  |   $cloneWithProperties, | ||
|  |   $createTabNode, | ||
|  |   $getEditor, | ||
|  |   $getRoot, | ||
|  |   $getSelection, | ||
|  |   $isElementNode, | ||
|  |   $isRangeSelection, | ||
|  |   $isTextNode, | ||
|  |   $parseSerializedNode, | ||
|  |   BaseSelection, | ||
|  |   COMMAND_PRIORITY_CRITICAL, | ||
|  |   COPY_COMMAND, | ||
|  |   isSelectionWithinEditor, | ||
|  |   LexicalEditor, | ||
|  |   LexicalNode, | ||
|  |   SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, | ||
|  |   SerializedElementNode, | ||
|  |   SerializedTextNode, | ||
|  | } from 'lexical'; | ||
|  | import {CAN_USE_DOM} from 'lexical/shared/canUseDOM'; | ||
|  | import invariant from 'lexical/shared/invariant'; | ||
|  | 
 | ||
|  | const getDOMSelection = (targetWindow: Window | null): Selection | null => | ||
|  |   CAN_USE_DOM ? (targetWindow || window).getSelection() : null; | ||
|  | 
 | ||
|  | export interface LexicalClipboardData { | ||
|  |   'text/html'?: string | undefined; | ||
|  |   'application/x-lexical-editor'?: string | undefined; | ||
|  |   'text/plain': string; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * 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 | ||
|  |  */ | ||
|  | export function $getHtmlContent( | ||
|  |   editor: LexicalEditor, | ||
|  |   selection = $getSelection(), | ||
|  | ): string { | ||
|  |   if (selection == null) { | ||
|  |     invariant(false, '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 | ||
|  |  */ | ||
|  | export function $getLexicalContent( | ||
|  |   editor: LexicalEditor, | ||
|  |   selection = $getSelection(), | ||
|  | ): null | string { | ||
|  |   if (selection == null) { | ||
|  |     invariant(false, '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 | ||
|  |  */ | ||
|  | export function $insertDataTransferForPlainText( | ||
|  |   dataTransfer: DataTransfer, | ||
|  |   selection: BaseSelection, | ||
|  | ): void { | ||
|  |   const text = | ||
|  |     dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list'); | ||
|  | 
 | ||
|  |   if (text != null) { | ||
|  |     selection.insertRawText(text); | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Attempts to insert content of the mime-types application/x-lexical-editor, text/html, | ||
|  |  * text/plain, or text/uri-list (in descending order of priority) from the provided DataTransfer | ||
|  |  * object into the editor at the provided selection. | ||
|  |  * | ||
|  |  * @param dataTransfer an object conforming to the [DataTransfer interface] (https://html.spec.whatwg.org/multipage/dnd.html#the-datatransfer-interface)
 | ||
|  |  * @param selection the selection to use as the insertion point for the content in the DataTransfer object | ||
|  |  * @param editor the LexicalEditor the content is being inserted into. | ||
|  |  */ | ||
|  | export function $insertDataTransferForRichText( | ||
|  |   dataTransfer: DataTransfer, | ||
|  |   selection: BaseSelection, | ||
|  |   editor: LexicalEditor, | ||
|  | ): void { | ||
|  |   const lexicalString = dataTransfer.getData('application/x-lexical-editor'); | ||
|  | 
 | ||
|  |   if (lexicalString) { | ||
|  |     try { | ||
|  |       const payload = JSON.parse(lexicalString); | ||
|  |       if ( | ||
|  |         payload.namespace === editor._config.namespace && | ||
|  |         Array.isArray(payload.nodes) | ||
|  |       ) { | ||
|  |         const nodes = $generateNodesFromSerializedNodes(payload.nodes); | ||
|  |         return $insertGeneratedNodes(editor, nodes, selection); | ||
|  |       } | ||
|  |     } catch { | ||
|  |       // Fail silently.
 | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   const htmlString = dataTransfer.getData('text/html'); | ||
|  |   if (htmlString) { | ||
|  |     try { | ||
|  |       const parser = new DOMParser(); | ||
|  |       const dom = parser.parseFromString(htmlString, 'text/html'); | ||
|  |       const nodes = $generateNodesFromDOM(editor, dom); | ||
|  |       return $insertGeneratedNodes(editor, nodes, selection); | ||
|  |     } catch { | ||
|  |       // Fail silently.
 | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   // Multi-line plain text in rich text mode pasted as separate paragraphs
 | ||
|  |   // instead of single paragraph with linebreaks.
 | ||
|  |   // Webkit-specific: Supports read 'text/uri-list' in clipboard.
 | ||
|  |   const text = | ||
|  |     dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list'); | ||
|  |   if (text != null) { | ||
|  |     if ($isRangeSelection(selection)) { | ||
|  |       const parts = text.split(/(\r?\n|\t)/); | ||
|  |       if (parts[parts.length - 1] === '') { | ||
|  |         parts.pop(); | ||
|  |       } | ||
|  |       for (let i = 0; i < parts.length; i++) { | ||
|  |         const currentSelection = $getSelection(); | ||
|  |         if ($isRangeSelection(currentSelection)) { | ||
|  |           const part = parts[i]; | ||
|  |           if (part === '\n' || part === '\r\n') { | ||
|  |             currentSelection.insertParagraph(); | ||
|  |           } else if (part === '\t') { | ||
|  |             currentSelection.insertNodes([$createTabNode()]); | ||
|  |           } else { | ||
|  |             currentSelection.insertText(part); | ||
|  |           } | ||
|  |         } | ||
|  |       } | ||
|  |     } else { | ||
|  |       selection.insertRawText(text); | ||
|  |     } | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * 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. | ||
|  |  */ | ||
|  | export function $insertGeneratedNodes( | ||
|  |   editor: LexicalEditor, | ||
|  |   nodes: Array<LexicalNode>, | ||
|  |   selection: BaseSelection, | ||
|  | ): void { | ||
|  |   if ( | ||
|  |     !editor.dispatchCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, { | ||
|  |       nodes, | ||
|  |       selection, | ||
|  |     }) | ||
|  |   ) { | ||
|  |     selection.insertNodes(nodes); | ||
|  |   } | ||
|  |   return; | ||
|  | } | ||
|  | 
 | ||
|  | export interface BaseSerializedNode { | ||
|  |   children?: Array<BaseSerializedNode>; | ||
|  |   type: string; | ||
|  |   version: number; | ||
|  | } | ||
|  | 
 | ||
|  | function exportNodeToJSON<T extends LexicalNode>(node: T): BaseSerializedNode { | ||
|  |   const serializedNode = node.exportJSON(); | ||
|  |   const nodeClass = node.constructor; | ||
|  | 
 | ||
|  |   if (serializedNode.type !== nodeClass.getType()) { | ||
|  |     invariant( | ||
|  |       false, | ||
|  |       'LexicalNode: Node %s does not implement .exportJSON().', | ||
|  |       nodeClass.name, | ||
|  |     ); | ||
|  |   } | ||
|  | 
 | ||
|  |   if ($isElementNode(node)) { | ||
|  |     const serializedChildren = (serializedNode as SerializedElementNode) | ||
|  |       .children; | ||
|  |     if (!Array.isArray(serializedChildren)) { | ||
|  |       invariant( | ||
|  |         false, | ||
|  |         'LexicalNode: Node %s is an element but .exportJSON() does not have a children array.', | ||
|  |         nodeClass.name, | ||
|  |       ); | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   return serializedNode; | ||
|  | } | ||
|  | 
 | ||
|  | function $appendNodesToJSON( | ||
|  |   editor: LexicalEditor, | ||
|  |   selection: BaseSelection | null, | ||
|  |   currentNode: LexicalNode, | ||
|  |   targetArray: Array<BaseSerializedNode> = [], | ||
|  | ): boolean { | ||
|  |   let shouldInclude = | ||
|  |     selection !== null ? currentNode.isSelected(selection) : true; | ||
|  |   const shouldExclude = | ||
|  |     $isElementNode(currentNode) && currentNode.excludeFromCopy('html'); | ||
|  |   let target = currentNode; | ||
|  | 
 | ||
|  |   if (selection !== null) { | ||
|  |     let clone = $cloneWithProperties(currentNode); | ||
|  |     clone = | ||
|  |       $isTextNode(clone) && selection !== null | ||
|  |         ? $sliceSelectedTextNodeContent(selection, clone) | ||
|  |         : clone; | ||
|  |     target = clone; | ||
|  |   } | ||
|  |   const children = $isElementNode(target) ? target.getChildren() : []; | ||
|  | 
 | ||
|  |   const serializedNode = exportNodeToJSON(target); | ||
|  | 
 | ||
|  |   // TODO: TextNode calls getTextContent() (NOT node.__text) within its exportJSON method
 | ||
|  |   // which uses getLatest() to get the text from the original node with the same key.
 | ||
|  |   // This is a deeper issue with the word "clone" here, it's still a reference to the
 | ||
|  |   // same node as far as the LexicalEditor is concerned since it shares a key.
 | ||
|  |   // We need a way to create a clone of a Node in memory with its own key, but
 | ||
|  |   // until then this hack will work for the selected text extract use case.
 | ||
|  |   if ($isTextNode(target)) { | ||
|  |     const text = target.__text; | ||
|  |     // If an uncollapsed selection ends or starts at the end of a line of specialized,
 | ||
|  |     // TextNodes, such as code tokens, we will get a 'blank' TextNode here, i.e., one
 | ||
|  |     // with text of length 0. We don't want this, it makes a confusing mess. Reset!
 | ||
|  |     if (text.length > 0) { | ||
|  |       (serializedNode as SerializedTextNode).text = text; | ||
|  |     } else { | ||
|  |       shouldInclude = false; | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   for (let i = 0; i < children.length; i++) { | ||
|  |     const childNode = children[i]; | ||
|  |     const shouldIncludeChild = $appendNodesToJSON( | ||
|  |       editor, | ||
|  |       selection, | ||
|  |       childNode, | ||
|  |       serializedNode.children, | ||
|  |     ); | ||
|  | 
 | ||
|  |     if ( | ||
|  |       !shouldInclude && | ||
|  |       $isElementNode(currentNode) && | ||
|  |       shouldIncludeChild && | ||
|  |       currentNode.extractWithChild(childNode, selection, 'clone') | ||
|  |     ) { | ||
|  |       shouldInclude = true; | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   if (shouldInclude && !shouldExclude) { | ||
|  |     targetArray.push(serializedNode); | ||
|  |   } else if (Array.isArray(serializedNode.children)) { | ||
|  |     for (let i = 0; i < serializedNode.children.length; i++) { | ||
|  |       const serializedChildNode = serializedNode.children[i]; | ||
|  |       targetArray.push(serializedChildNode); | ||
|  |     } | ||
|  |   } | ||
|  | 
 | ||
|  |   return shouldInclude; | ||
|  | } | ||
|  | 
 | ||
|  | // TODO why $ function with Editor instance?
 | ||
|  | /** | ||
|  |  * Gets the Lexical JSON of the nodes inside the provided Selection. | ||
|  |  * | ||
|  |  * @param editor LexicalEditor to get the JSON content from. | ||
|  |  * @param selection Selection to get the JSON content from. | ||
|  |  * @returns an object with the editor namespace and a list of serializable nodes as JavaScript objects. | ||
|  |  */ | ||
|  | export function $generateJSONFromSelectedNodes< | ||
|  |   SerializedNode extends BaseSerializedNode, | ||
|  | >( | ||
|  |   editor: LexicalEditor, | ||
|  |   selection: BaseSelection | null, | ||
|  | ): { | ||
|  |   namespace: string; | ||
|  |   nodes: Array<SerializedNode>; | ||
|  | } { | ||
|  |   const nodes: Array<SerializedNode> = []; | ||
|  |   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 BaseSeralizedNode 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. | ||
|  |  */ | ||
|  | export function $generateNodesFromSerializedNodes( | ||
|  |   serializedNodes: Array<BaseSerializedNode>, | ||
|  | ): Array<LexicalNode> { | ||
|  |   const nodes = []; | ||
|  |   for (let i = 0; i < serializedNodes.length; i++) { | ||
|  |     const serializedNode = serializedNodes[i]; | ||
|  |     const node = $parseSerializedNode(serializedNode); | ||
|  |     if ($isTextNode(node)) { | ||
|  |       $addNodeStyle(node); | ||
|  |     } | ||
|  |     nodes.push(node); | ||
|  |   } | ||
|  |   return nodes; | ||
|  | } | ||
|  | 
 | ||
|  | const EVENT_LATENCY = 50; | ||
|  | let clipboardEventTimeout: null | number = 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 | ||
|  |  */ | ||
|  | export async function copyToClipboard( | ||
|  |   editor: LexicalEditor, | ||
|  |   event: null | ClipboardEvent, | ||
|  |   data?: LexicalClipboardData, | ||
|  | ): Promise<boolean> { | ||
|  |   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 windowDocument = | ||
|  |     editor._window == null ? window.document : editor._window.document; | ||
|  |   const domSelection = getDOMSelection(editor._window); | ||
|  |   if (rootElement === null || domSelection === null) { | ||
|  |     return false; | ||
|  |   } | ||
|  |   const element = windowDocument.createElement('span'); | ||
|  |   element.style.cssText = 'position: fixed; top: -1000px;'; | ||
|  |   element.append(windowDocument.createTextNode('#')); | ||
|  |   rootElement.append(element); | ||
|  |   const range = new Range(); | ||
|  |   range.setStart(element, 0); | ||
|  |   range.setEnd(element, 1); | ||
|  |   domSelection.removeAllRanges(); | ||
|  |   domSelection.addRange(range); | ||
|  |   return new Promise((resolve, reject) => { | ||
|  |     const removeListener = editor.registerCommand( | ||
|  |       COPY_COMMAND, | ||
|  |       (secondEvent) => { | ||
|  |         if (objectKlassEquals(secondEvent, ClipboardEvent)) { | ||
|  |           removeListener(); | ||
|  |           if (clipboardEventTimeout !== null) { | ||
|  |             window.clearTimeout(clipboardEventTimeout); | ||
|  |             clipboardEventTimeout = null; | ||
|  |           } | ||
|  |           resolve( | ||
|  |             $copyToClipboardEvent(editor, secondEvent as ClipboardEvent, data), | ||
|  |           ); | ||
|  |         } | ||
|  |         // Block the entire copy flow while we wait for the next ClipboardEvent
 | ||
|  |         return true; | ||
|  |       }, | ||
|  |       COMMAND_PRIORITY_CRITICAL, | ||
|  |     ); | ||
|  |     // If the above hack execCommand hack works, this timeout code should never fire. Otherwise,
 | ||
|  |     // the listener will be quickly freed so that the user can reuse it again
 | ||
|  |     clipboardEventTimeout = window.setTimeout(() => { | ||
|  |       removeListener(); | ||
|  |       clipboardEventTimeout = null; | ||
|  |       resolve(false); | ||
|  |     }, EVENT_LATENCY); | ||
|  |     windowDocument.execCommand('copy'); | ||
|  |     element.remove(); | ||
|  |   }); | ||
|  | } | ||
|  | 
 | ||
|  | // TODO shouldn't pass editor (pass namespace directly)
 | ||
|  | function $copyToClipboardEvent( | ||
|  |   editor: LexicalEditor, | ||
|  |   event: ClipboardEvent, | ||
|  |   data?: LexicalClipboardData, | ||
|  | ): boolean { | ||
|  |   if (data === undefined) { | ||
|  |     const domSelection = getDOMSelection(editor._window); | ||
|  |     if (!domSelection) { | ||
|  |       return false; | ||
|  |     } | ||
|  |     const anchorDOM = domSelection.anchorNode; | ||
|  |     const focusDOM = domSelection.focusNode; | ||
|  |     if ( | ||
|  |       anchorDOM !== null && | ||
|  |       focusDOM !== null && | ||
|  |       !isSelectionWithinEditor(editor, anchorDOM, focusDOM) | ||
|  |     ) { | ||
|  |       return false; | ||
|  |     } | ||
|  |     const selection = $getSelection(); | ||
|  |     if (selection === null) { | ||
|  |       return false; | ||
|  |     } | ||
|  |     data = $getClipboardDataFromSelection(selection); | ||
|  |   } | ||
|  |   event.preventDefault(); | ||
|  |   const clipboardData = event.clipboardData; | ||
|  |   if (clipboardData === null) { | ||
|  |     return false; | ||
|  |   } | ||
|  |   setLexicalClipboardDataTransfer(clipboardData, data); | ||
|  |   return true; | ||
|  | } | ||
|  | 
 | ||
|  | const clipboardDataFunctions = [ | ||
|  |   ['text/html', $getHtmlContent], | ||
|  |   ['application/x-lexical-editor', $getLexicalContent], | ||
|  | ] as const; | ||
|  | 
 | ||
|  | /** | ||
|  |  * 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 | ||
|  |  */ | ||
|  | export function $getClipboardDataFromSelection( | ||
|  |   selection: BaseSelection | null = $getSelection(), | ||
|  | ): LexicalClipboardData { | ||
|  |   const clipboardData: LexicalClipboardData = { | ||
|  |     'text/plain': selection ? selection.getTextContent() : '', | ||
|  |   }; | ||
|  |   if (selection) { | ||
|  |     const editor = $getEditor(); | ||
|  |     for (const [mimeType, $editorFn] of clipboardDataFunctions) { | ||
|  |       const v = $editorFn(editor, selection); | ||
|  |       if (v !== null) { | ||
|  |         clipboardData[mimeType] = v; | ||
|  |       } | ||
|  |     } | ||
|  |   } | ||
|  |   return clipboardData; | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * Call setData on the given clipboardData for each MIME type present | ||
|  |  * in the given data (from {@link $getClipboardDataFromSelection}) | ||
|  |  * | ||
|  |  * @param clipboardData the event.clipboardData to populate from data | ||
|  |  * @param data The lexical data | ||
|  |  */ | ||
|  | export function setLexicalClipboardDataTransfer( | ||
|  |   clipboardData: DataTransfer, | ||
|  |   data: LexicalClipboardData, | ||
|  | ) { | ||
|  |   for (const k in data) { | ||
|  |     const v = data[k as keyof LexicalClipboardData]; | ||
|  |     if (v !== undefined) { | ||
|  |       clipboardData.setData(k, v); | ||
|  |     } | ||
|  |   } | ||
|  | } |