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);
 | |
|     }
 | |
|   }
 | |
| }
 |