2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
10 addClassNamesToElement,
12 removeClassNamesFromElement,
13 } from '@lexical/utils';
15 $applyNodeReplacement,
27 SerializedElementNode,
30 import invariant from 'lexical/shared/invariant';
31 import normalizeClassNames from 'lexical/shared/normalizeClassNames';
33 import {$createListItemNode, $isListItemNode, ListItemNode} from '.';
35 mergeNextSiblingListIfSameType,
36 updateChildrenListItemValue,
37 } from './formatList';
38 import {$getListDepth, $wrapInListItem} from './utils';
40 export type SerializedListNode = Spread<
49 export type ListType = 'number' | 'bullet' | 'check';
51 export type ListNodeTagType = 'ul' | 'ol';
54 export class ListNode extends ElementNode {
56 __tag: ListNodeTagType;
62 static getType(): string {
66 static clone(node: ListNode): ListNode {
67 const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag];
69 return new ListNode(listType, node.__start, node.__key);
72 constructor(listType: ListType, start: number, key?: NodeKey) {
74 const _listType = TAG_TO_LIST_TYPE[listType] || listType;
75 this.__listType = _listType;
76 this.__tag = _listType === 'number' ? 'ol' : 'ul';
80 getTag(): ListNodeTagType {
84 setListType(type: ListType): void {
85 const writable = this.getWritable();
86 writable.__listType = type;
87 writable.__tag = type === 'number' ? 'ol' : 'ul';
90 getListType(): ListType {
91 return this.__listType;
100 createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement {
101 const tag = this.__tag;
102 const dom = document.createElement(tag);
104 if (this.__start !== 1) {
105 dom.setAttribute('start', String(this.__start));
107 // @ts-expect-error Internal field.
108 dom.__lexicalListType = this.__listType;
109 $setListThemeClassNames(dom, config.theme, this);
117 config: EditorConfig,
119 if (prevNode.__tag !== this.__tag) {
123 $setListThemeClassNames(dom, config.theme, this);
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);
136 static importDOM(): DOMConversionMap | null {
139 conversion: $convertListNode,
143 conversion: $convertListNode,
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);
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));
163 if (this.__listType === 'check') {
164 element.setAttribute('__lexicalListType', 'check');
172 exportJSON(): SerializedListNode {
174 ...super.exportJSON(),
175 listType: this.getListType(),
176 start: this.getStart(),
183 canBeEmpty(): false {
191 append(...nodesToAppend: LexicalNode[]): this {
192 for (let i = 0; i < nodesToAppend.length; i++) {
193 const currentNode = nodesToAppend[i];
195 if ($isListItemNode(currentNode)) {
196 super.append(currentNode);
198 const listItemNode = $createListItemNode();
200 if ($isListNode(currentNode)) {
201 listItemNode.append(currentNode);
202 } else if ($isElementNode(currentNode)) {
203 const textNode = $createTextNode(currentNode.getTextContent());
204 listItemNode.append(textNode);
206 listItemNode.append(currentNode);
208 super.append(listItemNode);
214 extractWithChild(child: LexicalNode): boolean {
215 return $isListItemNode(child);
219 function $setListThemeClassNames(
221 editorThemeClasses: EditorThemeClasses,
224 const classesToAdd = [];
225 const classesToRemove = [];
226 const listTheme = editorThemeClasses.list;
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;
238 if (nestedListTheme !== undefined && nestedListTheme.list) {
239 nestedListClassName = nestedListTheme.list;
242 if (listClassName !== undefined) {
243 classesToAdd.push(listClassName);
246 if (checklistClassName !== undefined && node.__listType === 'check') {
247 classesToAdd.push(checklistClassName);
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);
259 if (nestedListClassName !== undefined) {
260 const nestedListItemClasses = normalizeClassNames(nestedListClassName);
263 classesToAdd.push(...nestedListItemClasses);
265 classesToRemove.push(...nestedListItemClasses);
270 if (classesToRemove.length > 0) {
271 removeClassNamesFromElement(dom, ...classesToRemove);
274 if (classesToAdd.length > 0) {
275 addClassNamesToElement(dom, ...classesToAdd);
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.
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));
299 normalizedListItems.push($wrapInListItem(node));
302 return normalizedListItems;
305 function isDomChecklist(domNode: HTMLElement) {
307 domNode.getAttribute('__lexicallisttype') === 'check' ||
308 // is github checklist
309 domNode.classList.contains('contains-task-list')
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')) {
322 function $convertListNode(domNode: HTMLElement): DOMConversionOutput {
323 const nodeName = domNode.nodeName.toLowerCase();
325 if (nodeName === 'ol') {
327 const start = domNode.start;
328 node = $createListNode('number', start);
329 } else if (nodeName === 'ul') {
330 if (isDomChecklist(domNode)) {
331 node = $createListNode('check');
333 node = $createListNode('bullet');
338 after: $normalizeChildren,
343 const TAG_TO_LIST_TYPE: Record<string, ListType> = {
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
354 export function $createListNode(listType: ListType, start = 1): ListNode {
355 return $applyNodeReplacement(new ListNode(listType, start));
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.
363 export function $isListNode(
364 node: LexicalNode | null | undefined,
365 ): node is ListNode {
366 return node instanceof ListNode;