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