]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/list/utils.ts
Updated translator & dependency attribution before release v25.05.2
[bookstack] / resources / js / wysiwyg / lexical / list / utils.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 {LexicalNode, Spread} from 'lexical';
10
11 import {$findMatchingParent} from '@lexical/utils';
12 import invariant from 'lexical/shared/invariant';
13
14 import {
15   $createListItemNode,
16   $isListItemNode,
17   $isListNode,
18   ListItemNode,
19   ListNode,
20 } from './';
21
22 /**
23  * Checks the depth of listNode from the root node.
24  * @param listNode - The ListNode to be checked.
25  * @returns The depth of the ListNode.
26  */
27 export function $getListDepth(listNode: ListNode): number {
28   let depth = 1;
29   let parent = listNode.getParent();
30
31   while (parent != null) {
32     if ($isListItemNode(parent)) {
33       const parentList = parent.getParent();
34
35       if ($isListNode(parentList)) {
36         depth++;
37         parent = parentList.getParent();
38         continue;
39       }
40       invariant(false, 'A ListItemNode must have a ListNode for a parent.');
41     }
42
43     return depth;
44   }
45
46   return depth;
47 }
48
49 /**
50  * Finds the nearest ancestral ListNode and returns it, throws an invariant if listItem is not a ListItemNode.
51  * @param listItem - The node to be checked.
52  * @returns The ListNode found.
53  */
54 export function $getTopListNode(listItem: LexicalNode): ListNode {
55   let list = listItem.getParent<ListNode>();
56
57   if (!$isListNode(list)) {
58     invariant(false, 'A ListItemNode must have a ListNode for a parent.');
59   }
60
61   let parent: ListNode | null = list;
62
63   while (parent !== null) {
64     parent = parent.getParent();
65
66     if ($isListNode(parent)) {
67       list = parent;
68     }
69   }
70
71   return list;
72 }
73
74 /**
75  * Checks if listItem has no child ListNodes and has no ListItemNode ancestors with siblings.
76  * @param listItem - the ListItemNode to be checked.
77  * @returns true if listItem has no child ListNode and no ListItemNode ancestors with siblings, false otherwise.
78  */
79 export function $isLastItemInList(listItem: ListItemNode): boolean {
80   let isLast = true;
81   const firstChild = listItem.getFirstChild();
82
83   if ($isListNode(firstChild)) {
84     return false;
85   }
86   let parent: ListItemNode | null = listItem;
87
88   while (parent !== null) {
89     if ($isListItemNode(parent)) {
90       if (parent.getNextSiblings().length > 0) {
91         isLast = false;
92       }
93     }
94
95     parent = parent.getParent();
96   }
97
98   return isLast;
99 }
100
101 /**
102  * A recursive Depth-First Search (Postorder Traversal) that finds all of a node's children
103  * that are of type ListItemNode and returns them in an array.
104  * @param node - The ListNode to start the search.
105  * @returns An array containing all nodes of type ListItemNode found.
106  */
107 // This should probably be $getAllChildrenOfType
108 export function $getAllListItems(node: ListNode): Array<ListItemNode> {
109   let listItemNodes: Array<ListItemNode> = [];
110   const listChildren: Array<ListItemNode> = node
111     .getChildren()
112     .filter($isListItemNode);
113
114   for (let i = 0; i < listChildren.length; i++) {
115     const listItemNode = listChildren[i];
116     const firstChild = listItemNode.getFirstChild();
117
118     if ($isListNode(firstChild)) {
119       listItemNodes = listItemNodes.concat($getAllListItems(firstChild));
120     } else {
121       listItemNodes.push(listItemNode);
122     }
123   }
124
125   return listItemNodes;
126 }
127
128 const NestedListNodeBrand: unique symbol = Symbol.for(
129   '@lexical/NestedListNodeBrand',
130 );
131
132 /**
133  * Checks to see if the passed node is a ListItemNode and has a ListNode as a child.
134  * @param node - The node to be checked.
135  * @returns true if the node is a ListItemNode and has a ListNode child, false otherwise.
136  */
137 export function isNestedListNode(
138   node: LexicalNode | null | undefined,
139 ): node is Spread<
140   {getFirstChild(): ListNode; [NestedListNodeBrand]: never},
141   ListItemNode
142 > {
143   return $isListItemNode(node) && $isListNode(node.getFirstChild());
144 }
145
146 /**
147  * Traverses up the tree and returns the first ListItemNode found.
148  * @param node - Node to start the search.
149  * @returns The first ListItemNode found, or null if none exist.
150  */
151 export function $findNearestListItemNode(
152   node: LexicalNode,
153 ): ListItemNode | null {
154   const matchingParent = $findMatchingParent(node, (parent) =>
155     $isListItemNode(parent),
156   );
157   return matchingParent as ListItemNode | null;
158 }
159
160 /**
161  * Takes a deeply nested ListNode or ListItemNode and traverses up the branch to delete the first
162  * ancestral ListNode (which could be the root ListNode) or ListItemNode with siblings, essentially
163  * bringing the deeply nested node up the branch once. Would remove sublist if it has siblings.
164  * Should not break ListItem -> List -> ListItem chain as empty List/ItemNodes should be removed on .remove().
165  * @param sublist - The nested ListNode or ListItemNode to be brought up the branch.
166  */
167 export function $removeHighestEmptyListParent(
168   sublist: ListItemNode | ListNode,
169 ) {
170   // Nodes may be repeatedly indented, to create deeply nested lists that each
171   // contain just one bullet.
172   // Our goal is to remove these (empty) deeply nested lists. The easiest
173   // way to do that is crawl back up the tree until we find a node that has siblings
174   // (e.g. is actually part of the list contents) and delete that, or delete
175   // the root of the list (if no list nodes have siblings.)
176   let emptyListPtr = sublist;
177
178   while (
179     emptyListPtr.getNextSibling() == null &&
180     emptyListPtr.getPreviousSibling() == null
181   ) {
182     const parent = emptyListPtr.getParent<ListItemNode | ListNode>();
183
184     if (
185       parent == null ||
186       !($isListItemNode(emptyListPtr) || $isListNode(emptyListPtr))
187     ) {
188       break;
189     }
190
191     emptyListPtr = parent;
192   }
193
194   emptyListPtr.remove();
195 }
196
197 /**
198  * Wraps a node into a ListItemNode.
199  * @param node - The node to be wrapped into a ListItemNode
200  * @returns The ListItemNode which the passed node is wrapped in.
201  */
202 export function $wrapInListItem(node: LexicalNode): ListItemNode {
203   const listItemWrapper = $createListItemNode();
204   return listItemWrapper.append(node);
205 }