]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/list/LexicalListNode.ts
Opensearch: Fixed XML declaration when php short tags enabled
[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 import {extractDirectionFromElement} from "lexical/nodes/common";
40
41 export type SerializedListNode = Spread<
42   {
43     id: string;
44     listType: ListType;
45     start: number;
46     tag: ListNodeTagType;
47   },
48   SerializedElementNode
49 >;
50
51 export type ListType = 'number' | 'bullet' | 'check';
52
53 export type ListNodeTagType = 'ul' | 'ol';
54
55 /** @noInheritDoc */
56 export class ListNode extends ElementNode {
57   /** @internal */
58   __tag: ListNodeTagType;
59   /** @internal */
60   __start: number;
61   /** @internal */
62   __listType: ListType;
63   /** @internal */
64   __id: string = '';
65
66   static getType(): string {
67     return 'list';
68   }
69
70   static clone(node: ListNode): ListNode {
71     const newNode = new ListNode(node.__listType, node.__start, node.__key);
72     newNode.__id = node.__id;
73     newNode.__dir = node.__dir;
74     return newNode;
75   }
76
77   constructor(listType: ListType, start: number, key?: NodeKey) {
78     super(key);
79     const _listType = TAG_TO_LIST_TYPE[listType] || listType;
80     this.__listType = _listType;
81     this.__tag = _listType === 'number' ? 'ol' : 'ul';
82     this.__start = start;
83   }
84
85   getTag(): ListNodeTagType {
86     return this.__tag;
87   }
88
89   setId(id: string) {
90     const self = this.getWritable();
91     self.__id = id;
92   }
93
94   getId(): string {
95     const self = this.getLatest();
96     return self.__id;
97   }
98
99   setListType(type: ListType): void {
100     const writable = this.getWritable();
101     writable.__listType = type;
102     writable.__tag = type === 'number' ? 'ol' : 'ul';
103   }
104
105   getListType(): ListType {
106     return this.__listType;
107   }
108
109   getStart(): number {
110     return this.__start;
111   }
112
113   // View
114
115   createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement {
116     const tag = this.__tag;
117     const dom = document.createElement(tag);
118
119     if (this.__start !== 1) {
120       dom.setAttribute('start', String(this.__start));
121     }
122     // @ts-expect-error Internal field.
123     dom.__lexicalListType = this.__listType;
124     $setListThemeClassNames(dom, config.theme, this);
125
126     if (this.__id) {
127       dom.setAttribute('id', this.__id);
128     }
129
130     if (this.__dir) {
131       dom.setAttribute('dir', this.__dir);
132     }
133
134     return dom;
135   }
136
137   updateDOM(
138     prevNode: ListNode,
139     dom: HTMLElement,
140     config: EditorConfig,
141   ): boolean {
142     if (
143         prevNode.__tag !== this.__tag
144         || prevNode.__dir !== this.__dir
145         || prevNode.__id !== this.__id
146     ) {
147       return true;
148     }
149
150     $setListThemeClassNames(dom, config.theme, this);
151
152     return false;
153   }
154
155   static transform(): (node: LexicalNode) => void {
156     return (node: LexicalNode) => {
157       invariant($isListNode(node), 'node is not a ListNode');
158       mergeNextSiblingListIfSameType(node);
159       updateChildrenListItemValue(node);
160     };
161   }
162
163   static importDOM(): DOMConversionMap | null {
164     return {
165       ol: () => ({
166         conversion: $convertListNode,
167         priority: 0,
168       }),
169       ul: () => ({
170         conversion: $convertListNode,
171         priority: 0,
172       }),
173     };
174   }
175
176   static importJSON(serializedNode: SerializedListNode): ListNode {
177     const node = $createListNode(serializedNode.listType, serializedNode.start);
178     node.setId(serializedNode.id);
179     node.setDirection(serializedNode.direction);
180     return node;
181   }
182
183   exportDOM(editor: LexicalEditor): DOMExportOutput {
184     const {element} = super.exportDOM(editor);
185     if (element && isHTMLElement(element)) {
186       if (this.__start !== 1) {
187         element.setAttribute('start', String(this.__start));
188       }
189       if (this.__listType === 'check') {
190         element.setAttribute('__lexicalListType', 'check');
191       }
192     }
193     return {
194       element,
195     };
196   }
197
198   exportJSON(): SerializedListNode {
199     return {
200       ...super.exportJSON(),
201       listType: this.getListType(),
202       start: this.getStart(),
203       tag: this.getTag(),
204       type: 'list',
205       version: 1,
206       id: this.__id,
207     };
208   }
209
210   canBeEmpty(): false {
211     return false;
212   }
213
214   canIndent(): false {
215     return false;
216   }
217
218   append(...nodesToAppend: LexicalNode[]): this {
219     for (let i = 0; i < nodesToAppend.length; i++) {
220       const currentNode = nodesToAppend[i];
221
222       if ($isListItemNode(currentNode)) {
223         super.append(currentNode);
224       } else {
225         const listItemNode = $createListItemNode();
226
227         if ($isListNode(currentNode)) {
228           listItemNode.append(currentNode);
229         } else if ($isElementNode(currentNode)) {
230           const textNode = $createTextNode(currentNode.getTextContent());
231           listItemNode.append(textNode);
232         } else {
233           listItemNode.append(currentNode);
234         }
235         super.append(listItemNode);
236       }
237     }
238     return this;
239   }
240
241   extractWithChild(child: LexicalNode): boolean {
242     return $isListItemNode(child);
243   }
244 }
245
246 function $setListThemeClassNames(
247   dom: HTMLElement,
248   editorThemeClasses: EditorThemeClasses,
249   node: ListNode,
250 ): void {
251   const classesToAdd = [];
252   const classesToRemove = [];
253   const listTheme = editorThemeClasses.list;
254
255   if (listTheme !== undefined) {
256     const listLevelsClassNames = listTheme[`${node.__tag}Depth`] || [];
257     const listDepth = $getListDepth(node) - 1;
258     const normalizedListDepth = listDepth % listLevelsClassNames.length;
259     const listLevelClassName = listLevelsClassNames[normalizedListDepth];
260     const listClassName = listTheme[node.__tag];
261     let nestedListClassName;
262     const nestedListTheme = listTheme.nested;
263     const checklistClassName = listTheme.checklist;
264
265     if (nestedListTheme !== undefined && nestedListTheme.list) {
266       nestedListClassName = nestedListTheme.list;
267     }
268
269     if (listClassName !== undefined) {
270       classesToAdd.push(listClassName);
271     }
272
273     if (checklistClassName !== undefined && node.__listType === 'check') {
274       classesToAdd.push(checklistClassName);
275     }
276
277     if (listLevelClassName !== undefined) {
278       classesToAdd.push(...normalizeClassNames(listLevelClassName));
279       for (let i = 0; i < listLevelsClassNames.length; i++) {
280         if (i !== normalizedListDepth) {
281           classesToRemove.push(node.__tag + i);
282         }
283       }
284     }
285
286     if (nestedListClassName !== undefined) {
287       const nestedListItemClasses = normalizeClassNames(nestedListClassName);
288
289       if (listDepth > 1) {
290         classesToAdd.push(...nestedListItemClasses);
291       } else {
292         classesToRemove.push(...nestedListItemClasses);
293       }
294     }
295   }
296
297   if (classesToRemove.length > 0) {
298     removeClassNamesFromElement(dom, ...classesToRemove);
299   }
300
301   if (classesToAdd.length > 0) {
302     addClassNamesToElement(dom, ...classesToAdd);
303   }
304 }
305
306 /*
307  * This function is a custom normalization function to allow nested lists within list item elements.
308  * Original taken from https://github.com/facebook/lexical/blob/6e10210fd1e113ccfafdc999b1d896733c5c5bea/packages/lexical-list/src/LexicalListNode.ts#L284-L303
309  * With modifications made.
310  */
311 function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
312   const normalizedListItems: Array<ListItemNode> = [];
313
314   for (const node of nodes) {
315     if ($isListItemNode(node)) {
316       normalizedListItems.push(node);
317     } else {
318       normalizedListItems.push($wrapInListItem(node));
319     }
320   }
321
322   return normalizedListItems;
323 }
324
325 function isDomChecklist(domNode: HTMLElement) {
326   if (
327     domNode.getAttribute('__lexicallisttype') === 'check' ||
328     // is github checklist
329     domNode.classList.contains('contains-task-list')
330   ) {
331     return true;
332   }
333   // if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting.
334   for (const child of domNode.childNodes) {
335     if (!isHTMLElement(child)) {
336       continue;
337     }
338
339     if (child.hasAttribute('aria-checked')) {
340       return true;
341     }
342
343     if (child.classList.contains('task-list-item')) {
344       return true;
345     }
346
347     if (child.firstElementChild && child.firstElementChild.matches('input[type="checkbox"]')) {
348       return true;
349     }
350   }
351   return false;
352 }
353
354 function $convertListNode(domNode: HTMLElement): DOMConversionOutput {
355   const nodeName = domNode.nodeName.toLowerCase();
356   let node = null;
357   if (nodeName === 'ol') {
358     // @ts-ignore
359     const start = domNode.start;
360     node = $createListNode('number', start);
361   } else if (nodeName === 'ul') {
362     if (isDomChecklist(domNode)) {
363       node = $createListNode('check');
364     } else {
365       node = $createListNode('bullet');
366     }
367   }
368
369   if (domNode.id && node) {
370     node.setId(domNode.id);
371   }
372
373   if (domNode.dir && node) {
374     node.setDirection(extractDirectionFromElement(domNode));
375   }
376
377   return {
378     after: $normalizeChildren,
379     node,
380   };
381 }
382
383 const TAG_TO_LIST_TYPE: Record<string, ListType> = {
384   ol: 'number',
385   ul: 'bullet',
386 };
387
388 /**
389  * Creates a ListNode of listType.
390  * @param listType - The type of list to be created. Can be 'number', 'bullet', or 'check'.
391  * @param start - Where an ordered list starts its count, start = 1 if left undefined.
392  * @returns The new ListNode
393  */
394 export function $createListNode(listType: ListType, start = 1): ListNode {
395   return $applyNodeReplacement(new ListNode(listType, start));
396 }
397
398 /**
399  * Checks to see if the node is a ListNode.
400  * @param node - The node to be checked.
401  * @returns true if the node is a ListNode, false otherwise.
402  */
403 export function $isListNode(
404   node: LexicalNode | null | undefined,
405 ): node is ListNode {
406   return node instanceof ListNode;
407 }