UNPKG

@lexical/list

Version:

This package provides the list feature for Lexical.

1,446 lines (1,391 loc) 56.2 kB
/** * 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. * */ 'use strict'; var utils = require('@lexical/utils'); var lexical = require('lexical'); var selection = require('@lexical/selection'); /** * 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. * */ // Do not require this module directly! Use normal `invariant` calls. function formatDevErrorMessage(message) { throw new Error(message); } /** * Checks the depth of listNode from the root node. * @param listNode - The ListNode to be checked. * @returns The depth of the ListNode. */ function $getListDepth(listNode) { let depth = 1; let parent = listNode.getParent(); while (parent != null) { if ($isListItemNode(parent)) { const parentList = parent.getParent(); if ($isListNode(parentList)) { depth++; parent = parentList.getParent(); continue; } { formatDevErrorMessage(`A ListItemNode must have a ListNode for a parent.`); } } return depth; } return depth; } /** * Finds the nearest ancestral ListNode and returns it, throws an invariant if listItem is not a ListItemNode. * @param listItem - The node to be checked. * @returns The ListNode found. */ function $getTopListNode(listItem) { let list = listItem.getParent(); if (!$isListNode(list)) { { formatDevErrorMessage(`A ListItemNode must have a ListNode for a parent.`); } } let parent = list; while (parent !== null) { parent = parent.getParent(); if ($isListNode(parent)) { list = parent; } } return list; } /** * A recursive Depth-First Search (Postorder Traversal) that finds all of a node's children * that are of type ListItemNode and returns them in an array. * @param node - The ListNode to start the search. * @returns An array containing all nodes of type ListItemNode found. */ // This should probably be $getAllChildrenOfType function $getAllListItems(node) { let listItemNodes = []; const listChildren = node.getChildren().filter($isListItemNode); for (let i = 0; i < listChildren.length; i++) { const listItemNode = listChildren[i]; const firstChild = listItemNode.getFirstChild(); if ($isListNode(firstChild)) { listItemNodes = listItemNodes.concat($getAllListItems(firstChild)); } else { listItemNodes.push(listItemNode); } } return listItemNodes; } /** * Checks to see if the passed node is a ListItemNode and has a ListNode as a child. * @param node - The node to be checked. * @returns true if the node is a ListItemNode and has a ListNode child, false otherwise. */ function isNestedListNode(node) { return $isListItemNode(node) && $isListNode(node.getFirstChild()); } /** * Takes a deeply nested ListNode or ListItemNode and traverses up the branch to delete the first * ancestral ListNode (which could be the root ListNode) or ListItemNode with siblings, essentially * bringing the deeply nested node up the branch once. Would remove sublist if it has siblings. * Should not break ListItem -> List -> ListItem chain as empty List/ItemNodes should be removed on .remove(). * @param sublist - The nested ListNode or ListItemNode to be brought up the branch. */ function $removeHighestEmptyListParent(sublist) { // Nodes may be repeatedly indented, to create deeply nested lists that each // contain just one bullet. // Our goal is to remove these (empty) deeply nested lists. The easiest // way to do that is crawl back up the tree until we find a node that has siblings // (e.g. is actually part of the list contents) and delete that, or delete // the root of the list (if no list nodes have siblings.) let emptyListPtr = sublist; while (emptyListPtr.getNextSibling() == null && emptyListPtr.getPreviousSibling() == null) { const parent = emptyListPtr.getParent(); if (parent == null || !($isListItemNode(parent) || $isListNode(parent))) { break; } emptyListPtr = parent; } emptyListPtr.remove(); } /** * Wraps a node into a ListItemNode. * @param node - The node to be wrapped into a ListItemNode * @returns The ListItemNode which the passed node is wrapped in. */ function $wrapInListItem(node) { const listItemWrapper = $createListItemNode(); return listItemWrapper.append(node); } function $isSelectingEmptyListItem(anchorNode, nodes) { return $isListItemNode(anchorNode) && (nodes.length === 0 || nodes.length === 1 && anchorNode.is(nodes[0]) && anchorNode.getChildrenSize() === 0); } /** * Inserts a new ListNode. If the selection's anchor node is an empty ListItemNode and is a child of * the root/shadow root, it will replace the ListItemNode with a ListNode and the old ListItemNode. * Otherwise it will replace its parent with a new ListNode and re-insert the ListItemNode and any previous children. * If the selection's anchor node is not an empty ListItemNode, it will add a new ListNode or merge an existing ListNode, * unless the the node is a leaf node, in which case it will attempt to find a ListNode up the branch and replace it with * a new ListNode, or create a new ListNode at the nearest root/shadow root. * @param listType - The type of list, "number" | "bullet" | "check". */ function $insertList(listType) { const selection = lexical.$getSelection(); if (selection !== null) { let nodes = selection.getNodes(); if (lexical.$isRangeSelection(selection)) { const anchorAndFocus = selection.getStartEndPoints(); if (!(anchorAndFocus !== null)) { formatDevErrorMessage(`insertList: anchor should be defined`); } const [anchor] = anchorAndFocus; const anchorNode = anchor.getNode(); const anchorNodeParent = anchorNode.getParent(); if (lexical.$isRootOrShadowRoot(anchorNode)) { const firstChild = anchorNode.getFirstChild(); if (firstChild) { nodes = firstChild.selectStart().getNodes(); } else { const paragraph = lexical.$createParagraphNode(); anchorNode.append(paragraph); nodes = paragraph.select().getNodes(); } } else if ($isSelectingEmptyListItem(anchorNode, nodes)) { const list = $createListNode(listType); if (lexical.$isRootOrShadowRoot(anchorNodeParent)) { anchorNode.replace(list); const listItem = $createListItemNode(); if (lexical.$isElementNode(anchorNode)) { listItem.setFormat(anchorNode.getFormatType()); listItem.setIndent(anchorNode.getIndent()); } list.append(listItem); } else if ($isListItemNode(anchorNode)) { const parent = anchorNode.getParentOrThrow(); append(list, parent.getChildren()); parent.replace(list); } return; } } const handled = new Set(); for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (lexical.$isElementNode(node) && node.isEmpty() && !$isListItemNode(node) && !handled.has(node.getKey())) { $createListOrMerge(node, listType); continue; } let parent = lexical.$isLeafNode(node) ? node.getParent() : $isListItemNode(node) && node.isEmpty() ? node : null; while (parent != null) { const parentKey = parent.getKey(); if ($isListNode(parent)) { if (!handled.has(parentKey)) { const newListNode = $createListNode(listType); append(newListNode, parent.getChildren()); parent.replace(newListNode); handled.add(parentKey); } break; } else { const nextParent = parent.getParent(); if (lexical.$isRootOrShadowRoot(nextParent) && !handled.has(parentKey)) { handled.add(parentKey); $createListOrMerge(parent, listType); break; } parent = nextParent; } } } } } function append(node, nodesToAppend) { node.splice(node.getChildrenSize(), 0, nodesToAppend); } function $createListOrMerge(node, listType) { if ($isListNode(node)) { return node; } const previousSibling = node.getPreviousSibling(); const nextSibling = node.getNextSibling(); const listItem = $createListItemNode(); append(listItem, node.getChildren()); let targetList; if ($isListNode(previousSibling) && listType === previousSibling.getListType()) { previousSibling.append(listItem); // if the same type of list is on both sides, merge them. if ($isListNode(nextSibling) && listType === nextSibling.getListType()) { append(previousSibling, nextSibling.getChildren()); nextSibling.remove(); } targetList = previousSibling; } else if ($isListNode(nextSibling) && listType === nextSibling.getListType()) { nextSibling.getFirstChildOrThrow().insertBefore(listItem); targetList = nextSibling; } else { const list = $createListNode(listType); list.append(listItem); node.replace(list); targetList = list; } // listItem needs to be attached to root prior to setting indent listItem.setFormat(node.getFormatType()); listItem.setIndent(node.getIndent()); node.remove(); return targetList; } /** * A recursive function that goes through each list and their children, including nested lists, * appending list2 children after list1 children and updating ListItemNode values. * @param list1 - The first list to be merged. * @param list2 - The second list to be merged. */ function mergeLists(list1, list2) { const listItem1 = list1.getLastChild(); const listItem2 = list2.getFirstChild(); if (listItem1 && listItem2 && isNestedListNode(listItem1) && isNestedListNode(listItem2)) { mergeLists(listItem1.getFirstChild(), listItem2.getFirstChild()); listItem2.remove(); } const toMerge = list2.getChildren(); if (toMerge.length > 0) { list1.append(...toMerge); } list2.remove(); } /** * Searches for the nearest ancestral ListNode and removes it. If selection is an empty ListItemNode * it will remove the whole list, including the ListItemNode. For each ListItemNode in the ListNode, * removeList will also generate new ParagraphNodes in the removed ListNode's place. Any child node * inside a ListItemNode will be appended to the new ParagraphNodes. */ function $removeList() { const selection = lexical.$getSelection(); if (lexical.$isRangeSelection(selection)) { const listNodes = new Set(); const nodes = selection.getNodes(); const anchorNode = selection.anchor.getNode(); if ($isSelectingEmptyListItem(anchorNode, nodes)) { listNodes.add($getTopListNode(anchorNode)); } else { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (lexical.$isLeafNode(node)) { const listItemNode = utils.$getNearestNodeOfType(node, ListItemNode); if (listItemNode != null) { listNodes.add($getTopListNode(listItemNode)); } } } } for (const listNode of listNodes) { let insertionPoint = listNode; const listItems = $getAllListItems(listNode); for (const listItemNode of listItems) { const paragraph = lexical.$createParagraphNode().setTextStyle(selection.style).setTextFormat(selection.format); append(paragraph, listItemNode.getChildren()); insertionPoint.insertAfter(paragraph); insertionPoint = paragraph; // When the anchor and focus fall on the textNode // we don't have to change the selection because the textNode will be appended to // the newly generated paragraph. // When selection is in empty nested list item, selection is actually on the listItemNode. // When the corresponding listItemNode is deleted and replaced by the newly generated paragraph // we should manually set the selection's focus and anchor to the newly generated paragraph. if (listItemNode.__key === selection.anchor.key) { lexical.$setPointFromCaret(selection.anchor, lexical.$normalizeCaret(lexical.$getChildCaret(paragraph, 'next'))); } if (listItemNode.__key === selection.focus.key) { lexical.$setPointFromCaret(selection.focus, lexical.$normalizeCaret(lexical.$getChildCaret(paragraph, 'next'))); } listItemNode.remove(); } listNode.remove(); } } } /** * Takes the value of a child ListItemNode and makes it the value the ListItemNode * should be if it isn't already. Also ensures that checked is undefined if the * parent does not have a list type of 'check'. * @param list - The list whose children are updated. */ function updateChildrenListItemValue(list) { const isNotChecklist = list.getListType() !== 'check'; let value = list.getStart(); for (const child of list.getChildren()) { if ($isListItemNode(child)) { if (child.getValue() !== value) { child.setValue(value); } if (isNotChecklist && child.getLatest().__checked != null) { child.setChecked(undefined); } if (!$isListNode(child.getFirstChild())) { value++; } } } } /** * Merge the next sibling list if same type. * <ul> will merge with <ul>, but NOT <ul> with <ol>. * @param list - The list whose next sibling should be potentially merged */ function mergeNextSiblingListIfSameType(list) { const nextSibling = list.getNextSibling(); if ($isListNode(nextSibling) && list.getListType() === nextSibling.getListType()) { mergeLists(list, nextSibling); } } /** * Adds an empty ListNode/ListItemNode chain at listItemNode, so as to * create an indent effect. Won't indent ListItemNodes that have a ListNode as * a child, but does merge sibling ListItemNodes if one has a nested ListNode. * @param listItemNode - The ListItemNode to be indented. */ function $handleIndent(listItemNode) { // go through each node and decide where to move it. const removed = new Set(); if (isNestedListNode(listItemNode) || removed.has(listItemNode.getKey())) { return; } const parent = listItemNode.getParent(); // We can cast both of the below `isNestedListNode` only returns a boolean type instead of a user-defined type guards const nextSibling = listItemNode.getNextSibling(); const previousSibling = listItemNode.getPreviousSibling(); // if there are nested lists on either side, merge them all together. if (isNestedListNode(nextSibling) && isNestedListNode(previousSibling)) { const innerList = previousSibling.getFirstChild(); if ($isListNode(innerList)) { innerList.append(listItemNode); const nextInnerList = nextSibling.getFirstChild(); if ($isListNode(nextInnerList)) { const children = nextInnerList.getChildren(); append(innerList, children); nextSibling.remove(); removed.add(nextSibling.getKey()); } } } else if (isNestedListNode(nextSibling)) { // if the ListItemNode is next to a nested ListNode, merge them const innerList = nextSibling.getFirstChild(); if ($isListNode(innerList)) { const firstChild = innerList.getFirstChild(); if (firstChild !== null) { firstChild.insertBefore(listItemNode); } } } else if (isNestedListNode(previousSibling)) { const innerList = previousSibling.getFirstChild(); if ($isListNode(innerList)) { innerList.append(listItemNode); } } else { // otherwise, we need to create a new nested ListNode if ($isListNode(parent)) { const newListItem = $createListItemNode().setTextFormat(parent.getTextFormat()).setTextStyle(parent.getTextStyle()); const newList = $createListNode(parent.getListType()).setTextFormat(parent.getTextFormat()).setTextStyle(parent.getTextStyle()); newListItem.append(newList); newList.append(listItemNode); if (previousSibling) { previousSibling.insertAfter(newListItem); } else if (nextSibling) { nextSibling.insertBefore(newListItem); } else { parent.append(newListItem); } } } } /** * Removes an indent by removing an empty ListNode/ListItemNode chain. An indented ListItemNode * has a great grandparent node of type ListNode, which is where the ListItemNode will reside * within as a child. * @param listItemNode - The ListItemNode to remove the indent (outdent). */ function $handleOutdent(listItemNode) { // go through each node and decide where to move it. if (isNestedListNode(listItemNode)) { return; } const parentList = listItemNode.getParent(); const grandparentListItem = parentList ? parentList.getParent() : undefined; const greatGrandparentList = grandparentListItem ? grandparentListItem.getParent() : undefined; // If it doesn't have these ancestors, it's not indented. if ($isListNode(greatGrandparentList) && $isListItemNode(grandparentListItem) && $isListNode(parentList)) { // if it's the first child in it's parent list, insert it into the // great grandparent list before the grandparent const firstChild = parentList ? parentList.getFirstChild() : undefined; const lastChild = parentList ? parentList.getLastChild() : undefined; if (listItemNode.is(firstChild)) { grandparentListItem.insertBefore(listItemNode); if (parentList.isEmpty()) { grandparentListItem.remove(); } // if it's the last child in it's parent list, insert it into the // great grandparent list after the grandparent. } else if (listItemNode.is(lastChild)) { grandparentListItem.insertAfter(listItemNode); if (parentList.isEmpty()) { grandparentListItem.remove(); } } else { // otherwise, we need to split the siblings into two new nested lists const listType = parentList.getListType(); const previousSiblingsListItem = $createListItemNode(); const previousSiblingsList = $createListNode(listType); previousSiblingsListItem.append(previousSiblingsList); listItemNode.getPreviousSiblings().forEach(sibling => previousSiblingsList.append(sibling)); const nextSiblingsListItem = $createListItemNode(); const nextSiblingsList = $createListNode(listType); nextSiblingsListItem.append(nextSiblingsList); append(nextSiblingsList, listItemNode.getNextSiblings()); // put the sibling nested lists on either side of the grandparent list item in the great grandparent. grandparentListItem.insertBefore(previousSiblingsListItem); grandparentListItem.insertAfter(nextSiblingsListItem); // replace the grandparent list item (now between the siblings) with the outdented list item. grandparentListItem.replace(listItemNode); } } } /** * Attempts to insert a ParagraphNode at selection and selects the new node. The selection must contain a ListItemNode * or a node that does not already contain text. If its grandparent is the root/shadow root, it will get the ListNode * (which should be the parent node) and insert the ParagraphNode as a sibling to the ListNode. If the ListNode is * nested in a ListItemNode instead, it will add the ParagraphNode after the grandparent ListItemNode. * Throws an invariant if the selection is not a child of a ListNode. * @returns true if a ParagraphNode was inserted successfully, false if there is no selection * or the selection does not contain a ListItemNode or the node already holds text. */ function $handleListInsertParagraph() { const selection = lexical.$getSelection(); if (!lexical.$isRangeSelection(selection) || !selection.isCollapsed()) { return false; } // Only run this code on empty list items const anchor = selection.anchor.getNode(); if (!$isListItemNode(anchor) || anchor.getChildrenSize() !== 0) { return false; } const topListNode = $getTopListNode(anchor); const parent = anchor.getParent(); if (!$isListNode(parent)) { formatDevErrorMessage(`A ListItemNode must have a ListNode for a parent.`); } const grandparent = parent.getParent(); let replacementNode; if (lexical.$isRootOrShadowRoot(grandparent)) { replacementNode = lexical.$createParagraphNode(); topListNode.insertAfter(replacementNode); } else if ($isListItemNode(grandparent)) { replacementNode = $createListItemNode(); grandparent.insertAfter(replacementNode); } else { return false; } replacementNode.setTextStyle(selection.style).setTextFormat(selection.format).select(); const nextSiblings = anchor.getNextSiblings(); if (nextSiblings.length > 0) { const newList = $createListNode(parent.getListType()); if ($isListItemNode(replacementNode)) { const newListItem = $createListItemNode(); newListItem.append(newList); replacementNode.insertAfter(newListItem); } else { replacementNode.insertAfter(newList); } newList.append(...nextSiblings); } // Don't leave hanging nested empty lists $removeHighestEmptyListParent(anchor); return true; } /** * 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. * */ function normalizeClassNames(...classNames) { const rval = []; for (const className of classNames) { if (className && typeof className === 'string') { for (const [s] of className.matchAll(/\S+/g)) { rval.push(s); } } } return rval; } function applyMarkerStyles(dom, node, prevNode) { const styles = selection.getStyleObjectFromCSS(node.__textStyle); for (const k in styles) { dom.style.setProperty(`--listitem-marker-${k}`, styles[k]); } if (prevNode) { for (const k in selection.getStyleObjectFromCSS(prevNode.__textStyle)) { if (!(k in styles)) { dom.style.removeProperty(`--listitem-marker-${k}`); } } } } /** @noInheritDoc */ class ListItemNode extends lexical.ElementNode { /** @internal */ /** @internal */ static getType() { return 'listitem'; } static clone(node) { return new ListItemNode(node.__value, node.__checked, node.__key); } constructor(value, checked, key) { super(key); this.__value = value === undefined ? 1 : value; this.__checked = checked; } createDOM(config) { const element = document.createElement('li'); const parent = this.getParent(); if ($isListNode(parent) && parent.getListType() === 'check') { updateListItemChecked(element, this, null); } element.value = this.__value; $setListItemThemeClassNames(element, config.theme, this); const nextStyle = this.__style; if (nextStyle) { element.style.cssText = nextStyle; } applyMarkerStyles(element, this, null); return element; } updateDOM(prevNode, dom, config) { const parent = this.getParent(); if ($isListNode(parent) && parent.getListType() === 'check') { updateListItemChecked(dom, this, prevNode); } // @ts-expect-error - this is always HTMLListItemElement dom.value = this.__value; $setListItemThemeClassNames(dom, config.theme, this); const prevStyle = prevNode.__style; const nextStyle = this.__style; if (prevStyle !== nextStyle) { if (nextStyle === '') { dom.removeAttribute('style'); } else { dom.style.cssText = nextStyle; } } applyMarkerStyles(dom, this, prevNode); return false; } static transform() { return node => { if (!$isListItemNode(node)) { formatDevErrorMessage(`node is not a ListItemNode`); } if (node.__checked == null) { return; } const parent = node.getParent(); if ($isListNode(parent)) { if (parent.getListType() !== 'check' && node.getChecked() != null) { node.setChecked(undefined); } } }; } static importDOM() { return { li: () => ({ conversion: $convertListItemElement, priority: 0 }) }; } static importJSON(serializedNode) { return $createListItemNode().updateFromJSON(serializedNode); } updateFromJSON(serializedNode) { return super.updateFromJSON(serializedNode).setValue(serializedNode.value).setChecked(serializedNode.checked); } exportDOM(editor) { const element = this.createDOM(editor._config); element.style.textAlign = this.getFormatType(); const direction = this.getDirection(); if (direction) { element.dir = direction; } return { element }; } exportJSON() { return { ...super.exportJSON(), checked: this.getChecked(), value: this.getValue() }; } append(...nodes) { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (lexical.$isElementNode(node) && this.canMergeWith(node)) { const children = node.getChildren(); this.append(...children); node.remove(); } else { super.append(node); } } return this; } replace(replaceWithNode, includeChildren) { if ($isListItemNode(replaceWithNode)) { return super.replace(replaceWithNode); } this.setIndent(0); const list = this.getParentOrThrow(); if (!$isListNode(list)) { return replaceWithNode; } if (list.__first === this.getKey()) { list.insertBefore(replaceWithNode); } else if (list.__last === this.getKey()) { list.insertAfter(replaceWithNode); } else { // Split the list const newList = $createListNode(list.getListType()); let nextSibling = this.getNextSibling(); while (nextSibling) { const nodeToAppend = nextSibling; nextSibling = nextSibling.getNextSibling(); newList.append(nodeToAppend); } list.insertAfter(replaceWithNode); replaceWithNode.insertAfter(newList); } if (includeChildren) { if (!lexical.$isElementNode(replaceWithNode)) { formatDevErrorMessage(`includeChildren should only be true for ElementNodes`); } this.getChildren().forEach(child => { replaceWithNode.append(child); }); } this.remove(); if (list.getChildrenSize() === 0) { list.remove(); } return replaceWithNode; } insertAfter(node, restoreSelection = true) { const listNode = this.getParentOrThrow(); if (!$isListNode(listNode)) { { formatDevErrorMessage(`insertAfter: list node is not parent of list item node`); } } if ($isListItemNode(node)) { return super.insertAfter(node, restoreSelection); } const siblings = this.getNextSiblings(); // Split the lists and insert the node in between them listNode.insertAfter(node, restoreSelection); if (siblings.length !== 0) { const newListNode = $createListNode(listNode.getListType()); siblings.forEach(sibling => newListNode.append(sibling)); node.insertAfter(newListNode, restoreSelection); } return node; } remove(preserveEmptyParent) { const prevSibling = this.getPreviousSibling(); const nextSibling = this.getNextSibling(); super.remove(preserveEmptyParent); if (prevSibling && nextSibling && isNestedListNode(prevSibling) && isNestedListNode(nextSibling)) { mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild()); nextSibling.remove(); } } insertNewAfter(_, restoreSelection = true) { const newElement = $createListItemNode().updateFromJSON(this.exportJSON()).setChecked(this.getChecked() ? false : undefined); this.insertAfter(newElement, restoreSelection); return newElement; } collapseAtStart(selection) { const paragraph = lexical.$createParagraphNode(); const children = this.getChildren(); children.forEach(child => paragraph.append(child)); const listNode = this.getParentOrThrow(); const listNodeParent = listNode.getParentOrThrow(); const isIndented = $isListItemNode(listNodeParent); if (listNode.getChildrenSize() === 1) { if (isIndented) { // if the list node is nested, we just want to remove it, // effectively unindenting it. listNode.remove(); listNodeParent.select(); } else { listNode.insertBefore(paragraph); listNode.remove(); // If we have selection on the list item, we'll need to move it // to the paragraph const anchor = selection.anchor; const focus = selection.focus; const key = paragraph.getKey(); if (anchor.type === 'element' && anchor.getNode().is(this)) { anchor.set(key, anchor.offset, 'element'); } if (focus.type === 'element' && focus.getNode().is(this)) { focus.set(key, focus.offset, 'element'); } } } else { listNode.insertBefore(paragraph); this.remove(); } return true; } getValue() { const self = this.getLatest(); return self.__value; } setValue(value) { const self = this.getWritable(); self.__value = value; return self; } getChecked() { const self = this.getLatest(); let listType; const parent = this.getParent(); if ($isListNode(parent)) { listType = parent.getListType(); } return listType === 'check' ? Boolean(self.__checked) : undefined; } setChecked(checked) { const self = this.getWritable(); self.__checked = checked; return self; } toggleChecked() { const self = this.getWritable(); return self.setChecked(!self.__checked); } getIndent() { // If we don't have a parent, we are likely serializing const parent = this.getParent(); if (parent === null || !this.isAttached()) { return this.getLatest().__indent; } // ListItemNode should always have a ListNode for a parent. let listNodeParent = parent.getParentOrThrow(); let indentLevel = 0; while ($isListItemNode(listNodeParent)) { listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow(); indentLevel++; } return indentLevel; } setIndent(indent) { if (!(typeof indent === 'number')) { formatDevErrorMessage(`Invalid indent value.`); } indent = Math.floor(indent); if (!(indent >= 0)) { formatDevErrorMessage(`Indent value must be non-negative.`); } let currentIndent = this.getIndent(); while (currentIndent !== indent) { if (currentIndent < indent) { $handleIndent(this); currentIndent++; } else { $handleOutdent(this); currentIndent--; } } return this; } /** @deprecated @internal */ canInsertAfter(node) { return $isListItemNode(node); } /** @deprecated @internal */ canReplaceWith(replacement) { return $isListItemNode(replacement); } canMergeWith(node) { return $isListItemNode(node) || lexical.$isParagraphNode(node); } extractWithChild(child, selection) { if (!lexical.$isRangeSelection(selection)) { return false; } const anchorNode = selection.anchor.getNode(); const focusNode = selection.focus.getNode(); return this.isParentOf(anchorNode) && this.isParentOf(focusNode) && this.getTextContent().length === selection.getTextContent().length; } isParentRequired() { return true; } createParentElementNode() { return $createListNode('bullet'); } canMergeWhenEmpty() { return true; } } function $setListItemThemeClassNames(dom, editorThemeClasses, node) { const classesToAdd = []; const classesToRemove = []; const listTheme = editorThemeClasses.list; const listItemClassName = listTheme ? listTheme.listitem : undefined; let nestedListItemClassName; if (listTheme && listTheme.nested) { nestedListItemClassName = listTheme.nested.listitem; } if (listItemClassName !== undefined) { classesToAdd.push(...normalizeClassNames(listItemClassName)); } if (listTheme) { const parentNode = node.getParent(); const isCheckList = $isListNode(parentNode) && parentNode.getListType() === 'check'; const checked = node.getChecked(); if (!isCheckList || checked) { classesToRemove.push(listTheme.listitemUnchecked); } if (!isCheckList || !checked) { classesToRemove.push(listTheme.listitemChecked); } if (isCheckList) { classesToAdd.push(checked ? listTheme.listitemChecked : listTheme.listitemUnchecked); } } if (nestedListItemClassName !== undefined) { const nestedListItemClasses = normalizeClassNames(nestedListItemClassName); if (node.getChildren().some(child => $isListNode(child))) { classesToAdd.push(...nestedListItemClasses); } else { classesToRemove.push(...nestedListItemClasses); } } if (classesToRemove.length > 0) { utils.removeClassNamesFromElement(dom, ...classesToRemove); } if (classesToAdd.length > 0) { utils.addClassNamesToElement(dom, ...classesToAdd); } } function updateListItemChecked(dom, listItemNode, prevListItemNode, listNode) { // Only add attributes for leaf list items if ($isListNode(listItemNode.getFirstChild())) { dom.removeAttribute('role'); dom.removeAttribute('tabIndex'); dom.removeAttribute('aria-checked'); } else { dom.setAttribute('role', 'checkbox'); dom.setAttribute('tabIndex', '-1'); if (!prevListItemNode || listItemNode.__checked !== prevListItemNode.__checked) { dom.setAttribute('aria-checked', listItemNode.getChecked() ? 'true' : 'false'); } } } function $convertListItemElement(domNode) { const isGitHubCheckList = domNode.classList.contains('task-list-item'); if (isGitHubCheckList) { for (const child of domNode.children) { if (child.tagName === 'INPUT') { return $convertCheckboxInput(child); } } } const ariaCheckedAttr = domNode.getAttribute('aria-checked'); const checked = ariaCheckedAttr === 'true' ? true : ariaCheckedAttr === 'false' ? false : undefined; return { node: $createListItemNode(checked) }; } function $convertCheckboxInput(domNode) { const isCheckboxInput = domNode.getAttribute('type') === 'checkbox'; if (!isCheckboxInput) { return { node: null }; } const checked = domNode.hasAttribute('checked'); return { node: $createListItemNode(checked) }; } /** * Creates a new List Item node, passing true/false will convert it to a checkbox input. * @param checked - Is the List Item a checkbox and, if so, is it checked? undefined/null: not a checkbox, true/false is a checkbox and checked/unchecked, respectively. * @returns The new List Item. */ function $createListItemNode(checked) { return lexical.$applyNodeReplacement(new ListItemNode(undefined, checked)); } /** * Checks to see if the node is a ListItemNode. * @param node - The node to be checked. * @returns true if the node is a ListItemNode, false otherwise. */ function $isListItemNode(node) { return node instanceof ListItemNode; } /** @noInheritDoc */ class ListNode extends lexical.ElementNode { /** @internal */ /** @internal */ /** @internal */ static getType() { return 'list'; } static clone(node) { const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag]; return new ListNode(listType, node.__start, node.__key); } constructor(listType = 'number', start = 1, key) { super(key); const _listType = TAG_TO_LIST_TYPE[listType] || listType; this.__listType = _listType; this.__tag = _listType === 'number' ? 'ol' : 'ul'; this.__start = start; } getTag() { return this.__tag; } setListType(type) { const writable = this.getWritable(); writable.__listType = type; writable.__tag = type === 'number' ? 'ol' : 'ul'; return writable; } getListType() { return this.__listType; } getStart() { return this.__start; } setStart(start) { const self = this.getWritable(); self.__start = start; return self; } // View createDOM(config, _editor) { 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, dom, config) { if (prevNode.__tag !== this.__tag) { return true; } $setListThemeClassNames(dom, config.theme, this); return false; } static transform() { return node => { if (!$isListNode(node)) { formatDevErrorMessage(`node is not a ListNode`); } mergeNextSiblingListIfSameType(node); updateChildrenListItemValue(node); }; } static importDOM() { return { ol: () => ({ conversion: $convertListNode, priority: 0 }), ul: () => ({ conversion: $convertListNode, priority: 0 }) }; } static importJSON(serializedNode) { return $createListNode().updateFromJSON(serializedNode); } updateFromJSON(serializedNode) { return super.updateFromJSON(serializedNode).setListType(serializedNode.listType).setStart(serializedNode.start); } exportDOM(editor) { const element = this.createDOM(editor._config, editor); if (utils.isHTMLElement(element)) { if (this.__start !== 1) { element.setAttribute('start', String(this.__start)); } if (this.__listType === 'check') { element.setAttribute('__lexicalListType', 'check'); } } return { element }; } exportJSON() { return { ...super.exportJSON(), listType: this.getListType(), start: this.getStart(), tag: this.getTag() }; } canBeEmpty() { return false; } canIndent() { return false; } splice(start, deleteCount, nodesToInsert) { let listItemNodesToInsert = nodesToInsert; for (let i = 0; i < nodesToInsert.length; i++) { const node = nodesToInsert[i]; if (!$isListItemNode(node)) { if (listItemNodesToInsert === nodesToInsert) { listItemNodesToInsert = [...nodesToInsert]; } listItemNodesToInsert[i] = $createListItemNode().append(lexical.$isElementNode(node) && !($isListNode(node) || node.isInline()) ? lexical.$createTextNode(node.getTextContent()) : node); } } return super.splice(start, deleteCount, listItemNodesToInsert); } extractWithChild(child) { return $isListItemNode(child); } } function $setListThemeClassNames(dom, editorThemeClasses, node) { 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) { utils.removeClassNamesFromElement(dom, ...classesToRemove); } if (classesToAdd.length > 0) { utils.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) { const normalizedListItems = []; 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) { 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 (utils.isHTMLElement(child) && child.hasAttribute('aria-checked')) { return true; } } return false; } function $convertListNode(domNode) { 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 = { 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 */ function $createListNode(listType = 'number', start = 1) { return lexical.$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. */ function $isListNode(node) { return node instanceof ListNode; } /** * 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. * */ const INSERT_CHECK_LIST_COMMAND = lexical.createCommand('INSERT_CHECK_LIST_COMMAND'); function registerCheckList(editor) { return utils.mergeRegister(editor.registerCommand(INSERT_CHECK_LIST_COMMAND, () => { $insertList('check'); return true; }, lexical.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical.KEY_ARROW_DOWN_COMMAND, event => { return handleArrowUpOrDown(event, editor, false); }, lexical.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical.KEY_ARROW_UP_COMMAND, event => { return handleArrowUpOrDown(event, editor, true); }, lexical.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical.KEY_ESCAPE_COMMAND, () => { const activeItem = getActiveCheckListItem(); if (activeItem != null) { const rootElement = editor.getRootElement(); if (rootElement != null) { rootElement.focus(); } return true; } return false; }, lexical.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical.KEY_SPACE_COMMAND, event => { const activeItem = getActiveCheckListItem(); if (activeItem != null && editor.isEditable()) { editor.update(() => { const listItemNode = lexical.$getNearestNodeFromDOMNode(activeItem); if ($isListItemNode(listItemNode)) { event.preventDefault(); listItemNode.toggleChecked(); } }); return true; } return false; }, lexical.COMMAND_PRIORITY_LOW), editor.registerCommand(lexical.KEY_ARROW_LEFT_COMMAND, event => { return editor.getEditorState().read(() => { const selection = lexical.$getSelection(); if (lexical.$isRangeSelection(selection) && selection.isCollapsed()) { const { anchor } = selection; const isElement = anchor.type === 'element'; if (isElement || anchor.offset === 0) { const anchorNode = anchor.getNode(); const elementNode = utils.$findMatchingParent(anchorNode, node => lexical.$isElementNode(node) && !node.isInline()); if ($isListItemNode(elementNode)) { const parent = elementNode.getParent(); if ($isListNode(parent) && parent.getListType() === 'check' && (isElement || elementNode.getFirstDescendant() === anchorNode)) { const domNode = editor.getElementByKey(elementNode.__key); if (domNode != null && document.activeElement !== domNode) { domNode.focus(); event.preventDefault(); return true; } } } } } return false; }); }, lexical.COMMAND_PRIORITY_LOW), editor.registerRootListener((rootElement, prevElement) => { if (rootElement !== null) { rootElement.addEventListener('click', handleClick); rootElement.addEventListener('pointerdown', handlePointerDown); } if (prevElement !== null) { prevElement.removeEventListener('click', handleClick); prevElement.removeEventListener('pointerdown', handlePointerDown); } })); } function handleCheckItemEvent(event, callback) { const target = event.target; if (!utils.isHTMLElement(target)) { return; } // Ignore clicks on LI that have nested lists const firstChild = target.firstChild; if (utils.isHTMLElement(firstChild) && (firstChild.tagName === 'UL' || firstChild.tagName === 'OL')) { return; } const parentNode = target.parentNode; // @ts-ignore internal field if (!parentNode || parentNode.__lexicalListType !== 'check') { return; } const rect = target.getBoundingClientRect(); const pageX = event.pageX / utils.calculateZoomLevel(target); if (target.dir === 'rtl' ? pageX < rect.right && pageX > rect.right - 20 : pageX > rect.left && pageX < rect.left + 20) { callback(); } } function handleClick(event) { handleCheckItemEvent(event, () => { if (utils.isHTMLElement(event.target)) { const domNode = event.target; const editor = lexical.getNearestEditorFromDOMNode(domNode); if (editor != null && editor.isEditable()) { editor.update(() => { const node = lexical.$getNearestNodeFromDOMNode(domNode); if ($isListItemNode(node)) { domNode.focus(); node.toggleChecked(); } }); } } }); } function handlePointerDown(event) { handleCheckItemEvent(event, () => { // Prevents caret moving when clicking on check mark event.preventDefault(); }); } function getActiveCheckListItem() { const activeElement = document.activeElement; return utils.isHTMLElement(activeElement) && activeElement.tagName === 'LI' && activeElement.parentNode != null && // @ts-ignore internal field activeElement.parentNode.__lexicalListType === 'check' ? activeElement : null; } function findCheckListItemSibling(node, backward) { let sibling = backward ? node.getPreviousSibling() : node.getNextSibling(); let parent = node; // Going up in a tree to get non-null sibling while (sibling == null && $isListItemNode(parent)) { // Get li -> parent ul/ol -> parent li parent = parent.getParentOrThrow().getParent(); if (parent != null) { sibling = backward ? parent.getPreviousSibling() : parent.getNextSibling(); } } // Going down in a tree to get first non-nested list item while ($isListItemNode(sibling)) { const firstChild = backward ? sibling.getLastChild() : sibling.getFirstChild(); if (!$isListNode(firstChild)) { return sibling; } sibling = backward ? firstChild.getLastChild() : firstChild.getFirstChild(); } return null; } function handleArrowUpOrDown(event, editor, backward) { const activeItem = getActiveCheckListItem(); if (activeItem != null) { editor.update(() => { const listItem = lexical.$getNearestNodeFromDOMNode(activeItem); if (!$isListItemNode(listItem)) { return; } const nextListItem = findCheckListItemSibling(listItem, backward); if (nextListItem != null) { nextListItem.selectStart(); const dom = editor.getElementByKey(nextListItem.__key); if (dom != null) { event.preventDefault(); setTimeout(() => { dom.focus(); }, 0); } } }); } return false; } /** * 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. * */ const INSERT_UNORDERED_LIST_COMMAND = lexical.createCommand('INSERT_UNORDERED_LIST_COMMAND'); const INSERT_ORDERED_LIST_COMMAND = lexical.createCommand('INSERT_ORDERED_LIST_COMMAND'); const REMOVE_LIST_COMMAND = lexical.createCommand('REMOVE_LIST_COMMAND'); function registerList(editor) { const removeListener = utils.mergeRegister(editor.registerCommand(INSERT_ORDERED_LIST_COMMAND, () => { $insertList('number'); return true; }, lexical.COMMAND_PRIORITY_LOW), editor.registerCommand(INSERT_UNO