]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts
Lexical: Merged list nodes
[bookstack] / resources / js / wysiwyg / lexical / list / LexicalListItemNode.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 {ListNode, ListType} from './';
10 import type {
11   BaseSelection,
12   DOMConversionMap,
13   DOMConversionOutput,
14   DOMExportOutput,
15   EditorConfig,
16   LexicalNode,
17   NodeKey,
18   ParagraphNode,
19   RangeSelection,
20   SerializedElementNode,
21   Spread,
22 } from 'lexical';
23
24 import {
25   $applyNodeReplacement,
26   $createParagraphNode,
27   $isElementNode,
28   $isParagraphNode,
29   $isRangeSelection,
30   ElementNode,
31   LexicalEditor,
32 } from 'lexical';
33 import invariant from 'lexical/shared/invariant';
34
35 import {$createListNode, $isListNode} from './';
36 import {mergeLists} from './formatList';
37 import {isNestedListNode} from './utils';
38 import {el} from "../../utils/dom";
39
40 export type SerializedListItemNode = Spread<
41   {
42     checked: boolean | undefined;
43     value: number;
44   },
45   SerializedElementNode
46 >;
47
48 /** @noInheritDoc */
49 export class ListItemNode extends ElementNode {
50   /** @internal */
51   __value: number;
52   /** @internal */
53   __checked?: boolean;
54
55   static getType(): string {
56     return 'listitem';
57   }
58
59   static clone(node: ListItemNode): ListItemNode {
60     return new ListItemNode(node.__value, node.__checked, node.__key);
61   }
62
63   constructor(value?: number, checked?: boolean, key?: NodeKey) {
64     super(key);
65     this.__value = value === undefined ? 1 : value;
66     this.__checked = checked;
67   }
68
69   createDOM(config: EditorConfig): HTMLElement {
70     const element = document.createElement('li');
71     const parent = this.getParent();
72
73     if ($isListNode(parent) && parent.getListType() === 'check') {
74       updateListItemChecked(element, this);
75     }
76
77     element.value = this.__value;
78
79     if ($hasNestedListWithoutLabel(this)) {
80       element.style.listStyle = 'none';
81     }
82
83     return element;
84   }
85
86   updateDOM(
87     prevNode: ListItemNode,
88     dom: HTMLElement,
89     config: EditorConfig,
90   ): boolean {
91     const parent = this.getParent();
92     if ($isListNode(parent) && parent.getListType() === 'check') {
93       updateListItemChecked(dom, this);
94     }
95
96     dom.style.listStyle = $hasNestedListWithoutLabel(this) ? 'none' : '';
97     // @ts-expect-error - this is always HTMLListItemElement
98     dom.value = this.__value;
99
100     return false;
101   }
102
103   static transform(): (node: LexicalNode) => void {
104     return (node: LexicalNode) => {
105       invariant($isListItemNode(node), 'node is not a ListItemNode');
106       if (node.__checked == null) {
107         return;
108       }
109       const parent = node.getParent();
110       if ($isListNode(parent)) {
111         if (parent.getListType() !== 'check' && node.getChecked() != null) {
112           node.setChecked(undefined);
113         }
114       }
115     };
116   }
117
118   static importDOM(): DOMConversionMap | null {
119     return {
120       li: () => ({
121         conversion: $convertListItemElement,
122         priority: 0,
123       }),
124     };
125   }
126
127   static importJSON(serializedNode: SerializedListItemNode): ListItemNode {
128     const node = $createListItemNode();
129     node.setChecked(serializedNode.checked);
130     node.setValue(serializedNode.value);
131     node.setDirection(serializedNode.direction);
132     return node;
133   }
134
135   exportDOM(editor: LexicalEditor): DOMExportOutput {
136     const element = this.createDOM(editor._config);
137
138     if (element.classList.contains('task-list-item')) {
139       const input = el('input', {
140         type: 'checkbox',
141         disabled: 'disabled',
142       });
143       if (element.hasAttribute('checked')) {
144         input.setAttribute('checked', 'checked');
145         element.removeAttribute('checked');
146       }
147
148       element.prepend(input);
149     }
150
151     return {
152       element,
153     };
154   }
155
156   exportJSON(): SerializedListItemNode {
157     return {
158       ...super.exportJSON(),
159       checked: this.getChecked(),
160       type: 'listitem',
161       value: this.getValue(),
162       version: 1,
163     };
164   }
165
166   append(...nodes: LexicalNode[]): this {
167     for (let i = 0; i < nodes.length; i++) {
168       const node = nodes[i];
169
170       if ($isElementNode(node) && this.canMergeWith(node)) {
171         const children = node.getChildren();
172         this.append(...children);
173         node.remove();
174       } else {
175         super.append(node);
176       }
177     }
178
179     return this;
180   }
181
182   replace<N extends LexicalNode>(
183     replaceWithNode: N,
184     includeChildren?: boolean,
185   ): N {
186     if ($isListItemNode(replaceWithNode)) {
187       return super.replace(replaceWithNode);
188     }
189     const list = this.getParentOrThrow();
190     if (!$isListNode(list)) {
191       return replaceWithNode;
192     }
193     if (list.__first === this.getKey()) {
194       list.insertBefore(replaceWithNode);
195     } else if (list.__last === this.getKey()) {
196       list.insertAfter(replaceWithNode);
197     } else {
198       // Split the list
199       const newList = $createListNode(list.getListType());
200       let nextSibling = this.getNextSibling();
201       while (nextSibling) {
202         const nodeToAppend = nextSibling;
203         nextSibling = nextSibling.getNextSibling();
204         newList.append(nodeToAppend);
205       }
206       list.insertAfter(replaceWithNode);
207       replaceWithNode.insertAfter(newList);
208     }
209     if (includeChildren) {
210       invariant(
211         $isElementNode(replaceWithNode),
212         'includeChildren should only be true for ElementNodes',
213       );
214       this.getChildren().forEach((child: LexicalNode) => {
215         replaceWithNode.append(child);
216       });
217     }
218     this.remove();
219     if (list.getChildrenSize() === 0) {
220       list.remove();
221     }
222     return replaceWithNode;
223   }
224
225   insertAfter(node: LexicalNode, restoreSelection = true): LexicalNode {
226     const listNode = this.getParentOrThrow();
227
228     if (!$isListNode(listNode)) {
229       invariant(
230         false,
231         'insertAfter: list node is not parent of list item node',
232       );
233     }
234
235     if ($isListItemNode(node)) {
236       return super.insertAfter(node, restoreSelection);
237     }
238
239     const siblings = this.getNextSiblings();
240
241     // Split the lists and insert the node in between them
242     listNode.insertAfter(node, restoreSelection);
243
244     if (siblings.length !== 0) {
245       const newListNode = $createListNode(listNode.getListType());
246
247       siblings.forEach((sibling) => newListNode.append(sibling));
248
249       node.insertAfter(newListNode, restoreSelection);
250     }
251
252     return node;
253   }
254
255   remove(preserveEmptyParent?: boolean): void {
256     const prevSibling = this.getPreviousSibling();
257     const nextSibling = this.getNextSibling();
258     super.remove(preserveEmptyParent);
259
260     if (
261       prevSibling &&
262       nextSibling &&
263       isNestedListNode(prevSibling) &&
264       isNestedListNode(nextSibling)
265     ) {
266       mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild());
267       nextSibling.remove();
268     }
269   }
270
271   insertNewAfter(
272     _: RangeSelection,
273     restoreSelection = true,
274   ): ListItemNode | ParagraphNode {
275
276     if (this.getTextContent().trim() === '' && this.isLastChild()) {
277       const list = this.getParentOrThrow<ListNode>();
278       if (!$isListItemNode(list.getParent())) {
279         const paragraph = $createParagraphNode();
280         list.insertAfter(paragraph, restoreSelection);
281         this.remove();
282         return paragraph;
283       }
284     }
285
286     const newElement = $createListItemNode(
287       this.__checked == null ? undefined : false,
288     );
289
290     this.insertAfter(newElement, restoreSelection);
291
292     return newElement;
293   }
294
295   collapseAtStart(selection: RangeSelection): true {
296     const paragraph = $createParagraphNode();
297     const children = this.getChildren();
298     children.forEach((child) => paragraph.append(child));
299     const listNode = this.getParentOrThrow();
300     const listNodeParent = listNode.getParentOrThrow();
301     const isIndented = $isListItemNode(listNodeParent);
302
303     if (listNode.getChildrenSize() === 1) {
304       if (isIndented) {
305         // if the list node is nested, we just want to remove it,
306         // effectively unindenting it.
307         listNode.remove();
308         listNodeParent.select();
309       } else {
310         listNode.insertBefore(paragraph);
311         listNode.remove();
312         // If we have selection on the list item, we'll need to move it
313         // to the paragraph
314         const anchor = selection.anchor;
315         const focus = selection.focus;
316         const key = paragraph.getKey();
317
318         if (anchor.type === 'element' && anchor.getNode().is(this)) {
319           anchor.set(key, anchor.offset, 'element');
320         }
321
322         if (focus.type === 'element' && focus.getNode().is(this)) {
323           focus.set(key, focus.offset, 'element');
324         }
325       }
326     } else {
327       listNode.insertBefore(paragraph);
328       this.remove();
329     }
330
331     return true;
332   }
333
334   getValue(): number {
335     const self = this.getLatest();
336
337     return self.__value;
338   }
339
340   setValue(value: number): void {
341     const self = this.getWritable();
342     self.__value = value;
343   }
344
345   getChecked(): boolean | undefined {
346     const self = this.getLatest();
347
348     let listType: ListType | undefined;
349
350     const parent = this.getParent();
351     if ($isListNode(parent)) {
352       listType = parent.getListType();
353     }
354
355     return listType === 'check' ? Boolean(self.__checked) : undefined;
356   }
357
358   setChecked(checked?: boolean): void {
359     const self = this.getWritable();
360     self.__checked = checked;
361   }
362
363   toggleChecked(): void {
364     this.setChecked(!this.__checked);
365   }
366
367   /** @deprecated @internal */
368   canInsertAfter(node: LexicalNode): boolean {
369     return $isListItemNode(node);
370   }
371
372   /** @deprecated @internal */
373   canReplaceWith(replacement: LexicalNode): boolean {
374     return $isListItemNode(replacement);
375   }
376
377   canMergeWith(node: LexicalNode): boolean {
378     return $isParagraphNode(node) || $isListItemNode(node);
379   }
380
381   extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
382     if (!$isRangeSelection(selection)) {
383       return false;
384     }
385
386     const anchorNode = selection.anchor.getNode();
387     const focusNode = selection.focus.getNode();
388
389     return (
390       this.isParentOf(anchorNode) &&
391       this.isParentOf(focusNode) &&
392       this.getTextContent().length === selection.getTextContent().length
393     );
394   }
395
396   isParentRequired(): true {
397     return true;
398   }
399
400   createParentElementNode(): ElementNode {
401     return $createListNode('bullet');
402   }
403
404   canMergeWhenEmpty(): true {
405     return true;
406   }
407 }
408
409 function $hasNestedListWithoutLabel(node: ListItemNode): boolean {
410   const children = node.getChildren();
411   let hasLabel = false;
412   let hasNestedList = false;
413
414   for (const child of children) {
415     if ($isListNode(child)) {
416       hasNestedList = true;
417     } else if (child.getTextContent().trim().length > 0) {
418       hasLabel = true;
419     }
420   }
421
422   return hasNestedList && !hasLabel;
423 }
424
425 function updateListItemChecked(
426   dom: HTMLElement,
427   listItemNode: ListItemNode,
428 ): void {
429   // Only set task list attrs for leaf list items
430   const shouldBeTaskItem = !$isListNode(listItemNode.getFirstChild());
431   dom.classList.toggle('task-list-item', shouldBeTaskItem);
432   if (listItemNode.__checked) {
433     dom.setAttribute('checked', 'checked');
434   } else {
435     dom.removeAttribute('checked');
436   }
437 }
438
439 function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
440   const isGitHubCheckList = domNode.classList.contains('task-list-item');
441   if (isGitHubCheckList) {
442     for (const child of domNode.children) {
443       if (child.tagName === 'INPUT') {
444         return $convertCheckboxInput(child);
445       }
446     }
447   }
448
449   const ariaCheckedAttr = domNode.getAttribute('aria-checked');
450   const checked =
451     ariaCheckedAttr === 'true'
452       ? true
453       : ariaCheckedAttr === 'false'
454       ? false
455       : undefined;
456   return {node: $createListItemNode(checked)};
457 }
458
459 function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
460   const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
461   if (!isCheckboxInput) {
462     return {node: null};
463   }
464   const checked = domNode.hasAttribute('checked');
465   return {node: $createListItemNode(checked)};
466 }
467
468 /**
469  * Creates a new List Item node, passing true/false will convert it to a checkbox input.
470  * @param checked - Is the List Item a checkbox and, if so, is it checked? undefined/null: not a checkbox, true/false is a checkbox and checked/unchecked, respectively.
471  * @returns The new List Item.
472  */
473 export function $createListItemNode(checked?: boolean): ListItemNode {
474   return $applyNodeReplacement(new ListItemNode(undefined, checked));
475 }
476
477 /**
478  * Checks to see if the node is a ListItemNode.
479  * @param node - The node to be checked.
480  * @returns true if the node is a ListItemNode, false otherwise.
481  */
482 export function $isListItemNode(
483   node: LexicalNode | null | undefined,
484 ): node is ListItemNode {
485   return node instanceof ListItemNode;
486 }