]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/clipboard/clipboard.ts
Lexical: Imported core lexical libs
[bookstack] / resources / js / wysiwyg / lexical / clipboard / clipboard.ts
1 /**
2  * Copyright (c) Meta Platforms, Inc. and affiliates.
3  *
4  * This source code is licensed under the MIT license found in the
5  * LICENSE file in the root directory of this source tree.
6  *
7  */
8
9 import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
10 import {$addNodeStyle, $sliceSelectedTextNodeContent} from '@lexical/selection';
11 import {objectKlassEquals} from '@lexical/utils';
12 import {
13   $cloneWithProperties,
14   $createTabNode,
15   $getEditor,
16   $getRoot,
17   $getSelection,
18   $isElementNode,
19   $isRangeSelection,
20   $isTextNode,
21   $parseSerializedNode,
22   BaseSelection,
23   COMMAND_PRIORITY_CRITICAL,
24   COPY_COMMAND,
25   isSelectionWithinEditor,
26   LexicalEditor,
27   LexicalNode,
28   SELECTION_INSERT_CLIPBOARD_NODES_COMMAND,
29   SerializedElementNode,
30   SerializedTextNode,
31 } from 'lexical';
32 import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
33 import invariant from 'lexical/shared/invariant';
34
35 const getDOMSelection = (targetWindow: Window | null): Selection | null =>
36   CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
37
38 export interface LexicalClipboardData {
39   'text/html'?: string | undefined;
40   'application/x-lexical-editor'?: string | undefined;
41   'text/plain': string;
42 }
43
44 /**
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).
49  *
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
53  */
54 export function $getHtmlContent(
55   editor: LexicalEditor,
56   selection = $getSelection(),
57 ): string {
58   if (selection == null) {
59     invariant(false, 'Expected valid LexicalSelection');
60   }
61
62   // If we haven't selected anything
63   if (
64     ($isRangeSelection(selection) && selection.isCollapsed()) ||
65     selection.getNodes().length === 0
66   ) {
67     return '';
68   }
69
70   return $generateHtmlFromNodes(editor, selection);
71 }
72
73 /**
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).
78  *
79  * @param editor  - LexicalEditor instance to get the JSON content from
80  * @param selection - The selection to use (default is $getSelection())
81  * @returns
82  */
83 export function $getLexicalContent(
84   editor: LexicalEditor,
85   selection = $getSelection(),
86 ): null | string {
87   if (selection == null) {
88     invariant(false, 'Expected valid LexicalSelection');
89   }
90
91   // If we haven't selected anything
92   if (
93     ($isRangeSelection(selection) && selection.isCollapsed()) ||
94     selection.getNodes().length === 0
95   ) {
96     return null;
97   }
98
99   return JSON.stringify($generateJSONFromSelectedNodes(editor, selection));
100 }
101
102 /**
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.
106  *
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
109  */
110 export function $insertDataTransferForPlainText(
111   dataTransfer: DataTransfer,
112   selection: BaseSelection,
113 ): void {
114   const text =
115     dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list');
116
117   if (text != null) {
118     selection.insertRawText(text);
119   }
120 }
121
122 /**
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.
126  *
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.
130  */
131 export function $insertDataTransferForRichText(
132   dataTransfer: DataTransfer,
133   selection: BaseSelection,
134   editor: LexicalEditor,
135 ): void {
136   const lexicalString = dataTransfer.getData('application/x-lexical-editor');
137
138   if (lexicalString) {
139     try {
140       const payload = JSON.parse(lexicalString);
141       if (
142         payload.namespace === editor._config.namespace &&
143         Array.isArray(payload.nodes)
144       ) {
145         const nodes = $generateNodesFromSerializedNodes(payload.nodes);
146         return $insertGeneratedNodes(editor, nodes, selection);
147       }
148     } catch {
149       // Fail silently.
150     }
151   }
152
153   const htmlString = dataTransfer.getData('text/html');
154   if (htmlString) {
155     try {
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);
160     } catch {
161       // Fail silently.
162     }
163   }
164
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.
168   const text =
169     dataTransfer.getData('text/plain') || dataTransfer.getData('text/uri-list');
170   if (text != null) {
171     if ($isRangeSelection(selection)) {
172       const parts = text.split(/(\r?\n|\t)/);
173       if (parts[parts.length - 1] === '') {
174         parts.pop();
175       }
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()]);
184           } else {
185             currentSelection.insertText(part);
186           }
187         }
188       }
189     } else {
190       selection.insertRawText(text);
191     }
192   }
193 }
194
195 /**
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}
200  *
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.
204  */
205 export function $insertGeneratedNodes(
206   editor: LexicalEditor,
207   nodes: Array<LexicalNode>,
208   selection: BaseSelection,
209 ): void {
210   if (
211     !editor.dispatchCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, {
212       nodes,
213       selection,
214     })
215   ) {
216     selection.insertNodes(nodes);
217   }
218   return;
219 }
220
221 export interface BaseSerializedNode {
222   children?: Array<BaseSerializedNode>;
223   type: string;
224   version: number;
225 }
226
227 function exportNodeToJSON<T extends LexicalNode>(node: T): BaseSerializedNode {
228   const serializedNode = node.exportJSON();
229   const nodeClass = node.constructor;
230
231   if (serializedNode.type !== nodeClass.getType()) {
232     invariant(
233       false,
234       'LexicalNode: Node %s does not implement .exportJSON().',
235       nodeClass.name,
236     );
237   }
238
239   if ($isElementNode(node)) {
240     const serializedChildren = (serializedNode as SerializedElementNode)
241       .children;
242     if (!Array.isArray(serializedChildren)) {
243       invariant(
244         false,
245         'LexicalNode: Node %s is an element but .exportJSON() does not have a children array.',
246         nodeClass.name,
247       );
248     }
249   }
250
251   return serializedNode;
252 }
253
254 function $appendNodesToJSON(
255   editor: LexicalEditor,
256   selection: BaseSelection | null,
257   currentNode: LexicalNode,
258   targetArray: Array<BaseSerializedNode> = [],
259 ): boolean {
260   let shouldInclude =
261     selection !== null ? currentNode.isSelected(selection) : true;
262   const shouldExclude =
263     $isElementNode(currentNode) && currentNode.excludeFromCopy('html');
264   let target = currentNode;
265
266   if (selection !== null) {
267     let clone = $cloneWithProperties(currentNode);
268     clone =
269       $isTextNode(clone) && selection !== null
270         ? $sliceSelectedTextNodeContent(selection, clone)
271         : clone;
272     target = clone;
273   }
274   const children = $isElementNode(target) ? target.getChildren() : [];
275
276   const serializedNode = exportNodeToJSON(target);
277
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;
291     } else {
292       shouldInclude = false;
293     }
294   }
295
296   for (let i = 0; i < children.length; i++) {
297     const childNode = children[i];
298     const shouldIncludeChild = $appendNodesToJSON(
299       editor,
300       selection,
301       childNode,
302       serializedNode.children,
303     );
304
305     if (
306       !shouldInclude &&
307       $isElementNode(currentNode) &&
308       shouldIncludeChild &&
309       currentNode.extractWithChild(childNode, selection, 'clone')
310     ) {
311       shouldInclude = true;
312     }
313   }
314
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);
321     }
322   }
323
324   return shouldInclude;
325 }
326
327 // TODO why $ function with Editor instance?
328 /**
329  * Gets the Lexical JSON of the nodes inside the provided Selection.
330  *
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.
334  */
335 export function $generateJSONFromSelectedNodes<
336   SerializedNode extends BaseSerializedNode,
337 >(
338   editor: LexicalEditor,
339   selection: BaseSelection | null,
340 ): {
341   namespace: string;
342   nodes: Array<SerializedNode>;
343 } {
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);
350   }
351   return {
352     namespace: editor._config.namespace,
353     nodes,
354   };
355 }
356
357 /**
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}
361  *
362  * @param serializedNodes an Array of objects conforming to the BaseSerializedNode interface.
363  * @returns an Array of Lexical Node objects.
364  */
365 export function $generateNodesFromSerializedNodes(
366   serializedNodes: Array<BaseSerializedNode>,
367 ): Array<LexicalNode> {
368   const nodes = [];
369   for (let i = 0; i < serializedNodes.length; i++) {
370     const serializedNode = serializedNodes[i];
371     const node = $parseSerializedNode(serializedNode);
372     if ($isTextNode(node)) {
373       $addNodeStyle(node);
374     }
375     nodes.push(node);
376   }
377   return nodes;
378 }
379
380 const EVENT_LATENCY = 50;
381 let clipboardEventTimeout: null | number = null;
382
383 // TODO custom selection
384 // TODO potentially have a node customizable version for plain text
385 /**
386  * Copies the content of the current selection to the clipboard in
387  * text/plain, text/html, and application/x-lexical-editor (Lexical JSON)
388  * formats.
389  *
390  * @param editor the LexicalEditor instance to copy content from
391  * @param event the native browser ClipboardEvent to add the content to.
392  * @returns
393  */
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.
402     return false;
403   }
404   if (event !== null) {
405     return new Promise((resolve, reject) => {
406       editor.update(() => {
407         resolve($copyToClipboardEvent(editor, event, data));
408       });
409     });
410   }
411
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) {
417     return false;
418   }
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(
430       COPY_COMMAND,
431       (secondEvent) => {
432         if (objectKlassEquals(secondEvent, ClipboardEvent)) {
433           removeListener();
434           if (clipboardEventTimeout !== null) {
435             window.clearTimeout(clipboardEventTimeout);
436             clipboardEventTimeout = null;
437           }
438           resolve(
439             $copyToClipboardEvent(editor, secondEvent as ClipboardEvent, data),
440           );
441         }
442         // Block the entire copy flow while we wait for the next ClipboardEvent
443         return true;
444       },
445       COMMAND_PRIORITY_CRITICAL,
446     );
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(() => {
450       removeListener();
451       clipboardEventTimeout = null;
452       resolve(false);
453     }, EVENT_LATENCY);
454     windowDocument.execCommand('copy');
455     element.remove();
456   });
457 }
458
459 // TODO shouldn't pass editor (pass namespace directly)
460 function $copyToClipboardEvent(
461   editor: LexicalEditor,
462   event: ClipboardEvent,
463   data?: LexicalClipboardData,
464 ): boolean {
465   if (data === undefined) {
466     const domSelection = getDOMSelection(editor._window);
467     if (!domSelection) {
468       return false;
469     }
470     const anchorDOM = domSelection.anchorNode;
471     const focusDOM = domSelection.focusNode;
472     if (
473       anchorDOM !== null &&
474       focusDOM !== null &&
475       !isSelectionWithinEditor(editor, anchorDOM, focusDOM)
476     ) {
477       return false;
478     }
479     const selection = $getSelection();
480     if (selection === null) {
481       return false;
482     }
483     data = $getClipboardDataFromSelection(selection);
484   }
485   event.preventDefault();
486   const clipboardData = event.clipboardData;
487   if (clipboardData === null) {
488     return false;
489   }
490   setLexicalClipboardDataTransfer(clipboardData, data);
491   return true;
492 }
493
494 const clipboardDataFunctions = [
495   ['text/html', $getHtmlContent],
496   ['application/x-lexical-editor', $getLexicalContent],
497 ] as const;
498
499 /**
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).
503  *
504  * @param selection the selection to serialize (defaults to $getSelection())
505  * @returns LexicalClipboardData
506  */
507 export function $getClipboardDataFromSelection(
508   selection: BaseSelection | null = $getSelection(),
509 ): LexicalClipboardData {
510   const clipboardData: LexicalClipboardData = {
511     'text/plain': selection ? selection.getTextContent() : '',
512   };
513   if (selection) {
514     const editor = $getEditor();
515     for (const [mimeType, $editorFn] of clipboardDataFunctions) {
516       const v = $editorFn(editor, selection);
517       if (v !== null) {
518         clipboardData[mimeType] = v;
519       }
520     }
521   }
522   return clipboardData;
523 }
524
525 /**
526  * Call setData on the given clipboardData for each MIME type present
527  * in the given data (from {@link $getClipboardDataFromSelection})
528  *
529  * @param clipboardData the event.clipboardData to populate from data
530  * @param data The lexical data
531  */
532 export function setLexicalClipboardDataTransfer(
533   clipboardData: DataTransfer,
534   data: LexicalClipboardData,
535 ) {
536   for (const k in data) {
537     const v = data[k as keyof LexicalClipboardData];
538     if (v !== undefined) {
539       clipboardData.setData(k, v);
540     }
541   }
542 }