]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/utils/lists.ts
Lexical: Made list selections & intendting more reliable
[bookstack] / resources / js / wysiwyg / utils / lists.ts
1 import {$createTextNode, $getSelection, BaseSelection, LexicalEditor, TextNode} from "lexical";
2 import {$getBlockElementNodesInSelection, $selectNodes, $toggleSelection} from "./selection";
3 import {$sortNodes, nodeHasInset} from "./nodes";
4 import {$createListItemNode, $createListNode, $isListItemNode, $isListNode, ListItemNode} from "@lexical/list";
5
6
7 export function $nestListItem(node: ListItemNode): ListItemNode {
8     const list = node.getParent();
9     if (!$isListNode(list)) {
10         return node;
11     }
12
13     const nodeChildList = node.getChildren().filter(n => $isListNode(n))[0] || null;
14     const nodeChildItems = nodeChildList?.getChildren() || [];
15
16     const listItems = list.getChildren() as ListItemNode[];
17     const nodeIndex = listItems.findIndex((n) => n.getKey() === node.getKey());
18     const isFirst = nodeIndex === 0;
19
20     const newListItem = $createListItemNode();
21     const newList = $createListNode(list.getListType());
22     newList.append(newListItem);
23     newListItem.append(...node.getChildren());
24
25     if (isFirst) {
26         node.append(newList);
27     } else  {
28         const prevListItem = listItems[nodeIndex - 1];
29         prevListItem.append(newList);
30         node.remove();
31     }
32
33     if (nodeChildList) {
34         for (const child of nodeChildItems) {
35             newListItem.insertAfter(child);
36         }
37         nodeChildList.remove();
38     }
39
40     return newListItem;
41 }
42
43 export function $unnestListItem(node: ListItemNode): ListItemNode {
44     const list = node.getParent();
45     const parentListItem = list?.getParent();
46     const outerList = parentListItem?.getParent();
47     if (!$isListNode(list) || !$isListNode(outerList) || !$isListItemNode(parentListItem)) {
48         return node;
49     }
50
51     const laterSiblings = node.getNextSiblings();
52     parentListItem.insertAfter(node);
53     if (list.getChildren().length === 0) {
54         list.remove();
55     }
56
57     if (laterSiblings.length > 0) {
58         const childList = $createListNode(list.getListType());
59         childList.append(...laterSiblings);
60         node.append(childList);
61     }
62
63     if (list.getChildrenSize() === 0) {
64         list.remove();
65     }
66
67     if (parentListItem.getChildren().length === 0) {
68         parentListItem.remove();
69     }
70
71     return node;
72 }
73
74 function getListItemsForSelection(selection: BaseSelection|null): (ListItemNode|null)[] {
75     const nodes = selection?.getNodes() || [];
76     let [start, end] = selection?.getStartEndPoints() || [null, null];
77
78     // Ensure we ignore parent list items of the top-most list item since,
79     // although technically part of the selection, from a user point of
80     // view the selection does not spread to encompass this outer element.
81     const itemsToIgnore: Set<string> = new Set();
82     if (selection && start) {
83         if (selection.isBackward() && end) {
84             [end, start] = [start, end];
85         }
86
87         const startParents = start.getNode().getParents();
88         let foundList = false;
89         for (const parent of startParents) {
90             if ($isListItemNode(parent)) {
91                 if (foundList) {
92                     itemsToIgnore.add(parent.getKey());
93                 } else {
94                     foundList = true;
95                 }
96             }
97         }
98     }
99
100     const listItemNodes = [];
101     outer: for (const node of nodes) {
102         if ($isListItemNode(node)) {
103             if (!itemsToIgnore.has(node.getKey())) {
104                 listItemNodes.push(node);
105             }
106             continue;
107         }
108
109         const parents = node.getParents();
110         for (const parent of parents) {
111             if ($isListItemNode(parent)) {
112                 if (!itemsToIgnore.has(parent.getKey())) {
113                     listItemNodes.push(parent);
114                 }
115                 continue outer;
116             }
117         }
118
119         listItemNodes.push(null);
120     }
121
122     return listItemNodes;
123 }
124
125 function $reduceDedupeListItems(listItems: (ListItemNode|null)[]): ListItemNode[] {
126     const listItemMap: Record<string, ListItemNode> = {};
127
128     for (const item of listItems) {
129         if (item === null) {
130             continue;
131         }
132
133         const key = item.getKey();
134         if (typeof listItemMap[key] === 'undefined') {
135             listItemMap[key] = item;
136         }
137     }
138
139     const items = Object.values(listItemMap);
140     return $sortNodes(items) as ListItemNode[];
141 }
142
143 export function $setInsetForSelection(editor: LexicalEditor, change: number): void {
144     const selection = $getSelection();
145     const selectionBounds = selection?.getStartEndPoints();
146     const listItemsInSelection = getListItemsForSelection(selection);
147     const isListSelection = listItemsInSelection.length > 0 && !listItemsInSelection.includes(null);
148
149     if (isListSelection) {
150         const alteredListItems = [];
151         const listItems = $reduceDedupeListItems(listItemsInSelection);
152         if (change > 0) {
153             for (const listItem of listItems) {
154                 alteredListItems.push($nestListItem(listItem));
155             }
156         } else if (change < 0) {
157             for (const listItem of [...listItems].reverse()) {
158                 alteredListItems.push($unnestListItem(listItem));
159             }
160             alteredListItems.reverse();
161         }
162
163         if (alteredListItems.length === 1 && selectionBounds) {
164             // Retain selection range if moving just one item
165             const listItem = alteredListItems[0] as ListItemNode;
166             let child = listItem.getChildren()[0] as TextNode;
167             if (!child) {
168                 child = $createTextNode('');
169                 listItem.append(child);
170             }
171             child.select(selectionBounds[0].offset, selectionBounds[1].offset);
172         } else {
173             $selectNodes(alteredListItems);
174         }
175
176         return;
177     }
178
179     const elements = $getBlockElementNodesInSelection(selection);
180     for (const node of elements) {
181         if (nodeHasInset(node)) {
182             const currentInset = node.getInset();
183             const newInset = Math.min(Math.max(currentInset + change, 0), 500);
184             node.setInset(newInset)
185         }
186     }
187
188     $toggleSelection(editor);
189 }