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';
39 import {extractDirectionFromElement} from "lexical/nodes/common";
41 export type SerializedListNode = Spread<
51 export type ListType = 'number' | 'bullet' | 'check';
53 export type ListNodeTagType = 'ul' | 'ol';
56 export class ListNode extends ElementNode {
58 __tag: ListNodeTagType;
66 static getType(): string {
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;
77 constructor(listType: ListType, start: number, key?: NodeKey) {
79 const _listType = TAG_TO_LIST_TYPE[listType] || listType;
80 this.__listType = _listType;
81 this.__tag = _listType === 'number' ? 'ol' : 'ul';
85 getTag(): ListNodeTagType {
90 const self = this.getWritable();
95 const self = this.getLatest();
99 setListType(type: ListType): void {
100 const writable = this.getWritable();
101 writable.__listType = type;
102 writable.__tag = type === 'number' ? 'ol' : 'ul';
105 getListType(): ListType {
106 return this.__listType;
115 createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement {
116 const tag = this.__tag;
117 const dom = document.createElement(tag);
119 if (this.__start !== 1) {
120 dom.setAttribute('start', String(this.__start));
122 // @ts-expect-error Internal field.
123 dom.__lexicalListType = this.__listType;
124 $setListThemeClassNames(dom, config.theme, this);
127 dom.setAttribute('id', this.__id);
131 dom.setAttribute('dir', this.__dir);
140 config: EditorConfig,
143 prevNode.__tag !== this.__tag
144 || prevNode.__dir !== this.__dir
145 || prevNode.__id !== this.__id
150 $setListThemeClassNames(dom, config.theme, this);
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);
163 static importDOM(): DOMConversionMap | null {
166 conversion: $convertListNode,
170 conversion: $convertListNode,
176 static importJSON(serializedNode: SerializedListNode): ListNode {
177 const node = $createListNode(serializedNode.listType, serializedNode.start);
178 node.setId(serializedNode.id);
179 node.setDirection(serializedNode.direction);
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));
189 if (this.__listType === 'check') {
190 element.setAttribute('__lexicalListType', 'check');
198 exportJSON(): SerializedListNode {
200 ...super.exportJSON(),
201 listType: this.getListType(),
202 start: this.getStart(),
210 canBeEmpty(): false {
218 append(...nodesToAppend: LexicalNode[]): this {
219 for (let i = 0; i < nodesToAppend.length; i++) {
220 const currentNode = nodesToAppend[i];
222 if ($isListItemNode(currentNode)) {
223 super.append(currentNode);
225 const listItemNode = $createListItemNode();
227 if ($isListNode(currentNode)) {
228 listItemNode.append(currentNode);
229 } else if ($isElementNode(currentNode)) {
230 const textNode = $createTextNode(currentNode.getTextContent());
231 listItemNode.append(textNode);
233 listItemNode.append(currentNode);
235 super.append(listItemNode);
241 extractWithChild(child: LexicalNode): boolean {
242 return $isListItemNode(child);
246 function $setListThemeClassNames(
248 editorThemeClasses: EditorThemeClasses,
251 const classesToAdd = [];
252 const classesToRemove = [];
253 const listTheme = editorThemeClasses.list;
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;
265 if (nestedListTheme !== undefined && nestedListTheme.list) {
266 nestedListClassName = nestedListTheme.list;
269 if (listClassName !== undefined) {
270 classesToAdd.push(listClassName);
273 if (checklistClassName !== undefined && node.__listType === 'check') {
274 classesToAdd.push(checklistClassName);
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);
286 if (nestedListClassName !== undefined) {
287 const nestedListItemClasses = normalizeClassNames(nestedListClassName);
290 classesToAdd.push(...nestedListItemClasses);
292 classesToRemove.push(...nestedListItemClasses);
297 if (classesToRemove.length > 0) {
298 removeClassNamesFromElement(dom, ...classesToRemove);
301 if (classesToAdd.length > 0) {
302 addClassNamesToElement(dom, ...classesToAdd);
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.
311 function $normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
312 const normalizedListItems: Array<ListItemNode> = [];
314 for (const node of nodes) {
315 if ($isListItemNode(node)) {
316 normalizedListItems.push(node);
318 normalizedListItems.push($wrapInListItem(node));
322 return normalizedListItems;
325 function isDomChecklist(domNode: HTMLElement) {
327 domNode.getAttribute('__lexicallisttype') === 'check' ||
328 // is github checklist
329 domNode.classList.contains('contains-task-list')
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)) {
339 if (child.hasAttribute('aria-checked')) {
343 if (child.classList.contains('task-list-item')) {
347 if (child.firstElementChild && child.firstElementChild.matches('input[type="checkbox"]')) {
354 function $convertListNode(domNode: HTMLElement): DOMConversionOutput {
355 const nodeName = domNode.nodeName.toLowerCase();
357 if (nodeName === 'ol') {
359 const start = domNode.start;
360 node = $createListNode('number', start);
361 } else if (nodeName === 'ul') {
362 if (isDomChecklist(domNode)) {
363 node = $createListNode('check');
365 node = $createListNode('bullet');
369 if (domNode.id && node) {
370 node.setId(domNode.id);
373 if (domNode.dir && node) {
374 node.setDirection(extractDirectionFromElement(domNode));
378 after: $normalizeChildren,
383 const TAG_TO_LIST_TYPE: Record<string, ListType> = {
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
394 export function $createListNode(listType: ListType, start = 1): ListNode {
395 return $applyNodeReplacement(new ListNode(listType, start));
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.
403 export function $isListNode(
404 node: LexicalNode | null | undefined,
405 ): node is ListNode {
406 return node instanceof ListNode;