]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts
Opensearch: Fixed XML declaration when php short tags enabled
[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 | null {
275
276     if (this.getTextContent().trim() === '' && this.isLastChild()) {
277       const list = this.getParentOrThrow<ListNode>();
278       const parentListItem = list.getParent();
279       if ($isListItemNode(parentListItem)) {
280         // Un-nest list item if empty nested item
281         parentListItem.insertAfter(this);
282         this.selectStart();
283         return null;
284       } else {
285         // Insert empty paragraph after list if adding after last empty child
286         const paragraph = $createParagraphNode();
287         list.insertAfter(paragraph, restoreSelection);
288         this.remove();
289         return paragraph;
290       }
291     }
292
293     const newElement = $createListItemNode(
294       this.__checked == null ? undefined : false,
295     );
296
297     this.insertAfter(newElement, restoreSelection);
298
299     return newElement;
300   }
301
302   collapseAtStart(selection: RangeSelection): true {
303     const paragraph = $createParagraphNode();
304     const children = this.getChildren();
305     children.forEach((child) => paragraph.append(child));
306     const listNode = this.getParentOrThrow();
307     const listNodeParent = listNode.getParentOrThrow();
308     const isIndented = $isListItemNode(listNodeParent);
309
310     if (listNode.getChildrenSize() === 1) {
311       if (isIndented) {
312         // if the list node is nested, we just want to remove it,
313         // effectively unindenting it.
314         listNode.remove();
315         listNodeParent.select();
316       } else {
317         listNode.insertBefore(paragraph);
318         listNode.remove();
319         // If we have selection on the list item, we'll need to move it
320         // to the paragraph
321         const anchor = selection.anchor;
322         const focus = selection.focus;
323         const key = paragraph.getKey();
324
325         if (anchor.type === 'element' && anchor.getNode().is(this)) {
326           anchor.set(key, anchor.offset, 'element');
327         }
328
329         if (focus.type === 'element' && focus.getNode().is(this)) {
330           focus.set(key, focus.offset, 'element');
331         }
332       }
333     } else {
334       listNode.insertBefore(paragraph);
335       this.remove();
336     }
337
338     return true;
339   }
340
341   getValue(): number {
342     const self = this.getLatest();
343
344     return self.__value;
345   }
346
347   setValue(value: number): void {
348     const self = this.getWritable();
349     self.__value = value;
350   }
351
352   getChecked(): boolean | undefined {
353     const self = this.getLatest();
354
355     let listType: ListType | undefined;
356
357     const parent = this.getParent();
358     if ($isListNode(parent)) {
359       listType = parent.getListType();
360     }
361
362     return listType === 'check' ? Boolean(self.__checked) : undefined;
363   }
364
365   setChecked(checked?: boolean): void {
366     const self = this.getWritable();
367     self.__checked = checked;
368   }
369
370   toggleChecked(): void {
371     this.setChecked(!this.__checked);
372   }
373
374   /** @deprecated @internal */
375   canInsertAfter(node: LexicalNode): boolean {
376     return $isListItemNode(node);
377   }
378
379   /** @deprecated @internal */
380   canReplaceWith(replacement: LexicalNode): boolean {
381     return $isListItemNode(replacement);
382   }
383
384   canMergeWith(node: LexicalNode): boolean {
385     return $isParagraphNode(node) || $isListItemNode(node);
386   }
387
388   extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
389     if (!$isRangeSelection(selection)) {
390       return false;
391     }
392
393     const anchorNode = selection.anchor.getNode();
394     const focusNode = selection.focus.getNode();
395
396     return (
397       this.isParentOf(anchorNode) &&
398       this.isParentOf(focusNode) &&
399       this.getTextContent().length === selection.getTextContent().length
400     );
401   }
402
403   isParentRequired(): true {
404     return true;
405   }
406
407   createParentElementNode(): ElementNode {
408     return $createListNode('bullet');
409   }
410
411   canMergeWhenEmpty(): true {
412     return true;
413   }
414 }
415
416 function $hasNestedListWithoutLabel(node: ListItemNode): boolean {
417   const children = node.getChildren();
418   let hasLabel = false;
419   let hasNestedList = false;
420
421   for (const child of children) {
422     if ($isListNode(child)) {
423       hasNestedList = true;
424     } else if (child.getTextContent().trim().length > 0) {
425       hasLabel = true;
426     }
427   }
428
429   return hasNestedList && !hasLabel;
430 }
431
432 function updateListItemChecked(
433   dom: HTMLElement,
434   listItemNode: ListItemNode,
435 ): void {
436   // Only set task list attrs for leaf list items
437   const shouldBeTaskItem = !$isListNode(listItemNode.getFirstChild());
438   dom.classList.toggle('task-list-item', shouldBeTaskItem);
439   if (listItemNode.__checked) {
440     dom.setAttribute('checked', 'checked');
441   } else {
442     dom.removeAttribute('checked');
443   }
444 }
445
446 function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
447   const isGitHubCheckList = domNode.classList.contains('task-list-item');
448   if (isGitHubCheckList) {
449     for (const child of domNode.children) {
450       if (child.tagName === 'INPUT') {
451         return $convertCheckboxInput(child);
452       }
453     }
454   }
455
456   const ariaCheckedAttr = domNode.getAttribute('aria-checked');
457   const checked =
458     ariaCheckedAttr === 'true'
459       ? true
460       : ariaCheckedAttr === 'false'
461       ? false
462       : undefined;
463   return {node: $createListItemNode(checked)};
464 }
465
466 function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
467   const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
468   if (!isCheckboxInput) {
469     return {node: null};
470   }
471   const checked = domNode.hasAttribute('checked');
472   return {node: $createListItemNode(checked)};
473 }
474
475 /**
476  * Creates a new List Item node, passing true/false will convert it to a checkbox input.
477  * @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.
478  * @returns The new List Item.
479  */
480 export function $createListItemNode(checked?: boolean): ListItemNode {
481   return $applyNodeReplacement(new ListItemNode(undefined, checked));
482 }
483
484 /**
485  * Checks to see if the node is a ListItemNode.
486  * @param node - The node to be checked.
487  * @returns true if the node is a ListItemNode, false otherwise.
488  */
489 export function $isListItemNode(
490   node: LexicalNode | null | undefined,
491 ): node is ListItemNode {
492   return node instanceof ListItemNode;
493 }