/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import { addClassNamesToElement, isHTMLElement, removeClassNamesFromElement, } from '@lexical/utils'; import { $applyNodeReplacement, $createTextNode, $isElementNode, DOMConversionMap, DOMConversionOutput, DOMExportOutput, EditorConfig, EditorThemeClasses, ElementNode, LexicalEditor, LexicalNode, NodeKey, SerializedElementNode, Spread, } from 'lexical'; import invariant from 'lexical/shared/invariant'; import normalizeClassNames from 'lexical/shared/normalizeClassNames'; import {$createListItemNode, $isListItemNode, ListItemNode} from '.'; import { mergeNextSiblingListIfSameType, updateChildrenListItemValue, } from './formatList'; import {$getListDepth, $wrapInListItem} from './utils'; export type SerializedListNode = Spread< { listType: ListType; start: number; tag: ListNodeTagType; }, SerializedElementNode >; export type ListType = 'number' | 'bullet' | 'check'; export type ListNodeTagType = 'ul' | 'ol'; /** @noInheritDoc */ export class ListNode extends ElementNode { /** @internal */ __tag: ListNodeTagType; /** @internal */ __start: number; /** @internal */ __listType: ListType; static getType(): string { return 'list'; } static clone(node: ListNode): ListNode { const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag]; return new ListNode(listType, node.__start, node.__key); } constructor(listType: ListType, start: number, key?: NodeKey) { super(key); const _listType = TAG_TO_LIST_TYPE[listType] || listType; this.__listType = _listType; this.__tag = _listType === 'number' ? 'ol' : 'ul'; this.__start = start; } getTag(): ListNodeTagType { return this.__tag; } setListType(type: ListType): void { const writable = this.getWritable(); writable.__listType = type; writable.__tag = type === 'number' ? 'ol' : 'ul'; } getListType(): ListType { return this.__listType; } getStart(): number { return this.__start; } // View createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement { const tag = this.__tag; const dom = document.createElement(tag); if (this.__start !== 1) { dom.setAttribute('start', String(this.__start)); } // @ts-expect-error Internal field. dom.__lexicalListType = this.__listType; $setListThemeClassNames(dom, config.theme, this); return dom; } updateDOM( prevNode: ListNode, dom: HTMLElement, config: EditorConfig, ): boolean { if (prevNode.__tag !== this.__tag) { return true; } $setListThemeClassNames(dom, config.theme, this); return false; } static transform(): (node: LexicalNode) => void { return (node: LexicalNode) => { invariant($isListNode(node), 'node is not a ListNode'); mergeNextSiblingListIfSameType(node); updateChildrenListItemValue(node); }; } static importDOM(): DOMConversionMap | null { return { ol: () => ({ conversion: $convertListNode, priority: 0, }), ul: () => ({ conversion: $convertListNode, priority: 0, }), }; } static importJSON(serializedNode: SerializedListNode): ListNode { const node = $createListNode(serializedNode.listType, serializedNode.start); node.setFormat(serializedNode.format); node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); if (element && isHTMLElement(element)) { if (this.__start !== 1) { element.setAttribute('start', String(this.__start)); } if (this.__listType === 'check') { element.setAttribute('__lexicalListType', 'check'); } } return { element, }; } exportJSON(): SerializedListNode { return { ...super.exportJSON(), listType: this.getListType(), start: this.getStart(), tag: this.getTag(), type: 'list', version: 1, }; } canBeEmpty(): false { return false; } canIndent(): false { return false; } append(...nodesToAppend: LexicalNode[]): this { for (let i = 0; i < nodesToAppend.length; i++) { const currentNode = nodesToAppend[i]; if ($isListItemNode(currentNode)) { super.append(currentNode); } else { const listItemNode = $createListItemNode(); if ($isListNode(currentNode)) { listItemNode.append(currentNode); } else if ($isElementNode(currentNode)) { const textNode = $createTextNode(currentNode.getTextContent()); listItemNode.append(textNode); } else { listItemNode.append(currentNode); } super.append(listItemNode); } } return this; } extractWithChild(child: LexicalNode): boolean { return $isListItemNode(child); } } function $setListThemeClassNames( dom: HTMLElement, editorThemeClasses: EditorThemeClasses, node: ListNode, ): void { const classesToAdd = []; const classesToRemove = []; const listTheme = editorThemeClasses.list; if (listTheme !== undefined) { const listLevelsClassNames = listTheme[`${node.__tag}Depth`] || []; const listDepth = $getListDepth(node) - 1; const normalizedListDepth = listDepth % listLevelsClassNames.length; const listLevelClassName = listLevelsClassNames[normalizedListDepth]; const listClassName = listTheme[node.__tag]; let nestedListClassName; const nestedListTheme = listTheme.nested; const checklistClassName = listTheme.checklist; if (nestedListTheme !== undefined && nestedListTheme.list) { nestedListClassName = nestedListTheme.list; } if (listClassName !== undefined) { classesToAdd.push(listClassName); } if (checklistClassName !== undefined && node.__listType === 'check') { classesToAdd.push(checklistClassName); } if (listLevelClassName !== undefined) { classesToAdd.push(...normalizeClassNames(listLevelClassName)); for (let i = 0; i < listLevelsClassNames.length; i++) { if (i !== normalizedListDepth) { classesToRemove.push(node.__tag + i); } } } if (nestedListClassName !== undefined) { const nestedListItemClasses = normalizeClassNames(nestedListClassName); if (listDepth > 1) { classesToAdd.push(...nestedListItemClasses); } else { classesToRemove.push(...nestedListItemClasses); } } } if (classesToRemove.length > 0) { removeClassNamesFromElement(dom, ...classesToRemove); } if (classesToAdd.length > 0) { addClassNamesToElement(dom, ...classesToAdd); } } /* * This function normalizes the children of a ListNode after the conversion from HTML, * ensuring that they are all ListItemNodes and contain either a single nested ListNode * or some other inline content. */ function $normalizeChildren(nodes: Array): Array { const normalizedListItems: Array = []; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if ($isListItemNode(node)) { normalizedListItems.push(node); const children = node.getChildren(); if (children.length > 1) { children.forEach((child) => { if ($isListNode(child)) { normalizedListItems.push($wrapInListItem(child)); } }); } } else { normalizedListItems.push($wrapInListItem(node)); } } return normalizedListItems; } function isDomChecklist(domNode: HTMLElement) { if ( domNode.getAttribute('__lexicallisttype') === 'check' || // is github checklist domNode.classList.contains('contains-task-list') ) { return true; } // if children are checklist items, the node is a checklist ul. Applicable for googledoc checklist pasting. for (const child of domNode.childNodes) { if (isHTMLElement(child) && child.hasAttribute('aria-checked')) { return true; } } return false; } function $convertListNode(domNode: HTMLElement): DOMConversionOutput { const nodeName = domNode.nodeName.toLowerCase(); let node = null; if (nodeName === 'ol') { // @ts-ignore const start = domNode.start; node = $createListNode('number', start); } else if (nodeName === 'ul') { if (isDomChecklist(domNode)) { node = $createListNode('check'); } else { node = $createListNode('bullet'); } } return { after: $normalizeChildren, node, }; } const TAG_TO_LIST_TYPE: Record = { ol: 'number', ul: 'bullet', }; /** * Creates a ListNode of listType. * @param listType - The type of list to be created. Can be 'number', 'bullet', or 'check'. * @param start - Where an ordered list starts its count, start = 1 if left undefined. * @returns The new ListNode */ export function $createListNode(listType: ListType, start = 1): ListNode { return $applyNodeReplacement(new ListNode(listType, start)); } /** * Checks to see if the node is a ListNode. * @param node - The node to be checked. * @returns true if the node is a ListNode, false otherwise. */ export function $isListNode( node: LexicalNode | null | undefined, ): node is ListNode { return node instanceof ListNode; }