]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts
5026a01293ef26216b715a37a2b27160088a1f78
[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.setFormat(serializedNode.format);
130     node.setDirection(serializedNode.direction);
131     return node;
132   }
133
134   exportDOM(editor: LexicalEditor): DOMExportOutput {
135     const element = this.createDOM(editor._config);
136     element.style.textAlign = this.getFormatType();
137     return {
138       element,
139     };
140   }
141
142   exportJSON(): SerializedListItemNode {
143     return {
144       ...super.exportJSON(),
145       checked: this.getChecked(),
146       type: 'listitem',
147       value: this.getValue(),
148       version: 1,
149     };
150   }
151
152   append(...nodes: LexicalNode[]): this {
153     for (let i = 0; i < nodes.length; i++) {
154       const node = nodes[i];
155
156       if ($isElementNode(node) && this.canMergeWith(node)) {
157         const children = node.getChildren();
158         this.append(...children);
159         node.remove();
160       } else {
161         super.append(node);
162       }
163     }
164
165     return this;
166   }
167
168   replace<N extends LexicalNode>(
169     replaceWithNode: N,
170     includeChildren?: boolean,
171   ): N {
172     if ($isListItemNode(replaceWithNode)) {
173       return super.replace(replaceWithNode);
174     }
175     this.setIndent(0);
176     const list = this.getParentOrThrow();
177     if (!$isListNode(list)) {
178       return replaceWithNode;
179     }
180     if (list.__first === this.getKey()) {
181       list.insertBefore(replaceWithNode);
182     } else if (list.__last === this.getKey()) {
183       list.insertAfter(replaceWithNode);
184     } else {
185       // Split the list
186       const newList = $createListNode(list.getListType());
187       let nextSibling = this.getNextSibling();
188       while (nextSibling) {
189         const nodeToAppend = nextSibling;
190         nextSibling = nextSibling.getNextSibling();
191         newList.append(nodeToAppend);
192       }
193       list.insertAfter(replaceWithNode);
194       replaceWithNode.insertAfter(newList);
195     }
196     if (includeChildren) {
197       invariant(
198         $isElementNode(replaceWithNode),
199         'includeChildren should only be true for ElementNodes',
200       );
201       this.getChildren().forEach((child: LexicalNode) => {
202         replaceWithNode.append(child);
203       });
204     }
205     this.remove();
206     if (list.getChildrenSize() === 0) {
207       list.remove();
208     }
209     return replaceWithNode;
210   }
211
212   insertAfter(node: LexicalNode, restoreSelection = true): LexicalNode {
213     const listNode = this.getParentOrThrow();
214
215     if (!$isListNode(listNode)) {
216       invariant(
217         false,
218         'insertAfter: list node is not parent of list item node',
219       );
220     }
221
222     if ($isListItemNode(node)) {
223       return super.insertAfter(node, restoreSelection);
224     }
225
226     const siblings = this.getNextSiblings();
227
228     // Split the lists and insert the node in between them
229     listNode.insertAfter(node, restoreSelection);
230
231     if (siblings.length !== 0) {
232       const newListNode = $createListNode(listNode.getListType());
233
234       siblings.forEach((sibling) => newListNode.append(sibling));
235
236       node.insertAfter(newListNode, restoreSelection);
237     }
238
239     return node;
240   }
241
242   remove(preserveEmptyParent?: boolean): void {
243     const prevSibling = this.getPreviousSibling();
244     const nextSibling = this.getNextSibling();
245     super.remove(preserveEmptyParent);
246
247     if (
248       prevSibling &&
249       nextSibling &&
250       isNestedListNode(prevSibling) &&
251       isNestedListNode(nextSibling)
252     ) {
253       mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild());
254       nextSibling.remove();
255     }
256   }
257
258   insertNewAfter(
259     _: RangeSelection,
260     restoreSelection = true,
261   ): ListItemNode | ParagraphNode {
262
263     if (this.getTextContent().trim() === '' && this.isLastChild()) {
264       const list = this.getParentOrThrow<ListNode>();
265       if (!$isListItemNode(list.getParent())) {
266         const paragraph = $createParagraphNode();
267         list.insertAfter(paragraph, restoreSelection);
268         this.remove();
269         return paragraph;
270       }
271     }
272
273     const newElement = $createListItemNode(
274       this.__checked == null ? undefined : false,
275     );
276
277     this.insertAfter(newElement, restoreSelection);
278
279     return newElement;
280   }
281
282   collapseAtStart(selection: RangeSelection): true {
283     const paragraph = $createParagraphNode();
284     const children = this.getChildren();
285     children.forEach((child) => paragraph.append(child));
286     const listNode = this.getParentOrThrow();
287     const listNodeParent = listNode.getParentOrThrow();
288     const isIndented = $isListItemNode(listNodeParent);
289
290     if (listNode.getChildrenSize() === 1) {
291       if (isIndented) {
292         // if the list node is nested, we just want to remove it,
293         // effectively unindenting it.
294         listNode.remove();
295         listNodeParent.select();
296       } else {
297         listNode.insertBefore(paragraph);
298         listNode.remove();
299         // If we have selection on the list item, we'll need to move it
300         // to the paragraph
301         const anchor = selection.anchor;
302         const focus = selection.focus;
303         const key = paragraph.getKey();
304
305         if (anchor.type === 'element' && anchor.getNode().is(this)) {
306           anchor.set(key, anchor.offset, 'element');
307         }
308
309         if (focus.type === 'element' && focus.getNode().is(this)) {
310           focus.set(key, focus.offset, 'element');
311         }
312       }
313     } else {
314       listNode.insertBefore(paragraph);
315       this.remove();
316     }
317
318     return true;
319   }
320
321   getValue(): number {
322     const self = this.getLatest();
323
324     return self.__value;
325   }
326
327   setValue(value: number): void {
328     const self = this.getWritable();
329     self.__value = value;
330   }
331
332   getChecked(): boolean | undefined {
333     const self = this.getLatest();
334
335     let listType: ListType | undefined;
336
337     const parent = this.getParent();
338     if ($isListNode(parent)) {
339       listType = parent.getListType();
340     }
341
342     return listType === 'check' ? Boolean(self.__checked) : undefined;
343   }
344
345   setChecked(checked?: boolean): void {
346     const self = this.getWritable();
347     self.__checked = checked;
348   }
349
350   toggleChecked(): void {
351     this.setChecked(!this.__checked);
352   }
353
354   getIndent(): number {
355     // If we don't have a parent, we are likely serializing
356     const parent = this.getParent();
357     if (parent === null) {
358       return this.getLatest().__indent;
359     }
360     // ListItemNode should always have a ListNode for a parent.
361     let listNodeParent = parent.getParentOrThrow();
362     let indentLevel = 0;
363     while ($isListItemNode(listNodeParent)) {
364       listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow();
365       indentLevel++;
366     }
367
368     return indentLevel;
369   }
370
371   setIndent(indent: number): this {
372     invariant(typeof indent === 'number', 'Invalid indent value.');
373     indent = Math.floor(indent);
374     invariant(indent >= 0, 'Indent value must be non-negative.');
375     let currentIndent = this.getIndent();
376     while (currentIndent !== indent) {
377       if (currentIndent < indent) {
378         $handleIndent(this);
379         currentIndent++;
380       } else {
381         $handleOutdent(this);
382         currentIndent--;
383       }
384     }
385
386     return this;
387   }
388
389   /** @deprecated @internal */
390   canInsertAfter(node: LexicalNode): boolean {
391     return $isListItemNode(node);
392   }
393
394   /** @deprecated @internal */
395   canReplaceWith(replacement: LexicalNode): boolean {
396     return $isListItemNode(replacement);
397   }
398
399   canMergeWith(node: LexicalNode): boolean {
400     return $isParagraphNode(node) || $isListItemNode(node);
401   }
402
403   extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
404     if (!$isRangeSelection(selection)) {
405       return false;
406     }
407
408     const anchorNode = selection.anchor.getNode();
409     const focusNode = selection.focus.getNode();
410
411     return (
412       this.isParentOf(anchorNode) &&
413       this.isParentOf(focusNode) &&
414       this.getTextContent().length === selection.getTextContent().length
415     );
416   }
417
418   isParentRequired(): true {
419     return true;
420   }
421
422   createParentElementNode(): ElementNode {
423     return $createListNode('bullet');
424   }
425
426   canMergeWhenEmpty(): true {
427     return true;
428   }
429 }
430
431 function $setListItemThemeClassNames(
432   dom: HTMLElement,
433   editorThemeClasses: EditorThemeClasses,
434   node: ListItemNode,
435 ): void {
436   const classesToAdd = [];
437   const classesToRemove = [];
438   const listTheme = editorThemeClasses.list;
439   const listItemClassName = listTheme ? listTheme.listitem : undefined;
440   let nestedListItemClassName;
441
442   if (listTheme && listTheme.nested) {
443     nestedListItemClassName = listTheme.nested.listitem;
444   }
445
446   if (listItemClassName !== undefined) {
447     classesToAdd.push(...normalizeClassNames(listItemClassName));
448   }
449
450   if (listTheme) {
451     const parentNode = node.getParent();
452     const isCheckList =
453       $isListNode(parentNode) && parentNode.getListType() === 'check';
454     const checked = node.getChecked();
455
456     if (!isCheckList || checked) {
457       classesToRemove.push(listTheme.listitemUnchecked);
458     }
459
460     if (!isCheckList || !checked) {
461       classesToRemove.push(listTheme.listitemChecked);
462     }
463
464     if (isCheckList) {
465       classesToAdd.push(
466         checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
467       );
468     }
469   }
470
471   if (nestedListItemClassName !== undefined) {
472     const nestedListItemClasses = normalizeClassNames(nestedListItemClassName);
473
474     if (node.getChildren().some((child) => $isListNode(child))) {
475       classesToAdd.push(...nestedListItemClasses);
476     } else {
477       classesToRemove.push(...nestedListItemClasses);
478     }
479   }
480
481   if (classesToRemove.length > 0) {
482     removeClassNamesFromElement(dom, ...classesToRemove);
483   }
484
485   if (classesToAdd.length > 0) {
486     addClassNamesToElement(dom, ...classesToAdd);
487   }
488 }
489
490 function updateListItemChecked(
491   dom: HTMLElement,
492   listItemNode: ListItemNode,
493   prevListItemNode: ListItemNode | null,
494   listNode: ListNode,
495 ): void {
496   // Only add attributes for leaf list items
497   if ($isListNode(listItemNode.getFirstChild())) {
498     dom.removeAttribute('role');
499     dom.removeAttribute('tabIndex');
500     dom.removeAttribute('aria-checked');
501   } else {
502     dom.setAttribute('role', 'checkbox');
503     dom.setAttribute('tabIndex', '-1');
504
505     if (
506       !prevListItemNode ||
507       listItemNode.__checked !== prevListItemNode.__checked
508     ) {
509       dom.setAttribute(
510         'aria-checked',
511         listItemNode.getChecked() ? 'true' : 'false',
512       );
513     }
514   }
515 }
516
517 function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
518   const isGitHubCheckList = domNode.classList.contains('task-list-item');
519   if (isGitHubCheckList) {
520     for (const child of domNode.children) {
521       if (child.tagName === 'INPUT') {
522         return $convertCheckboxInput(child);
523       }
524     }
525   }
526
527   const ariaCheckedAttr = domNode.getAttribute('aria-checked');
528   const checked =
529     ariaCheckedAttr === 'true'
530       ? true
531       : ariaCheckedAttr === 'false'
532       ? false
533       : undefined;
534   return {node: $createListItemNode(checked)};
535 }
536
537 function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
538   const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
539   if (!isCheckboxInput) {
540     return {node: null};
541   }
542   const checked = domNode.hasAttribute('checked');
543   return {node: $createListItemNode(checked)};
544 }
545
546 /**
547  * Creates a new List Item node, passing true/false will convert it to a checkbox input.
548  * @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.
549  * @returns The new List Item.
550  */
551 export function $createListItemNode(checked?: boolean): ListItemNode {
552   return $applyNodeReplacement(new ListItemNode(undefined, checked));
553 }
554
555 /**
556  * Checks to see if the node is a ListItemNode.
557  * @param node - The node to be checked.
558  * @returns true if the node is a ListItemNode, false otherwise.
559  */
560 export function $isListItemNode(
561   node: LexicalNode | null | undefined,
562 ): node is ListItemNode {
563   return node instanceof ListItemNode;
564 }