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