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