/** * 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. *