]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/utils/index.ts
Lexical: Imported core lexical libs
[bookstack] / resources / js / wysiwyg / lexical / utils / index.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 {
10   $cloneWithProperties,
11   $createParagraphNode,
12   $getPreviousSelection,
13   $getRoot,
14   $getSelection,
15   $isElementNode,
16   $isRangeSelection,
17   $isRootOrShadowRoot,
18   $isTextNode,
19   $setSelection,
20   $splitNode,
21   EditorState,
22   ElementNode,
23   Klass,
24   LexicalEditor,
25   LexicalNode,
26 } from 'lexical';
27 // This underscore postfixing is used as a hotfix so we do not
28 // export shared types from this module #5918
29 import {CAN_USE_DOM as CAN_USE_DOM_} from 'lexical/shared/canUseDOM';
30 import {
31   CAN_USE_BEFORE_INPUT as CAN_USE_BEFORE_INPUT_,
32   IS_ANDROID as IS_ANDROID_,
33   IS_ANDROID_CHROME as IS_ANDROID_CHROME_,
34   IS_APPLE as IS_APPLE_,
35   IS_APPLE_WEBKIT as IS_APPLE_WEBKIT_,
36   IS_CHROME as IS_CHROME_,
37   IS_FIREFOX as IS_FIREFOX_,
38   IS_IOS as IS_IOS_,
39   IS_SAFARI as IS_SAFARI_,
40 } from 'lexical/shared/environment';
41 import invariant from 'lexical/shared/invariant';
42 import normalizeClassNames from 'lexical/shared/normalizeClassNames';
43
44 export {default as markSelection} from './markSelection';
45 export {default as mergeRegister} from './mergeRegister';
46 export {default as positionNodeOnRange} from './positionNodeOnRange';
47 export {
48   $splitNode,
49   isBlockDomNode,
50   isHTMLAnchorElement,
51   isHTMLElement,
52   isInlineDomNode,
53 } from 'lexical';
54 // Hotfix to export these with inlined types #5918
55 export const CAN_USE_BEFORE_INPUT: boolean = CAN_USE_BEFORE_INPUT_;
56 export const CAN_USE_DOM: boolean = CAN_USE_DOM_;
57 export const IS_ANDROID: boolean = IS_ANDROID_;
58 export const IS_ANDROID_CHROME: boolean = IS_ANDROID_CHROME_;
59 export const IS_APPLE: boolean = IS_APPLE_;
60 export const IS_APPLE_WEBKIT: boolean = IS_APPLE_WEBKIT_;
61 export const IS_CHROME: boolean = IS_CHROME_;
62 export const IS_FIREFOX: boolean = IS_FIREFOX_;
63 export const IS_IOS: boolean = IS_IOS_;
64 export const IS_SAFARI: boolean = IS_SAFARI_;
65
66 export type DFSNode = Readonly<{
67   depth: number;
68   node: LexicalNode;
69 }>;
70
71 /**
72  * Takes an HTML element and adds the classNames passed within an array,
73  * ignoring any non-string types. A space can be used to add multiple classes
74  * eg. addClassNamesToElement(element, ['element-inner active', true, null])
75  * will add both 'element-inner' and 'active' as classes to that element.
76  * @param element - The element in which the classes are added
77  * @param classNames - An array defining the class names to add to the element
78  */
79 export function addClassNamesToElement(
80   element: HTMLElement,
81   ...classNames: Array<typeof undefined | boolean | null | string>
82 ): void {
83   const classesToAdd = normalizeClassNames(...classNames);
84   if (classesToAdd.length > 0) {
85     element.classList.add(...classesToAdd);
86   }
87 }
88
89 /**
90  * Takes an HTML element and removes the classNames passed within an array,
91  * ignoring any non-string types. A space can be used to remove multiple classes
92  * eg. removeClassNamesFromElement(element, ['active small', true, null])
93  * will remove both the 'active' and 'small' classes from that element.
94  * @param element - The element in which the classes are removed
95  * @param classNames - An array defining the class names to remove from the element
96  */
97 export function removeClassNamesFromElement(
98   element: HTMLElement,
99   ...classNames: Array<typeof undefined | boolean | null | string>
100 ): void {
101   const classesToRemove = normalizeClassNames(...classNames);
102   if (classesToRemove.length > 0) {
103     element.classList.remove(...classesToRemove);
104   }
105 }
106
107 /**
108  * Returns true if the file type matches the types passed within the acceptableMimeTypes array, false otherwise.
109  * The types passed must be strings and are CASE-SENSITIVE.
110  * eg. if file is of type 'text' and acceptableMimeTypes = ['TEXT', 'IMAGE'] the function will return false.
111  * @param file - The file you want to type check.
112  * @param acceptableMimeTypes - An array of strings of types which the file is checked against.
113  * @returns true if the file is an acceptable mime type, false otherwise.
114  */
115 export function isMimeType(
116   file: File,
117   acceptableMimeTypes: Array<string>,
118 ): boolean {
119   for (const acceptableType of acceptableMimeTypes) {
120     if (file.type.startsWith(acceptableType)) {
121       return true;
122     }
123   }
124   return false;
125 }
126
127 /**
128  * Lexical File Reader with:
129  *  1. MIME type support
130  *  2. batched results (HistoryPlugin compatibility)
131  *  3. Order aware (respects the order when multiple Files are passed)
132  *
133  * const filesResult = await mediaFileReader(files, ['image/']);
134  * filesResult.forEach(file => editor.dispatchCommand('INSERT_IMAGE', \\{
135  *   src: file.result,
136  * \\}));
137  */
138 export function mediaFileReader(
139   files: Array<File>,
140   acceptableMimeTypes: Array<string>,
141 ): Promise<Array<{file: File; result: string}>> {
142   const filesIterator = files[Symbol.iterator]();
143   return new Promise((resolve, reject) => {
144     const processed: Array<{file: File; result: string}> = [];
145     const handleNextFile = () => {
146       const {done, value: file} = filesIterator.next();
147       if (done) {
148         return resolve(processed);
149       }
150       const fileReader = new FileReader();
151       fileReader.addEventListener('error', reject);
152       fileReader.addEventListener('load', () => {
153         const result = fileReader.result;
154         if (typeof result === 'string') {
155           processed.push({file, result});
156         }
157         handleNextFile();
158       });
159       if (isMimeType(file, acceptableMimeTypes)) {
160         fileReader.readAsDataURL(file);
161       } else {
162         handleNextFile();
163       }
164     };
165     handleNextFile();
166   });
167 }
168
169 /**
170  * "Depth-First Search" starts at the root/top node of a tree and goes as far as it can down a branch end
171  * before backtracking and finding a new path. Consider solving a maze by hugging either wall, moving down a
172  * branch until you hit a dead-end (leaf) and backtracking to find the nearest branching path and repeat.
173  * It will then return all the nodes found in the search in an array of objects.
174  * @param startingNode - The node to start the search, if ommitted, it will start at the root node.
175  * @param endingNode - The node to end the search, if ommitted, it will find all descendants of the startingNode.
176  * @returns An array of objects of all the nodes found by the search, including their depth into the tree.
177  * \\{depth: number, node: LexicalNode\\} It will always return at least 1 node (the ending node) so long as it exists
178  */
179 export function $dfs(
180   startingNode?: LexicalNode,
181   endingNode?: LexicalNode,
182 ): Array<DFSNode> {
183   const nodes = [];
184   const start = (startingNode || $getRoot()).getLatest();
185   const end =
186     endingNode ||
187     ($isElementNode(start) ? start.getLastDescendant() || start : start);
188   let node: LexicalNode | null = start;
189   let depth = $getDepth(node);
190
191   while (node !== null && !node.is(end)) {
192     nodes.push({depth, node});
193
194     if ($isElementNode(node) && node.getChildrenSize() > 0) {
195       node = node.getFirstChild();
196       depth++;
197     } else {
198       // Find immediate sibling or nearest parent sibling
199       let sibling = null;
200
201       while (sibling === null && node !== null) {
202         sibling = node.getNextSibling();
203
204         if (sibling === null) {
205           node = node.getParent();
206           depth--;
207         } else {
208           node = sibling;
209         }
210       }
211     }
212   }
213
214   if (node !== null && node.is(end)) {
215     nodes.push({depth, node});
216   }
217
218   return nodes;
219 }
220
221 function $getDepth(node: LexicalNode): number {
222   let innerNode: LexicalNode | null = node;
223   let depth = 0;
224
225   while ((innerNode = innerNode.getParent()) !== null) {
226     depth++;
227   }
228
229   return depth;
230 }
231
232 /**
233  * Performs a right-to-left preorder tree traversal.
234  * From the starting node it goes to the rightmost child, than backtracks to paret and finds new rightmost path.
235  * It will return the next node in traversal sequence after the startingNode.
236  * The traversal is similar to $dfs functions above, but the nodes are visited right-to-left, not left-to-right.
237  * @param startingNode - The node to start the search.
238  * @returns The next node in pre-order right to left traversal sequence or `null`, if the node does not exist
239  */
240 export function $getNextRightPreorderNode(
241   startingNode: LexicalNode,
242 ): LexicalNode | null {
243   let node: LexicalNode | null = startingNode;
244
245   if ($isElementNode(node) && node.getChildrenSize() > 0) {
246     node = node.getLastChild();
247   } else {
248     let sibling = null;
249
250     while (sibling === null && node !== null) {
251       sibling = node.getPreviousSibling();
252
253       if (sibling === null) {
254         node = node.getParent();
255       } else {
256         node = sibling;
257       }
258     }
259   }
260   return node;
261 }
262
263 /**
264  * Takes a node and traverses up its ancestors (toward the root node)
265  * in order to find a specific type of node.
266  * @param node - the node to begin searching.
267  * @param klass - an instance of the type of node to look for.
268  * @returns the node of type klass that was passed, or null if none exist.
269  */
270 export function $getNearestNodeOfType<T extends ElementNode>(
271   node: LexicalNode,
272   klass: Klass<T>,
273 ): T | null {
274   let parent: ElementNode | LexicalNode | null = node;
275
276   while (parent != null) {
277     if (parent instanceof klass) {
278       return parent as T;
279     }
280
281     parent = parent.getParent();
282   }
283
284   return null;
285 }
286
287 /**
288  * Returns the element node of the nearest ancestor, otherwise throws an error.
289  * @param startNode - The starting node of the search
290  * @returns The ancestor node found
291  */
292 export function $getNearestBlockElementAncestorOrThrow(
293   startNode: LexicalNode,
294 ): ElementNode {
295   const blockNode = $findMatchingParent(
296     startNode,
297     (node) => $isElementNode(node) && !node.isInline(),
298   );
299   if (!$isElementNode(blockNode)) {
300     invariant(
301       false,
302       'Expected node %s to have closest block element node.',
303       startNode.__key,
304     );
305   }
306   return blockNode;
307 }
308
309 export type DOMNodeToLexicalConversion = (element: Node) => LexicalNode;
310
311 export type DOMNodeToLexicalConversionMap = Record<
312   string,
313   DOMNodeToLexicalConversion
314 >;
315
316 /**
317  * Starts with a node and moves up the tree (toward the root node) to find a matching node based on
318  * the search parameters of the findFn. (Consider JavaScripts' .find() function where a testing function must be
319  * passed as an argument. eg. if( (node) => node.__type === 'div') ) return true; otherwise return false
320  * @param startingNode - The node where the search starts.
321  * @param findFn - A testing function that returns true if the current node satisfies the testing parameters.
322  * @returns A parent node that matches the findFn parameters, or null if one wasn't found.
323  */
324 export const $findMatchingParent: {
325   <T extends LexicalNode>(
326     startingNode: LexicalNode,
327     findFn: (node: LexicalNode) => node is T,
328   ): T | null;
329   (
330     startingNode: LexicalNode,
331     findFn: (node: LexicalNode) => boolean,
332   ): LexicalNode | null;
333 } = (
334   startingNode: LexicalNode,
335   findFn: (node: LexicalNode) => boolean,
336 ): LexicalNode | null => {
337   let curr: ElementNode | LexicalNode | null = startingNode;
338
339   while (curr !== $getRoot() && curr != null) {
340     if (findFn(curr)) {
341       return curr;
342     }
343
344     curr = curr.getParent();
345   }
346
347   return null;
348 };
349
350 /**
351  * Attempts to resolve nested element nodes of the same type into a single node of that type.
352  * It is generally used for marks/commenting
353  * @param editor - The lexical editor
354  * @param targetNode - The target for the nested element to be extracted from.
355  * @param cloneNode - See {@link $createMarkNode}
356  * @param handleOverlap - Handles any overlap between the node to extract and the targetNode
357  * @returns The lexical editor
358  */
359 export function registerNestedElementResolver<N extends ElementNode>(
360   editor: LexicalEditor,
361   targetNode: Klass<N>,
362   cloneNode: (from: N) => N,
363   handleOverlap: (from: N, to: N) => void,
364 ): () => void {
365   const $isTargetNode = (node: LexicalNode | null | undefined): node is N => {
366     return node instanceof targetNode;
367   };
368
369   const $findMatch = (node: N): {child: ElementNode; parent: N} | null => {
370     // First validate we don't have any children that are of the target,
371     // as we need to handle them first.
372     const children = node.getChildren();
373
374     for (let i = 0; i < children.length; i++) {
375       const child = children[i];
376
377       if ($isTargetNode(child)) {
378         return null;
379       }
380     }
381
382     let parentNode: N | null = node;
383     let childNode = node;
384
385     while (parentNode !== null) {
386       childNode = parentNode;
387       parentNode = parentNode.getParent();
388
389       if ($isTargetNode(parentNode)) {
390         return {child: childNode, parent: parentNode};
391       }
392     }
393
394     return null;
395   };
396
397   const $elementNodeTransform = (node: N) => {
398     const match = $findMatch(node);
399
400     if (match !== null) {
401       const {child, parent} = match;
402
403       // Simple path, we can move child out and siblings into a new parent.
404
405       if (child.is(node)) {
406         handleOverlap(parent, node);
407         const nextSiblings = child.getNextSiblings();
408         const nextSiblingsLength = nextSiblings.length;
409         parent.insertAfter(child);
410
411         if (nextSiblingsLength !== 0) {
412           const newParent = cloneNode(parent);
413           child.insertAfter(newParent);
414
415           for (let i = 0; i < nextSiblingsLength; i++) {
416             newParent.append(nextSiblings[i]);
417           }
418         }
419
420         if (!parent.canBeEmpty() && parent.getChildrenSize() === 0) {
421           parent.remove();
422         }
423       } else {
424         // Complex path, we have a deep node that isn't a child of the
425         // target parent.
426         // TODO: implement this functionality
427       }
428     }
429   };
430
431   return editor.registerNodeTransform(targetNode, $elementNodeTransform);
432 }
433
434 /**
435  * Clones the editor and marks it as dirty to be reconciled. If there was a selection,
436  * it would be set back to its previous state, or null otherwise.
437  * @param editor - The lexical editor
438  * @param editorState - The editor's state
439  */
440 export function $restoreEditorState(
441   editor: LexicalEditor,
442   editorState: EditorState,
443 ): void {
444   const FULL_RECONCILE = 2;
445   const nodeMap = new Map();
446   const activeEditorState = editor._pendingEditorState;
447
448   for (const [key, node] of editorState._nodeMap) {
449     nodeMap.set(key, $cloneWithProperties(node));
450   }
451
452   if (activeEditorState) {
453     activeEditorState._nodeMap = nodeMap;
454   }
455
456   editor._dirtyType = FULL_RECONCILE;
457   const selection = editorState._selection;
458   $setSelection(selection === null ? null : selection.clone());
459 }
460
461 /**
462  * If the selected insertion area is the root/shadow root node (see {@link lexical!$isRootOrShadowRoot}),
463  * the node will be appended there, otherwise, it will be inserted before the insertion area.
464  * If there is no selection where the node is to be inserted, it will be appended after any current nodes
465  * within the tree, as a child of the root node. A paragraph node will then be added after the inserted node and selected.
466  * @param node - The node to be inserted
467  * @returns The node after its insertion
468  */
469 export function $insertNodeToNearestRoot<T extends LexicalNode>(node: T): T {
470   const selection = $getSelection() || $getPreviousSelection();
471
472   if ($isRangeSelection(selection)) {
473     const {focus} = selection;
474     const focusNode = focus.getNode();
475     const focusOffset = focus.offset;
476
477     if ($isRootOrShadowRoot(focusNode)) {
478       const focusChild = focusNode.getChildAtIndex(focusOffset);
479       if (focusChild == null) {
480         focusNode.append(node);
481       } else {
482         focusChild.insertBefore(node);
483       }
484       node.selectNext();
485     } else {
486       let splitNode: ElementNode;
487       let splitOffset: number;
488       if ($isTextNode(focusNode)) {
489         splitNode = focusNode.getParentOrThrow();
490         splitOffset = focusNode.getIndexWithinParent();
491         if (focusOffset > 0) {
492           splitOffset += 1;
493           focusNode.splitText(focusOffset);
494         }
495       } else {
496         splitNode = focusNode;
497         splitOffset = focusOffset;
498       }
499       const [, rightTree] = $splitNode(splitNode, splitOffset);
500       rightTree.insertBefore(node);
501       rightTree.selectStart();
502     }
503   } else {
504     if (selection != null) {
505       const nodes = selection.getNodes();
506       nodes[nodes.length - 1].getTopLevelElementOrThrow().insertAfter(node);
507     } else {
508       const root = $getRoot();
509       root.append(node);
510     }
511     const paragraphNode = $createParagraphNode();
512     node.insertAfter(paragraphNode);
513     paragraphNode.select();
514   }
515   return node.getLatest();
516 }
517
518 /**
519  * Wraps the node into another node created from a createElementNode function, eg. $createParagraphNode
520  * @param node - Node to be wrapped.
521  * @param createElementNode - Creates a new lexical element to wrap the to-be-wrapped node and returns it.
522  * @returns A new lexical element with the previous node appended within (as a child, including its children).
523  */
524 export function $wrapNodeInElement(
525   node: LexicalNode,
526   createElementNode: () => ElementNode,
527 ): ElementNode {
528   const elementNode = createElementNode();
529   node.replace(elementNode);
530   elementNode.append(node);
531   return elementNode;
532 }
533
534 // eslint-disable-next-line @typescript-eslint/no-explicit-any
535 type ObjectKlass<T> = new (...args: any[]) => T;
536
537 /**
538  * @param object = The instance of the type
539  * @param objectClass = The class of the type
540  * @returns Whether the object is has the same Klass of the objectClass, ignoring the difference across window (e.g. different iframs)
541  */
542 export function objectKlassEquals<T>(
543   object: unknown,
544   objectClass: ObjectKlass<T>,
545 ): boolean {
546   return object !== null
547     ? Object.getPrototypeOf(object).constructor.name === objectClass.name
548     : false;
549 }
550
551 /**
552  * Filter the nodes
553  * @param nodes Array of nodes that needs to be filtered
554  * @param filterFn A filter function that returns node if the current node satisfies the condition otherwise null
555  * @returns Array of filtered nodes
556  */
557
558 export function $filter<T>(
559   nodes: Array<LexicalNode>,
560   filterFn: (node: LexicalNode) => null | T,
561 ): Array<T> {
562   const result: T[] = [];
563   for (let i = 0; i < nodes.length; i++) {
564     const node = filterFn(nodes[i]);
565     if (node !== null) {
566       result.push(node);
567     }
568   }
569   return result;
570 }
571 /**
572  * Appends the node before the first child of the parent node
573  * @param parent A parent node
574  * @param node Node that needs to be appended
575  */
576 export function $insertFirst(parent: ElementNode, node: LexicalNode): void {
577   const firstChild = parent.getFirstChild();
578   if (firstChild !== null) {
579     firstChild.insertBefore(node);
580   } else {
581     parent.append(node);
582   }
583 }
584
585 /**
586  * Calculates the zoom level of an element as a result of using
587  * css zoom property.
588  * @param element
589  */
590 export function calculateZoomLevel(element: Element | null): number {
591   if (IS_FIREFOX) {
592     return 1;
593   }
594   let zoom = 1;
595   while (element) {
596     zoom *= Number(window.getComputedStyle(element).getPropertyValue('zoom'));
597     element = element.parentElement;
598   }
599   return zoom;
600 }
601
602 /**
603  * Checks if the editor is a nested editor created by LexicalNestedComposer
604  */
605 export function $isEditorIsNestedEditor(editor: LexicalEditor): boolean {
606   return editor._parentEditor !== null;
607 }