]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts
Lexical: Imported core lexical libs
[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     const newElement = $createListItemNode(
263       this.__checked == null ? undefined : false,
264     );
265     this.insertAfter(newElement, restoreSelection);
266
267     return newElement;
268   }
269
270   collapseAtStart(selection: RangeSelection): true {
271     const paragraph = $createParagraphNode();
272     const children = this.getChildren();
273     children.forEach((child) => paragraph.append(child));
274     const listNode = this.getParentOrThrow();
275     const listNodeParent = listNode.getParentOrThrow();
276     const isIndented = $isListItemNode(listNodeParent);
277
278     if (listNode.getChildrenSize() === 1) {
279       if (isIndented) {
280         // if the list node is nested, we just want to remove it,
281         // effectively unindenting it.
282         listNode.remove();
283         listNodeParent.select();
284       } else {
285         listNode.insertBefore(paragraph);
286         listNode.remove();
287         // If we have selection on the list item, we'll need to move it
288         // to the paragraph
289         const anchor = selection.anchor;
290         const focus = selection.focus;
291         const key = paragraph.getKey();
292
293         if (anchor.type === 'element' && anchor.getNode().is(this)) {
294           anchor.set(key, anchor.offset, 'element');
295         }
296
297         if (focus.type === 'element' && focus.getNode().is(this)) {
298           focus.set(key, focus.offset, 'element');
299         }
300       }
301     } else {
302       listNode.insertBefore(paragraph);
303       this.remove();
304     }
305
306     return true;
307   }
308
309   getValue(): number {
310     const self = this.getLatest();
311
312     return self.__value;
313   }
314
315   setValue(value: number): void {
316     const self = this.getWritable();
317     self.__value = value;
318   }
319
320   getChecked(): boolean | undefined {
321     const self = this.getLatest();
322
323     let listType: ListType | undefined;
324
325     const parent = this.getParent();
326     if ($isListNode(parent)) {
327       listType = parent.getListType();
328     }
329
330     return listType === 'check' ? Boolean(self.__checked) : undefined;
331   }
332
333   setChecked(checked?: boolean): void {
334     const self = this.getWritable();
335     self.__checked = checked;
336   }
337
338   toggleChecked(): void {
339     this.setChecked(!this.__checked);
340   }
341
342   getIndent(): number {
343     // If we don't have a parent, we are likely serializing
344     const parent = this.getParent();
345     if (parent === null) {
346       return this.getLatest().__indent;
347     }
348     // ListItemNode should always have a ListNode for a parent.
349     let listNodeParent = parent.getParentOrThrow();
350     let indentLevel = 0;
351     while ($isListItemNode(listNodeParent)) {
352       listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow();
353       indentLevel++;
354     }
355
356     return indentLevel;
357   }
358
359   setIndent(indent: number): this {
360     invariant(typeof indent === 'number', 'Invalid indent value.');
361     indent = Math.floor(indent);
362     invariant(indent >= 0, 'Indent value must be non-negative.');
363     let currentIndent = this.getIndent();
364     while (currentIndent !== indent) {
365       if (currentIndent < indent) {
366         $handleIndent(this);
367         currentIndent++;
368       } else {
369         $handleOutdent(this);
370         currentIndent--;
371       }
372     }
373
374     return this;
375   }
376
377   /** @deprecated @internal */
378   canInsertAfter(node: LexicalNode): boolean {
379     return $isListItemNode(node);
380   }
381
382   /** @deprecated @internal */
383   canReplaceWith(replacement: LexicalNode): boolean {
384     return $isListItemNode(replacement);
385   }
386
387   canMergeWith(node: LexicalNode): boolean {
388     return $isParagraphNode(node) || $isListItemNode(node);
389   }
390
391   extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
392     if (!$isRangeSelection(selection)) {
393       return false;
394     }
395
396     const anchorNode = selection.anchor.getNode();
397     const focusNode = selection.focus.getNode();
398
399     return (
400       this.isParentOf(anchorNode) &&
401       this.isParentOf(focusNode) &&
402       this.getTextContent().length === selection.getTextContent().length
403     );
404   }
405
406   isParentRequired(): true {
407     return true;
408   }
409
410   createParentElementNode(): ElementNode {
411     return $createListNode('bullet');
412   }
413
414   canMergeWhenEmpty(): true {
415     return true;
416   }
417 }
418
419 function $setListItemThemeClassNames(
420   dom: HTMLElement,
421   editorThemeClasses: EditorThemeClasses,
422   node: ListItemNode,
423 ): void {
424   const classesToAdd = [];
425   const classesToRemove = [];
426   const listTheme = editorThemeClasses.list;
427   const listItemClassName = listTheme ? listTheme.listitem : undefined;
428   let nestedListItemClassName;
429
430   if (listTheme && listTheme.nested) {
431     nestedListItemClassName = listTheme.nested.listitem;
432   }
433
434   if (listItemClassName !== undefined) {
435     classesToAdd.push(...normalizeClassNames(listItemClassName));
436   }
437
438   if (listTheme) {
439     const parentNode = node.getParent();
440     const isCheckList =
441       $isListNode(parentNode) && parentNode.getListType() === 'check';
442     const checked = node.getChecked();
443
444     if (!isCheckList || checked) {
445       classesToRemove.push(listTheme.listitemUnchecked);
446     }
447
448     if (!isCheckList || !checked) {
449       classesToRemove.push(listTheme.listitemChecked);
450     }
451
452     if (isCheckList) {
453       classesToAdd.push(
454         checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
455       );
456     }
457   }
458
459   if (nestedListItemClassName !== undefined) {
460     const nestedListItemClasses = normalizeClassNames(nestedListItemClassName);
461
462     if (node.getChildren().some((child) => $isListNode(child))) {
463       classesToAdd.push(...nestedListItemClasses);
464     } else {
465       classesToRemove.push(...nestedListItemClasses);
466     }
467   }
468
469   if (classesToRemove.length > 0) {
470     removeClassNamesFromElement(dom, ...classesToRemove);
471   }
472
473   if (classesToAdd.length > 0) {
474     addClassNamesToElement(dom, ...classesToAdd);
475   }
476 }
477
478 function updateListItemChecked(
479   dom: HTMLElement,
480   listItemNode: ListItemNode,
481   prevListItemNode: ListItemNode | null,
482   listNode: ListNode,
483 ): void {
484   // Only add attributes for leaf list items
485   if ($isListNode(listItemNode.getFirstChild())) {
486     dom.removeAttribute('role');
487     dom.removeAttribute('tabIndex');
488     dom.removeAttribute('aria-checked');
489   } else {
490     dom.setAttribute('role', 'checkbox');
491     dom.setAttribute('tabIndex', '-1');
492
493     if (
494       !prevListItemNode ||
495       listItemNode.__checked !== prevListItemNode.__checked
496     ) {
497       dom.setAttribute(
498         'aria-checked',
499         listItemNode.getChecked() ? 'true' : 'false',
500       );
501     }
502   }
503 }
504
505 function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
506   const isGitHubCheckList = domNode.classList.contains('task-list-item');
507   if (isGitHubCheckList) {
508     for (const child of domNode.children) {
509       if (child.tagName === 'INPUT') {
510         return $convertCheckboxInput(child);
511       }
512     }
513   }
514
515   const ariaCheckedAttr = domNode.getAttribute('aria-checked');
516   const checked =
517     ariaCheckedAttr === 'true'
518       ? true
519       : ariaCheckedAttr === 'false'
520       ? false
521       : undefined;
522   return {node: $createListItemNode(checked)};
523 }
524
525 function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
526   const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
527   if (!isCheckboxInput) {
528     return {node: null};
529   }
530   const checked = domNode.hasAttribute('checked');
531   return {node: $createListItemNode(checked)};
532 }
533
534 /**
535  * Creates a new List Item node, passing true/false will convert it to a checkbox input.
536  * @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.
537  * @returns The new List Item.
538  */
539 export function $createListItemNode(checked?: boolean): ListItemNode {
540   return $applyNodeReplacement(new ListItemNode(undefined, checked));
541 }
542
543 /**
544  * Checks to see if the node is a ListItemNode.
545  * @param node - The node to be checked.
546  * @returns true if the node is a ListItemNode, false otherwise.
547  */
548 export function $isListItemNode(
549   node: LexicalNode | null | undefined,
550 ): node is ListItemNode {
551   return node instanceof ListItemNode;
552 }