]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/selection/range-selection.ts
respective book and chapter structure added.
[bookstack] / resources / js / wysiwyg / lexical / selection / range-selection.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 type {
10   BaseSelection,
11   ElementNode,
12   LexicalNode,
13   NodeKey,
14   Point,
15   RangeSelection,
16   TextNode,
17 } from 'lexical';
18
19 import {TableSelection} from '@lexical/table';
20 import {
21   $getAdjacentNode,
22   $getPreviousSelection,
23   $getRoot,
24   $hasAncestor,
25   $isDecoratorNode,
26   $isElementNode,
27   $isLeafNode,
28   $isLineBreakNode,
29   $isRangeSelection,
30   $isRootNode,
31   $isRootOrShadowRoot,
32   $isTextNode,
33   $setSelection,
34 } from 'lexical';
35 import invariant from 'lexical/shared/invariant';
36
37 import {getStyleObjectFromCSS} from './utils';
38
39 /**
40  * Converts all nodes in the selection that are of one block type to another.
41  * @param selection - The selected blocks to be converted.
42  * @param createElement - The function that creates the node. eg. $createParagraphNode.
43  */
44 export function $setBlocksType(
45   selection: BaseSelection | null,
46   createElement: () => ElementNode,
47 ): void {
48   if (selection === null) {
49     return;
50   }
51   const anchorAndFocus = selection.getStartEndPoints();
52   const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
53
54   if (anchor !== null && anchor.key === 'root') {
55     const element = createElement();
56     const root = $getRoot();
57     const firstChild = root.getFirstChild();
58
59     if (firstChild) {
60       firstChild.replace(element, true);
61     } else {
62       root.append(element);
63     }
64
65     return;
66   }
67
68   const nodes = selection.getNodes();
69   const firstSelectedBlock =
70     anchor !== null ? $getAncestor(anchor.getNode(), INTERNAL_$isBlock) : false;
71   if (firstSelectedBlock && nodes.indexOf(firstSelectedBlock) === -1) {
72     nodes.push(firstSelectedBlock);
73   }
74
75   for (let i = 0; i < nodes.length; i++) {
76     const node = nodes[i];
77
78     if (!INTERNAL_$isBlock(node)) {
79       continue;
80     }
81     invariant($isElementNode(node), 'Expected block node to be an ElementNode');
82
83     const targetElement = createElement();
84     targetElement.setFormat(node.getFormatType());
85     targetElement.setIndent(node.getIndent());
86     node.replace(targetElement, true);
87   }
88 }
89
90 function isPointAttached(point: Point): boolean {
91   return point.getNode().isAttached();
92 }
93
94 function $removeParentEmptyElements(startingNode: ElementNode): void {
95   let node: ElementNode | null = startingNode;
96
97   while (node !== null && !$isRootOrShadowRoot(node)) {
98     const latest = node.getLatest();
99     const parentNode: ElementNode | null = node.getParent<ElementNode>();
100
101     if (latest.getChildrenSize() === 0) {
102       node.remove(true);
103     }
104
105     node = parentNode;
106   }
107 }
108
109 /**
110  * @deprecated
111  * Wraps all nodes in the selection into another node of the type returned by createElement.
112  * @param selection - The selection of nodes to be wrapped.
113  * @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
114  * @param wrappingElement - An element to append the wrapped selection and its children to.
115  */
116 export function $wrapNodes(
117   selection: BaseSelection,
118   createElement: () => ElementNode,
119   wrappingElement: null | ElementNode = null,
120 ): void {
121   const anchorAndFocus = selection.getStartEndPoints();
122   const anchor = anchorAndFocus ? anchorAndFocus[0] : null;
123   const nodes = selection.getNodes();
124   const nodesLength = nodes.length;
125
126   if (
127     anchor !== null &&
128     (nodesLength === 0 ||
129       (nodesLength === 1 &&
130         anchor.type === 'element' &&
131         anchor.getNode().getChildrenSize() === 0))
132   ) {
133     const target =
134       anchor.type === 'text'
135         ? anchor.getNode().getParentOrThrow()
136         : anchor.getNode();
137     const children = target.getChildren();
138     let element = createElement();
139     element.setFormat(target.getFormatType());
140     element.setIndent(target.getIndent());
141     children.forEach((child) => element.append(child));
142
143     if (wrappingElement) {
144       element = wrappingElement.append(element);
145     }
146
147     target.replace(element);
148
149     return;
150   }
151
152   let topLevelNode = null;
153   let descendants: LexicalNode[] = [];
154   for (let i = 0; i < nodesLength; i++) {
155     const node = nodes[i];
156     // Determine whether wrapping has to be broken down into multiple chunks. This can happen if the
157     // user selected multiple Root-like nodes that have to be treated separately as if they are
158     // their own branch. I.e. you don't want to wrap a whole table, but rather the contents of each
159     // of each of the cell nodes.
160     if ($isRootOrShadowRoot(node)) {
161       $wrapNodesImpl(
162         selection,
163         descendants,
164         descendants.length,
165         createElement,
166         wrappingElement,
167       );
168       descendants = [];
169       topLevelNode = node;
170     } else if (
171       topLevelNode === null ||
172       (topLevelNode !== null && $hasAncestor(node, topLevelNode))
173     ) {
174       descendants.push(node);
175     } else {
176       $wrapNodesImpl(
177         selection,
178         descendants,
179         descendants.length,
180         createElement,
181         wrappingElement,
182       );
183       descendants = [node];
184     }
185   }
186   $wrapNodesImpl(
187     selection,
188     descendants,
189     descendants.length,
190     createElement,
191     wrappingElement,
192   );
193 }
194
195 /**
196  * Wraps each node into a new ElementNode.
197  * @param selection - The selection of nodes to wrap.
198  * @param nodes - An array of nodes, generally the descendants of the selection.
199  * @param nodesLength - The length of nodes.
200  * @param createElement - A function that creates the wrapping ElementNode. eg. $createParagraphNode.
201  * @param wrappingElement - An element to wrap all the nodes into.
202  * @returns
203  */
204 export function $wrapNodesImpl(
205   selection: BaseSelection,
206   nodes: LexicalNode[],
207   nodesLength: number,
208   createElement: () => ElementNode,
209   wrappingElement: null | ElementNode = null,
210 ): void {
211   if (nodes.length === 0) {
212     return;
213   }
214
215   const firstNode = nodes[0];
216   const elementMapping: Map<NodeKey, ElementNode> = new Map();
217   const elements = [];
218   // The below logic is to find the right target for us to
219   // either insertAfter/insertBefore/append the corresponding
220   // elements to. This is made more complicated due to nested
221   // structures.
222   let target = $isElementNode(firstNode)
223     ? firstNode
224     : firstNode.getParentOrThrow();
225
226   if (target.isInline()) {
227     target = target.getParentOrThrow();
228   }
229
230   let targetIsPrevSibling = false;
231   while (target !== null) {
232     const prevSibling = target.getPreviousSibling<ElementNode>();
233
234     if (prevSibling !== null) {
235       target = prevSibling;
236       targetIsPrevSibling = true;
237       break;
238     }
239
240     target = target.getParentOrThrow();
241
242     if ($isRootOrShadowRoot(target)) {
243       break;
244     }
245   }
246
247   const emptyElements = new Set();
248
249   // Find any top level empty elements
250   for (let i = 0; i < nodesLength; i++) {
251     const node = nodes[i];
252
253     if ($isElementNode(node) && node.getChildrenSize() === 0) {
254       emptyElements.add(node.getKey());
255     }
256   }
257
258   const movedNodes: Set<NodeKey> = new Set();
259
260   // Move out all leaf nodes into our elements array.
261   // If we find a top level empty element, also move make
262   // an element for that.
263   for (let i = 0; i < nodesLength; i++) {
264     const node = nodes[i];
265     let parent = node.getParent();
266
267     if (parent !== null && parent.isInline()) {
268       parent = parent.getParent();
269     }
270
271     if (
272       parent !== null &&
273       $isLeafNode(node) &&
274       !movedNodes.has(node.getKey())
275     ) {
276       const parentKey = parent.getKey();
277
278       if (elementMapping.get(parentKey) === undefined) {
279         const targetElement = createElement();
280         targetElement.setFormat(parent.getFormatType());
281         targetElement.setIndent(parent.getIndent());
282         elements.push(targetElement);
283         elementMapping.set(parentKey, targetElement);
284         // Move node and its siblings to the new
285         // element.
286         parent.getChildren().forEach((child) => {
287           targetElement.append(child);
288           movedNodes.add(child.getKey());
289           if ($isElementNode(child)) {
290             // Skip nested leaf nodes if the parent has already been moved
291             child.getChildrenKeys().forEach((key) => movedNodes.add(key));
292           }
293         });
294         $removeParentEmptyElements(parent);
295       }
296     } else if (emptyElements.has(node.getKey())) {
297       invariant(
298         $isElementNode(node),
299         'Expected node in emptyElements to be an ElementNode',
300       );
301       const targetElement = createElement();
302       targetElement.setFormat(node.getFormatType());
303       targetElement.setIndent(node.getIndent());
304       elements.push(targetElement);
305       node.remove(true);
306     }
307   }
308
309   if (wrappingElement !== null) {
310     for (let i = 0; i < elements.length; i++) {
311       const element = elements[i];
312       wrappingElement.append(element);
313     }
314   }
315   let lastElement = null;
316
317   // If our target is Root-like, let's see if we can re-adjust
318   // so that the target is the first child instead.
319   if ($isRootOrShadowRoot(target)) {
320     if (targetIsPrevSibling) {
321       if (wrappingElement !== null) {
322         target.insertAfter(wrappingElement);
323       } else {
324         for (let i = elements.length - 1; i >= 0; i--) {
325           const element = elements[i];
326           target.insertAfter(element);
327         }
328       }
329     } else {
330       const firstChild = target.getFirstChild();
331
332       if ($isElementNode(firstChild)) {
333         target = firstChild;
334       }
335
336       if (firstChild === null) {
337         if (wrappingElement) {
338           target.append(wrappingElement);
339         } else {
340           for (let i = 0; i < elements.length; i++) {
341             const element = elements[i];
342             target.append(element);
343             lastElement = element;
344           }
345         }
346       } else {
347         if (wrappingElement !== null) {
348           firstChild.insertBefore(wrappingElement);
349         } else {
350           for (let i = 0; i < elements.length; i++) {
351             const element = elements[i];
352             firstChild.insertBefore(element);
353             lastElement = element;
354           }
355         }
356       }
357     }
358   } else {
359     if (wrappingElement) {
360       target.insertAfter(wrappingElement);
361     } else {
362       for (let i = elements.length - 1; i >= 0; i--) {
363         const element = elements[i];
364         target.insertAfter(element);
365         lastElement = element;
366       }
367     }
368   }
369
370   const prevSelection = $getPreviousSelection();
371
372   if (
373     $isRangeSelection(prevSelection) &&
374     isPointAttached(prevSelection.anchor) &&
375     isPointAttached(prevSelection.focus)
376   ) {
377     $setSelection(prevSelection.clone());
378   } else if (lastElement !== null) {
379     lastElement.selectEnd();
380   } else {
381     selection.dirty = true;
382   }
383 }
384
385 /**
386  * Determines if the default character selection should be overridden. Used with DecoratorNodes
387  * @param selection - The selection whose default character selection may need to be overridden.
388  * @param isBackward - Is the selection backwards (the focus comes before the anchor)?
389  * @returns true if it should be overridden, false if not.
390  */
391 export function $shouldOverrideDefaultCharacterSelection(
392   selection: RangeSelection,
393   isBackward: boolean,
394 ): boolean {
395   const possibleNode = $getAdjacentNode(selection.focus, isBackward);
396
397   return (
398     ($isDecoratorNode(possibleNode) && !possibleNode.isIsolated()) ||
399     ($isElementNode(possibleNode) &&
400       !possibleNode.isInline() &&
401       !possibleNode.canBeEmpty())
402   );
403 }
404
405 /**
406  * Moves the selection according to the arguments.
407  * @param selection - The selected text or nodes.
408  * @param isHoldingShift - Is the shift key being held down during the operation.
409  * @param isBackward - Is the selection selected backwards (the focus comes before the anchor)?
410  * @param granularity - The distance to adjust the current selection.
411  */
412 export function $moveCaretSelection(
413   selection: RangeSelection,
414   isHoldingShift: boolean,
415   isBackward: boolean,
416   granularity: 'character' | 'word' | 'lineboundary',
417 ): void {
418   selection.modify(isHoldingShift ? 'extend' : 'move', isBackward, granularity);
419 }
420
421 /**
422  * Tests a parent element for right to left direction.
423  * @param selection - The selection whose parent is to be tested.
424  * @returns true if the selections' parent element has a direction of 'rtl' (right to left), false otherwise.
425  */
426 export function $isParentElementRTL(selection: RangeSelection): boolean {
427   const anchorNode = selection.anchor.getNode();
428   const parent = $isRootNode(anchorNode)
429     ? anchorNode
430     : anchorNode.getParentOrThrow();
431
432   return parent.getDirection() === 'rtl';
433 }
434
435 /**
436  * Moves selection by character according to arguments.
437  * @param selection - The selection of the characters to move.
438  * @param isHoldingShift - Is the shift key being held down during the operation.
439  * @param isBackward - Is the selection backward (the focus comes before the anchor)?
440  */
441 export function $moveCharacter(
442   selection: RangeSelection,
443   isHoldingShift: boolean,
444   isBackward: boolean,
445 ): void {
446   const isRTL = $isParentElementRTL(selection);
447   $moveCaretSelection(
448     selection,
449     isHoldingShift,
450     isBackward ? !isRTL : isRTL,
451     'character',
452   );
453 }
454
455 /**
456  * Expands the current Selection to cover all of the content in the editor.
457  * @param selection - The current selection.
458  */
459 export function $selectAll(selection: RangeSelection): void {
460   const anchor = selection.anchor;
461   const focus = selection.focus;
462   const anchorNode = anchor.getNode();
463   const topParent = anchorNode.getTopLevelElementOrThrow();
464   const root = topParent.getParentOrThrow();
465   let firstNode = root.getFirstDescendant();
466   let lastNode = root.getLastDescendant();
467   let firstType: 'element' | 'text' = 'element';
468   let lastType: 'element' | 'text' = 'element';
469   let lastOffset = 0;
470
471   if ($isTextNode(firstNode)) {
472     firstType = 'text';
473   } else if (!$isElementNode(firstNode) && firstNode !== null) {
474     firstNode = firstNode.getParentOrThrow();
475   }
476
477   if ($isTextNode(lastNode)) {
478     lastType = 'text';
479     lastOffset = lastNode.getTextContentSize();
480   } else if (!$isElementNode(lastNode) && lastNode !== null) {
481     lastNode = lastNode.getParentOrThrow();
482   }
483
484   if (firstNode && lastNode) {
485     anchor.set(firstNode.getKey(), 0, firstType);
486     focus.set(lastNode.getKey(), lastOffset, lastType);
487   }
488 }
489
490 /**
491  * Returns the current value of a CSS property for Nodes, if set. If not set, it returns the defaultValue.
492  * @param node - The node whose style value to get.
493  * @param styleProperty - The CSS style property.
494  * @param defaultValue - The default value for the property.
495  * @returns The value of the property for node.
496  */
497 function $getNodeStyleValueForProperty(
498   node: TextNode,
499   styleProperty: string,
500   defaultValue: string,
501 ): string {
502   const css = node.getStyle();
503   const styleObject = getStyleObjectFromCSS(css);
504
505   if (styleObject !== null) {
506     return styleObject[styleProperty] || defaultValue;
507   }
508
509   return defaultValue;
510 }
511
512 /**
513  * Returns the current value of a CSS property for TextNodes in the Selection, if set. If not set, it returns the defaultValue.
514  * If all TextNodes do not have the same value, it returns an empty string.
515  * @param selection - The selection of TextNodes whose value to find.
516  * @param styleProperty - The CSS style property.
517  * @param defaultValue - The default value for the property, defaults to an empty string.
518  * @returns The value of the property for the selected TextNodes.
519  */
520 export function $getSelectionStyleValueForProperty(
521   selection: RangeSelection | TableSelection,
522   styleProperty: string,
523   defaultValue = '',
524 ): string {
525   let styleValue: string | null = null;
526   const nodes = selection.getNodes();
527   const anchor = selection.anchor;
528   const focus = selection.focus;
529   const isBackward = selection.isBackward();
530   const endOffset = isBackward ? focus.offset : anchor.offset;
531   const endNode = isBackward ? focus.getNode() : anchor.getNode();
532
533   if (
534     $isRangeSelection(selection) &&
535     selection.isCollapsed() &&
536     selection.style !== ''
537   ) {
538     const css = selection.style;
539     const styleObject = getStyleObjectFromCSS(css);
540
541     if (styleObject !== null && styleProperty in styleObject) {
542       return styleObject[styleProperty];
543     }
544   }
545
546   for (let i = 0; i < nodes.length; i++) {
547     const node = nodes[i];
548
549     // if no actual characters in the end node are selected, we don't
550     // include it in the selection for purposes of determining style
551     // value
552     if (i !== 0 && endOffset === 0 && node.is(endNode)) {
553       continue;
554     }
555
556     if ($isTextNode(node)) {
557       const nodeStyleValue = $getNodeStyleValueForProperty(
558         node,
559         styleProperty,
560         defaultValue,
561       );
562
563       if (styleValue === null) {
564         styleValue = nodeStyleValue;
565       } else if (styleValue !== nodeStyleValue) {
566         // multiple text nodes are in the selection and they don't all
567         // have the same style.
568         styleValue = '';
569         break;
570       }
571     }
572   }
573
574   return styleValue === null ? defaultValue : styleValue;
575 }
576
577 /**
578  * This function is for internal use of the library.
579  * Please do not use it as it may change in the future.
580  */
581 export function INTERNAL_$isBlock(node: LexicalNode): node is ElementNode {
582   if ($isDecoratorNode(node)) {
583     return false;
584   }
585   if (!$isElementNode(node) || $isRootOrShadowRoot(node)) {
586     return false;
587   }
588
589   const firstChild = node.getFirstChild();
590   const isLeafElement =
591     firstChild === null ||
592     $isLineBreakNode(firstChild) ||
593     $isTextNode(firstChild) ||
594     firstChild.isInline();
595
596   return !node.isInline() && node.canBeEmpty() !== false && isLeafElement;
597 }
598
599 export function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
600   node: LexicalNode,
601   predicate: (ancestor: LexicalNode) => ancestor is NodeType,
602 ) {
603   let parent = node;
604   while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
605     parent = parent.getParentOrThrow();
606   }
607   return predicate(parent) ? parent : null;
608 }