]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/list/LexicalListNode.ts
e22fbf7717f3570de675d98a867c1fb8d4fac458
[bookstack] / resources / js / wysiwyg / lexical / list / LexicalListNode.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 {
10   addClassNamesToElement,
11   isHTMLElement,
12   removeClassNamesFromElement,
13 } from '@lexical/utils';
14 import {
15   $applyNodeReplacement,
16   $createTextNode,
17   $isElementNode,
18   DOMConversionMap,
19   DOMConversionOutput,
20   DOMExportOutput,
21   EditorConfig,
22   EditorThemeClasses,
23   ElementNode,
24   LexicalEditor,
25   LexicalNode,
26   NodeKey,
27   SerializedElementNode,
28   Spread,
29 } from 'lexical';
30 import invariant from 'lexical/shared/invariant';
31 import normalizeClassNames from 'lexical/shared/normalizeClassNames';
32
33 import {$createListItemNode, $isListItemNode, ListItemNode} from '.';
34 import {
35   mergeNextSiblingListIfSameType,
36   updateChildrenListItemValue,
37 } from './formatList';
38 import {$getListDepth, $wrapInListItem} from './utils';
39
40 export type SerializedListNode = Spread<
41   {
42     listType: ListType;
43     start: number;
44     tag: ListNodeTagType;
45   },
46   SerializedElementNode
47 >;
48
49 export type ListType = 'number' | 'bullet' | 'check';
50
51 export type ListNodeTagType = 'ul' | 'ol';
52
53 /** @noInheritDoc */
54 export class ListNode extends ElementNode {
55   /** @internal */
56   __tag: ListNodeTagType;
57   /** @internal */
58   __start: number;
59   /** @internal */
60   __listType: ListType;
61
62   static getType(): string {
63     return 'list';
64   }
65
66   static clone(node: ListNode): ListNode {
67     const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag];
68
69     return new ListNode(listType, node.__start, node.__key);
70   }
71
72   constructor(listType: ListType, start: number, key?: NodeKey) {
73     super(key);
74     const _listType = TAG_TO_LIST_TYPE[listType] || listType;
75     this.__listType = _listType;
76     this.__tag = _listType === 'number' ? 'ol' : 'ul';
77     this.__start = start;
78   }
79
80   getTag(): ListNodeTagType {
81     return this.__tag;
82   }
83
84   setListType(type: ListType): void {
85     const writable = this.getWritable();
86     writable.__listType = type;
87     writable.__tag = type === 'number' ? 'ol' : 'ul';
88   }
89
90   getListType(): ListType {
91     return this.__listType;
92   }
93
94   getStart(): number {
95     return this.__start;
96   }
97
98   // View
99
100   createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement {
101     const tag = this.__tag;
102     const dom = document.createElement(tag);
103
104     if (this.__start !== 1) {
105       dom.setAttribute('start', String(this.__start));
106     }
107     // @ts-expect-error Internal field.
108     dom.__lexicalListType = this.__listType;
109     $setListThemeClassNames(dom, config.theme, this);
110
111     return dom;
112   }
113
114   updateDOM(
115     prevNode: ListNode,
116     dom: HTMLElement,
117     config: EditorConfig,
118   ): boolean {
119     if (prevNode.__tag !== this.__tag) {
120       return true;
121     }
122
123     $setListThemeClassNames(dom, config.theme, this);
124
125     return false;
126   }
127
128   static transform(): (node: LexicalNode) => void {
129     return (node: LexicalNode) => {
130       invariant($isListNode(node), 'node is not a ListNode');
131       mergeNextSiblingListIfSameType(node);
132       updateChildrenListItemValue(node);
133     };
134   }
135
136   static importDOM(): DOMConversionMap | null {
137     return {
138       ol: () => ({
139         conversion: $convertListNode,
140         priority: 0,
141       }),
142       ul: () => ({
143         conversion: $convertListNode,
144         priority: 0,
145       }),
146     };
147   }
148
149   static importJSON(serializedNode: SerializedListNode): ListNode {
150     const node = $createListNode(serializedNode.listType, serializedNode.start);
151     node.setFormat(serializedNode.format);
152     node.setIndent(serializedNode.indent);
153     node.setDirection(serializedNode.direction);
154     return node;
155   }
156
157   exportDOM(editor: LexicalEditor): DOMExportOutput {
158     const {element} = super.exportDOM(editor);
159     if (element && isHTMLElement(element)) {
160       if (this.__start !== 1) {
161         element.setAttribute('start', String(this.__start));
162       }
163       if (this.__listType === 'check') {
164         element.setAttribute('__lexicalListType', 'check');
165       }
166     }
167     return {
168       element,
169     };
170   }
171
172   exportJSON(): SerializedListNode {
173     return {
174       ...super.exportJSON(),
175       listType: this.getListType(),
176       start: this.getStart(),
177       tag: this.getTag(),
178       type: 'list',
179       version: 1,
180     };
181   }
182
183   canBeEmpty(): false {
184     return false;
185   }
186
187   canIndent(): false {
188     return false;
189   }
190
191   append(...nodesToAppend: LexicalNode[]): this {
192     for (let i = 0; i < nodesToAppend.length; i++) {
193       const currentNode = nodesToAppend[i];
194
195       if ($isListItemNode(currentNode)) {
196         super.append(currentNode);
197       } else {
198         const listItemNode = $createListItemNode();
199
200         if ($isListNode(currentNode)) {
201           listItemNode.append(currentNode);
202         } else if ($isElementNode(currentNode)) {
203           const textNode = $createTextNode(currentNode.getTextContent());
204           listItemNode.append(textNode);
205         } else {
206           listItemNode.append(currentNode);
207         }
208         super.append(listItemNode);
209       }
210     }
211     return this;
212   }
213
214   extractWithChild(child: LexicalNode): boolean {
215     return $isListItemNode(child);
216   }
217 }
218
219 function $setListThemeClassNames(
220   dom: HTMLElement,
221   editorThemeClasses: EditorThemeClasses,
222   node: ListNode,
223 ): void {
224   const classesToAdd = [];
225   const classesToRemove = [];
226   const listTheme = editorThemeClasses.list;
227
228   if (listTheme !== undefined) {
229     const listLevelsClassNames = listTheme[`${node.__tag}Depth`] || [];
230     const listDepth = $getListDepth(node) - 1;
231     const normalizedListDepth = listDepth % listLevelsClassNames.length;
232     const listLevelClassName = listLevelsClassNames[normalizedListDepth];
233     const listClassName = listTheme[node.__tag];
234     let nestedListClassName;
235     const nestedListTheme = listTheme.nested;
236     const checklistClassName = listTheme.checklist;
237
238     if (nestedListTheme !== undefined && nestedListTheme.list) {
239       nestedListClassName = nestedListTheme.list;
240     }
241
242     if (listClassName !== undefined) {
243       classesToAdd.push(listClassName);
244     }
245
246     if (checklistClassName !== undefined && node.__listType === 'check') {
247       classesToAdd.push(checklistClassName);
248     }
249
250     if (listLevelClassName !== undefined) {
251       classesToAdd.push(...normalizeClassNames(listLevelClassName));
252       for (let i = 0; i < listLevelsClassNames.length; i++) {
253         if (i !== normalizedListDepth) {
254           classesToRemove.push(node.__tag + i);
255         }
256       }
257     }
258
259     if (nestedListClassName !== undefined) {
260       const nestedListItemClasses = normalizeClassNames(nestedListClassName);
261
262       if (listDepth > 1) {
263         classesToAdd.push(...nestedListItemClasses);
264       } else {
265         classesToRemove.push(...nestedListItemClasses);
266       }
267     }
268   }
269
270   if (classesToRemove.length > 0) {
271     removeClassNamesFromElement(dom, ...classesToRemove);
272   }
273
274   if (classesToAdd.length > 0) {
275     addClassNamesToElement(dom, ...classesToAdd);
276   }
277 }
278
279 /*
280  * This function normalizes the children of a ListNode after the conversion from HTML,
281  * ensuring that they are all ListItemNodes and contain either a single nested ListNode
282  * or some other inline content.
283  */
284 function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
285   const normalizedListItems: Array<ListItemNode> = [];
286   for (let i = 0; i < nodes.length; i++) {
287     const node = nodes[i];
288     if ($isListItemNode(node)) {
289       normalizedListItems.push(node);
290       const children = node.getChildren();
291       if (children.length > 1) {
292         children.forEach((child) => {
293           if ($isListNode(child)) {
294             normalizedListItems.push($wrapInListItem(child));
295           }
296         });
297       }
298     } else {
299       normalizedListItems.push($wrapInListItem(node));
300     }
301   }
302   return normalizedListItems;
303 }
304
305 function isDomChecklist(domNode: HTMLElement) {
306   if (
307     domNode.getAttribute('__lexicallisttype') === 'check' ||
308     // is github checklist
309     domNode.classList.contains('contains-task-list')
310   ) {
311     return true;
312   }
313   // if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting.
314   for (const child of domNode.childNodes) {
315     if (isHTMLElement(child) && child.hasAttribute('aria-checked')) {
316       return true;
317     }
318   }
319   return false;
320 }
321
322 function $convertListNode(domNode: HTMLElement): DOMConversionOutput {
323   const nodeName = domNode.nodeName.toLowerCase();
324   let node = null;
325   if (nodeName === 'ol') {
326     // @ts-ignore
327     const start = domNode.start;
328     node = $createListNode('number', start);
329   } else if (nodeName === 'ul') {
330     if (isDomChecklist(domNode)) {
331       node = $createListNode('check');
332     } else {
333       node = $createListNode('bullet');
334     }
335   }
336
337   return {
338     after: $normalizeChildren,
339     node,
340   };
341 }
342
343 const TAG_TO_LIST_TYPE: Record<string, ListType> = {
344   ol: 'number',
345   ul: 'bullet',
346 };
347
348 /**
349  * Creates a ListNode of listType.
350  * @param listType - The type of list to be created. Can be 'number', 'bullet', or 'check'.
351  * @param start - Where an ordered list starts its count, start = 1 if left undefined.
352  * @returns The new ListNode
353  */
354 export function $createListNode(listType: ListType, start = 1): ListNode {
355   return $applyNodeReplacement(new ListNode(listType, start));
356 }
357
358 /**
359  * Checks to see if the node is a ListNode.
360  * @param node - The node to be checked.
361  * @returns true if the node is a ListNode, false otherwise.
362  */
363 export function $isListNode(
364   node: LexicalNode | null | undefined,
365 ): node is ListNode {
366   return node instanceof ListNode;
367 }