]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/list/formatList.ts
b9ca011696abb931b6dfc67413eaf4762f239665
[bookstack] / resources / js / wysiwyg / lexical / list / formatList.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 {$getNearestNodeOfType} from '@lexical/utils';
10 import {
11   $createParagraphNode,
12   $getSelection,
13   $isElementNode,
14   $isLeafNode,
15   $isParagraphNode,
16   $isRangeSelection,
17   $isRootOrShadowRoot,
18   ElementNode,
19   LexicalEditor,
20   LexicalNode,
21   NodeKey,
22   ParagraphNode,
23 } from 'lexical';
24 import invariant from 'lexical/shared/invariant';
25
26 import {
27   $createListItemNode,
28   $createListNode,
29   $isListItemNode,
30   $isListNode,
31   ListItemNode,
32   ListNode,
33 } from './';
34 import {ListType} from './LexicalListNode';
35 import {
36   $getAllListItems,
37   $getTopListNode,
38   $removeHighestEmptyListParent,
39   isNestedListNode,
40 } from './utils';
41
42 function $isSelectingEmptyListItem(
43   anchorNode: ListItemNode | LexicalNode,
44   nodes: Array<LexicalNode>,
45 ): boolean {
46   return (
47     $isListItemNode(anchorNode) &&
48     (nodes.length === 0 ||
49       (nodes.length === 1 &&
50         anchorNode.is(nodes[0]) &&
51         anchorNode.getChildrenSize() === 0))
52   );
53 }
54
55 /**
56  * Inserts a new ListNode. If the selection's anchor node is an empty ListItemNode and is a child of
57  * the root/shadow root, it will replace the ListItemNode with a ListNode and the old ListItemNode.
58  * Otherwise it will replace its parent with a new ListNode and re-insert the ListItemNode and any previous children.
59  * If the selection's anchor node is not an empty ListItemNode, it will add a new ListNode or merge an existing ListNode,
60  * unless the the node is a leaf node, in which case it will attempt to find a ListNode up the branch and replace it with
61  * a new ListNode, or create a new ListNode at the nearest root/shadow root.
62  * @param editor - The lexical editor.
63  * @param listType - The type of list, "number" | "bullet" | "check".
64  */
65 export function insertList(editor: LexicalEditor, listType: ListType): void {
66   editor.update(() => {
67     const selection = $getSelection();
68
69     if (selection !== null) {
70       const nodes = selection.getNodes();
71       if ($isRangeSelection(selection)) {
72         const anchorAndFocus = selection.getStartEndPoints();
73         invariant(
74           anchorAndFocus !== null,
75           'insertList: anchor should be defined',
76         );
77         const [anchor] = anchorAndFocus;
78         const anchorNode = anchor.getNode();
79         const anchorNodeParent = anchorNode.getParent();
80
81         if ($isSelectingEmptyListItem(anchorNode, nodes)) {
82           const list = $createListNode(listType);
83
84           if ($isRootOrShadowRoot(anchorNodeParent)) {
85             anchorNode.replace(list);
86             const listItem = $createListItemNode();
87             if ($isElementNode(anchorNode)) {
88               listItem.setFormat(anchorNode.getFormatType());
89               listItem.setIndent(anchorNode.getIndent());
90             }
91             list.append(listItem);
92           } else if ($isListItemNode(anchorNode)) {
93             const parent = anchorNode.getParentOrThrow();
94             append(list, parent.getChildren());
95             parent.replace(list);
96           }
97
98           return;
99         }
100       }
101
102       const handled = new Set();
103       for (let i = 0; i < nodes.length; i++) {
104         const node = nodes[i];
105
106         if (
107           $isElementNode(node) &&
108           node.isEmpty() &&
109           !$isListItemNode(node) &&
110           !handled.has(node.getKey())
111         ) {
112           $createListOrMerge(node, listType);
113           continue;
114         }
115
116         if ($isLeafNode(node)) {
117           let parent = node.getParent();
118           while (parent != null) {
119             const parentKey = parent.getKey();
120
121             if ($isListNode(parent)) {
122               if (!handled.has(parentKey)) {
123                 const newListNode = $createListNode(listType);
124                 append(newListNode, parent.getChildren());
125                 parent.replace(newListNode);
126                 handled.add(parentKey);
127               }
128
129               break;
130             } else {
131               const nextParent = parent.getParent();
132
133               if ($isRootOrShadowRoot(nextParent) && !handled.has(parentKey)) {
134                 handled.add(parentKey);
135                 $createListOrMerge(parent, listType);
136                 break;
137               }
138
139               parent = nextParent;
140             }
141           }
142         }
143       }
144     }
145   });
146 }
147
148 function append(node: ElementNode, nodesToAppend: Array<LexicalNode>) {
149   node.splice(node.getChildrenSize(), 0, nodesToAppend);
150 }
151
152 function $createListOrMerge(node: ElementNode, listType: ListType): ListNode {
153   if ($isListNode(node)) {
154     return node;
155   }
156
157   const previousSibling = node.getPreviousSibling();
158   const nextSibling = node.getNextSibling();
159   const listItem = $createListItemNode();
160   listItem.setFormat(node.getFormatType());
161   listItem.setIndent(node.getIndent());
162   append(listItem, node.getChildren());
163
164   if (
165     $isListNode(previousSibling) &&
166     listType === previousSibling.getListType()
167   ) {
168     previousSibling.append(listItem);
169     node.remove();
170     // if the same type of list is on both sides, merge them.
171
172     if ($isListNode(nextSibling) && listType === nextSibling.getListType()) {
173       append(previousSibling, nextSibling.getChildren());
174       nextSibling.remove();
175     }
176     return previousSibling;
177   } else if (
178     $isListNode(nextSibling) &&
179     listType === nextSibling.getListType()
180   ) {
181     nextSibling.getFirstChildOrThrow().insertBefore(listItem);
182     node.remove();
183     return nextSibling;
184   } else {
185     const list = $createListNode(listType);
186     list.append(listItem);
187     node.replace(list);
188     return list;
189   }
190 }
191
192 /**
193  * A recursive function that goes through each list and their children, including nested lists,
194  * appending list2 children after list1 children and updating ListItemNode values.
195  * @param list1 - The first list to be merged.
196  * @param list2 - The second list to be merged.
197  */
198 export function mergeLists(list1: ListNode, list2: ListNode): void {
199   const listItem1 = list1.getLastChild();
200   const listItem2 = list2.getFirstChild();
201
202   if (
203     listItem1 &&
204     listItem2 &&
205     isNestedListNode(listItem1) &&
206     isNestedListNode(listItem2)
207   ) {
208     mergeLists(listItem1.getFirstChild(), listItem2.getFirstChild());
209     listItem2.remove();
210   }
211
212   const toMerge = list2.getChildren();
213   if (toMerge.length > 0) {
214     list1.append(...toMerge);
215   }
216
217   list2.remove();
218 }
219
220 /**
221  * Searches for the nearest ancestral ListNode and removes it. If selection is an empty ListItemNode
222  * it will remove the whole list, including the ListItemNode. For each ListItemNode in the ListNode,
223  * removeList will also generate new ParagraphNodes in the removed ListNode's place. Any child node
224  * inside a ListItemNode will be appended to the new ParagraphNodes.
225  * @param editor - The lexical editor.
226  */
227 export function removeList(editor: LexicalEditor): void {
228   editor.update(() => {
229     const selection = $getSelection();
230
231     if ($isRangeSelection(selection)) {
232       const listNodes = new Set<ListNode>();
233       const nodes = selection.getNodes();
234       const anchorNode = selection.anchor.getNode();
235
236       if ($isSelectingEmptyListItem(anchorNode, nodes)) {
237         listNodes.add($getTopListNode(anchorNode));
238       } else {
239         for (let i = 0; i < nodes.length; i++) {
240           const node = nodes[i];
241
242           if ($isLeafNode(node)) {
243             const listItemNode = $getNearestNodeOfType(node, ListItemNode);
244
245             if (listItemNode != null) {
246               listNodes.add($getTopListNode(listItemNode));
247             }
248           }
249         }
250       }
251
252       for (const listNode of listNodes) {
253         let insertionPoint: ListNode | ParagraphNode = listNode;
254
255         const listItems = $getAllListItems(listNode);
256
257         for (const listItemNode of listItems) {
258           const paragraph = $createParagraphNode();
259
260           append(paragraph, listItemNode.getChildren());
261
262           insertionPoint.insertAfter(paragraph);
263           insertionPoint = paragraph;
264
265           // When the anchor and focus fall on the textNode
266           // we don't have to change the selection because the textNode will be appended to
267           // the newly generated paragraph.
268           // When selection is in empty nested list item, selection is actually on the listItemNode.
269           // When the corresponding listItemNode is deleted and replaced by the newly generated paragraph
270           // we should manually set the selection's focus and anchor to the newly generated paragraph.
271           if (listItemNode.__key === selection.anchor.key) {
272             selection.anchor.set(paragraph.getKey(), 0, 'element');
273           }
274           if (listItemNode.__key === selection.focus.key) {
275             selection.focus.set(paragraph.getKey(), 0, 'element');
276           }
277
278           listItemNode.remove();
279         }
280         listNode.remove();
281       }
282     }
283   });
284 }
285
286 /**
287  * Takes the value of a child ListItemNode and makes it the value the ListItemNode
288  * should be if it isn't already. Also ensures that checked is undefined if the
289  * parent does not have a list type of 'check'.
290  * @param list - The list whose children are updated.
291  */
292 export function updateChildrenListItemValue(list: ListNode): void {
293   const isNotChecklist = list.getListType() !== 'check';
294   let value = list.getStart();
295   for (const child of list.getChildren()) {
296     if ($isListItemNode(child)) {
297       if (child.getValue() !== value) {
298         child.setValue(value);
299       }
300       if (isNotChecklist && child.getLatest().__checked != null) {
301         child.setChecked(undefined);
302       }
303       if (!$isListNode(child.getFirstChild())) {
304         value++;
305       }
306     }
307   }
308 }
309
310 /**
311  * Merge the next sibling list if same type.
312  * <ul> will merge with <ul>, but NOT <ul> with <ol>.
313  * @param list - The list whose next sibling should be potentially merged
314  */
315 export function mergeNextSiblingListIfSameType(list: ListNode): void {
316   const nextSibling = list.getNextSibling();
317   if (
318     $isListNode(nextSibling) &&
319     list.getListType() === nextSibling.getListType()
320   ) {
321     mergeLists(list, nextSibling);
322   }
323 }
324
325 /**
326  * Adds an empty ListNode/ListItemNode chain at listItemNode, so as to
327  * create an indent effect. Won't indent ListItemNodes that have a ListNode as
328  * a child, but does merge sibling ListItemNodes if one has a nested ListNode.
329  * @param listItemNode - The ListItemNode to be indented.
330  */
331 export function $handleIndent(listItemNode: ListItemNode): void {
332   // go through each node and decide where to move it.
333   const removed = new Set<NodeKey>();
334
335   if (isNestedListNode(listItemNode) || removed.has(listItemNode.getKey())) {
336     return;
337   }
338
339   const parent = listItemNode.getParent();
340
341   // We can cast both of the below `isNestedListNode` only returns a boolean type instead of a user-defined type guards
342   const nextSibling =
343     listItemNode.getNextSibling<ListItemNode>() as ListItemNode;
344   const previousSibling =
345     listItemNode.getPreviousSibling<ListItemNode>() as ListItemNode;
346   // if there are nested lists on either side, merge them all together.
347
348   if (isNestedListNode(nextSibling) && isNestedListNode(previousSibling)) {
349     const innerList = previousSibling.getFirstChild();
350
351     if ($isListNode(innerList)) {
352       innerList.append(listItemNode);
353       const nextInnerList = nextSibling.getFirstChild();
354
355       if ($isListNode(nextInnerList)) {
356         const children = nextInnerList.getChildren();
357         append(innerList, children);
358         nextSibling.remove();
359         removed.add(nextSibling.getKey());
360       }
361     }
362   } else if (isNestedListNode(nextSibling)) {
363     // if the ListItemNode is next to a nested ListNode, merge them
364     const innerList = nextSibling.getFirstChild();
365
366     if ($isListNode(innerList)) {
367       const firstChild = innerList.getFirstChild();
368
369       if (firstChild !== null) {
370         firstChild.insertBefore(listItemNode);
371       }
372     }
373   } else if (isNestedListNode(previousSibling)) {
374     const innerList = previousSibling.getFirstChild();
375
376     if ($isListNode(innerList)) {
377       innerList.append(listItemNode);
378     }
379   } else {
380     // otherwise, we need to create a new nested ListNode
381
382     if ($isListNode(parent)) {
383       const newListItem = $createListItemNode();
384       const newList = $createListNode(parent.getListType());
385       newListItem.append(newList);
386       newList.append(listItemNode);
387
388       if (previousSibling) {
389         previousSibling.insertAfter(newListItem);
390       } else if (nextSibling) {
391         nextSibling.insertBefore(newListItem);
392       } else {
393         parent.append(newListItem);
394       }
395     }
396   }
397 }
398
399 /**
400  * Removes an indent by removing an empty ListNode/ListItemNode chain. An indented ListItemNode
401  * has a great grandparent node of type ListNode, which is where the ListItemNode will reside
402  * within as a child.
403  * @param listItemNode - The ListItemNode to remove the indent (outdent).
404  */
405 export function $handleOutdent(listItemNode: ListItemNode): void {
406   // go through each node and decide where to move it.
407
408   if (isNestedListNode(listItemNode)) {
409     return;
410   }
411   const parentList = listItemNode.getParent();
412   const grandparentListItem = parentList ? parentList.getParent() : undefined;
413   const greatGrandparentList = grandparentListItem
414     ? grandparentListItem.getParent()
415     : undefined;
416   // If it doesn't have these ancestors, it's not indented.
417
418   if (
419     $isListNode(greatGrandparentList) &&
420     $isListItemNode(grandparentListItem) &&
421     $isListNode(parentList)
422   ) {
423     // if it's the first child in it's parent list, insert it into the
424     // great grandparent list before the grandparent
425     const firstChild = parentList ? parentList.getFirstChild() : undefined;
426     const lastChild = parentList ? parentList.getLastChild() : undefined;
427
428     if (listItemNode.is(firstChild)) {
429       grandparentListItem.insertBefore(listItemNode);
430
431       if (parentList.isEmpty()) {
432         grandparentListItem.remove();
433       }
434       // if it's the last child in it's parent list, insert it into the
435       // great grandparent list after the grandparent.
436     } else if (listItemNode.is(lastChild)) {
437       grandparentListItem.insertAfter(listItemNode);
438
439       if (parentList.isEmpty()) {
440         grandparentListItem.remove();
441       }
442     } else {
443       // otherwise, we need to split the siblings into two new nested lists
444       const listType = parentList.getListType();
445       const previousSiblingsListItem = $createListItemNode();
446       const previousSiblingsList = $createListNode(listType);
447       previousSiblingsListItem.append(previousSiblingsList);
448       listItemNode
449         .getPreviousSiblings()
450         .forEach((sibling) => previousSiblingsList.append(sibling));
451       const nextSiblingsListItem = $createListItemNode();
452       const nextSiblingsList = $createListNode(listType);
453       nextSiblingsListItem.append(nextSiblingsList);
454       append(nextSiblingsList, listItemNode.getNextSiblings());
455       // put the sibling nested lists on either side of the grandparent list item in the great grandparent.
456       grandparentListItem.insertBefore(previousSiblingsListItem);
457       grandparentListItem.insertAfter(nextSiblingsListItem);
458       // replace the grandparent list item (now between the siblings) with the outdented list item.
459       grandparentListItem.replace(listItemNode);
460     }
461   }
462 }
463
464 /**
465  * Attempts to insert a ParagraphNode at selection and selects the new node. The selection must contain a ListItemNode
466  * or a node that does not already contain text. If its grandparent is the root/shadow root, it will get the ListNode
467  * (which should be the parent node) and insert the ParagraphNode as a sibling to the ListNode. If the ListNode is
468  * nested in a ListItemNode instead, it will add the ParagraphNode after the grandparent ListItemNode.
469  * Throws an invariant if the selection is not a child of a ListNode.
470  * @returns true if a ParagraphNode was inserted succesfully, false if there is no selection
471  * or the selection does not contain a ListItemNode or the node already holds text.
472  */
473 export function $handleListInsertParagraph(): boolean {
474   const selection = $getSelection();
475
476   if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
477     return false;
478   }
479   // Only run this code on empty list items
480   const anchor = selection.anchor.getNode();
481
482   if (!$isListItemNode(anchor) || anchor.getChildrenSize() !== 0) {
483     return false;
484   }
485   const topListNode = $getTopListNode(anchor);
486   const parent = anchor.getParent();
487
488   invariant(
489     $isListNode(parent),
490     'A ListItemNode must have a ListNode for a parent.',
491   );
492
493   const grandparent = parent.getParent();
494
495   let replacementNode;
496
497   if ($isRootOrShadowRoot(grandparent)) {
498     replacementNode = $createParagraphNode();
499     topListNode.insertAfter(replacementNode);
500   } else if ($isListItemNode(grandparent)) {
501     replacementNode = $createListItemNode();
502     grandparent.insertAfter(replacementNode);
503   } else {
504     return false;
505   }
506   replacementNode.select();
507
508   const nextSiblings = anchor.getNextSiblings();
509
510   if (nextSiblings.length > 0) {
511     const newList = $createListNode(parent.getListType());
512
513     if ($isParagraphNode(replacementNode)) {
514       replacementNode.insertAfter(newList);
515     } else {
516       const newListItem = $createListItemNode();
517       newListItem.append(newList);
518       replacementNode.insertAfter(newListItem);
519     }
520     nextSiblings.forEach((sibling) => {
521       sibling.remove();
522       newList.append(sibling);
523     });
524   }
525
526   // Don't leave hanging nested empty lists
527   $removeHighestEmptyListParent(anchor);
528
529   return true;
530 }