531 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
		
		
			
		
	
	
			531 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
|  | /** | ||
|  |  * 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 {$getNearestNodeOfType} from '@lexical/utils'; | ||
|  | import { | ||
|  |   $createParagraphNode, | ||
|  |   $getSelection, | ||
|  |   $isElementNode, | ||
|  |   $isLeafNode, | ||
|  |   $isParagraphNode, | ||
|  |   $isRangeSelection, | ||
|  |   $isRootOrShadowRoot, | ||
|  |   ElementNode, | ||
|  |   LexicalEditor, | ||
|  |   LexicalNode, | ||
|  |   NodeKey, | ||
|  |   ParagraphNode, | ||
|  | } from 'lexical'; | ||
|  | import invariant from 'lexical/shared/invariant'; | ||
|  | 
 | ||
|  | import { | ||
|  |   $createListItemNode, | ||
|  |   $createListNode, | ||
|  |   $isListItemNode, | ||
|  |   $isListNode, | ||
|  |   ListItemNode, | ||
|  |   ListNode, | ||
|  | } from './'; | ||
|  | import {ListType} from './LexicalListNode'; | ||
|  | import { | ||
|  |   $getAllListItems, | ||
|  |   $getTopListNode, | ||
|  |   $removeHighestEmptyListParent, | ||
|  |   isNestedListNode, | ||
|  | } from './utils'; | ||
|  | 
 | ||
|  | function $isSelectingEmptyListItem( | ||
|  |   anchorNode: ListItemNode | LexicalNode, | ||
|  |   nodes: Array<LexicalNode>, | ||
|  | ): boolean { | ||
|  |   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 editor - The lexical editor. | ||
|  |  * @param listType - The type of list, "number" | "bullet" | "check". | ||
|  |  */ | ||
|  | export function insertList(editor: LexicalEditor, listType: ListType): void { | ||
|  |   editor.update(() => { | ||
|  |     const selection = $getSelection(); | ||
|  | 
 | ||
|  |     if (selection !== null) { | ||
|  |       const nodes = selection.getNodes(); | ||
|  |       if ($isRangeSelection(selection)) { | ||
|  |         const anchorAndFocus = selection.getStartEndPoints(); | ||
|  |         invariant( | ||
|  |           anchorAndFocus !== null, | ||
|  |           'insertList: anchor should be defined', | ||
|  |         ); | ||
|  |         const [anchor] = anchorAndFocus; | ||
|  |         const anchorNode = anchor.getNode(); | ||
|  |         const anchorNodeParent = anchorNode.getParent(); | ||
|  | 
 | ||
|  |         if ($isSelectingEmptyListItem(anchorNode, nodes)) { | ||
|  |           const list = $createListNode(listType); | ||
|  | 
 | ||
|  |           if ($isRootOrShadowRoot(anchorNodeParent)) { | ||
|  |             anchorNode.replace(list); | ||
|  |             const listItem = $createListItemNode(); | ||
|  |             if ($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 ( | ||
|  |           $isElementNode(node) && | ||
|  |           node.isEmpty() && | ||
|  |           !$isListItemNode(node) && | ||
|  |           !handled.has(node.getKey()) | ||
|  |         ) { | ||
|  |           $createListOrMerge(node, listType); | ||
|  |           continue; | ||
|  |         } | ||
|  | 
 | ||
|  |         if ($isLeafNode(node)) { | ||
|  |           let parent = node.getParent(); | ||
|  |           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 ($isRootOrShadowRoot(nextParent) && !handled.has(parentKey)) { | ||
|  |                 handled.add(parentKey); | ||
|  |                 $createListOrMerge(parent, listType); | ||
|  |                 break; | ||
|  |               } | ||
|  | 
 | ||
|  |               parent = nextParent; | ||
|  |             } | ||
|  |           } | ||
|  |         } | ||
|  |       } | ||
|  |     } | ||
|  |   }); | ||
|  | } | ||
|  | 
 | ||
|  | function append(node: ElementNode, nodesToAppend: Array<LexicalNode>) { | ||
|  |   node.splice(node.getChildrenSize(), 0, nodesToAppend); | ||
|  | } | ||
|  | 
 | ||
|  | function $createListOrMerge(node: ElementNode, listType: ListType): ListNode { | ||
|  |   if ($isListNode(node)) { | ||
|  |     return node; | ||
|  |   } | ||
|  | 
 | ||
|  |   const previousSibling = node.getPreviousSibling(); | ||
|  |   const nextSibling = node.getNextSibling(); | ||
|  |   const listItem = $createListItemNode(); | ||
|  |   listItem.setFormat(node.getFormatType()); | ||
|  |   listItem.setIndent(node.getIndent()); | ||
|  |   append(listItem, node.getChildren()); | ||
|  | 
 | ||
|  |   if ( | ||
|  |     $isListNode(previousSibling) && | ||
|  |     listType === previousSibling.getListType() | ||
|  |   ) { | ||
|  |     previousSibling.append(listItem); | ||
|  |     node.remove(); | ||
|  |     // if the same type of list is on both sides, merge them.
 | ||
|  | 
 | ||
|  |     if ($isListNode(nextSibling) && listType === nextSibling.getListType()) { | ||
|  |       append(previousSibling, nextSibling.getChildren()); | ||
|  |       nextSibling.remove(); | ||
|  |     } | ||
|  |     return previousSibling; | ||
|  |   } else if ( | ||
|  |     $isListNode(nextSibling) && | ||
|  |     listType === nextSibling.getListType() | ||
|  |   ) { | ||
|  |     nextSibling.getFirstChildOrThrow().insertBefore(listItem); | ||
|  |     node.remove(); | ||
|  |     return nextSibling; | ||
|  |   } else { | ||
|  |     const list = $createListNode(listType); | ||
|  |     list.append(listItem); | ||
|  |     node.replace(list); | ||
|  |     return list; | ||
|  |   } | ||
|  | } | ||
|  | 
 | ||
|  | /** | ||
|  |  * 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. | ||
|  |  */ | ||
|  | export function mergeLists(list1: ListNode, list2: ListNode): void { | ||
|  |   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. | ||
|  |  * @param editor - The lexical editor. | ||
|  |  */ | ||
|  | export function removeList(editor: LexicalEditor): void { | ||
|  |   editor.update(() => { | ||
|  |     const selection = $getSelection(); | ||
|  | 
 | ||
|  |     if ($isRangeSelection(selection)) { | ||
|  |       const listNodes = new Set<ListNode>(); | ||
|  |       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 ($isLeafNode(node)) { | ||
|  |             const listItemNode = $getNearestNodeOfType(node, ListItemNode); | ||
|  | 
 | ||
|  |             if (listItemNode != null) { | ||
|  |               listNodes.add($getTopListNode(listItemNode)); | ||
|  |             } | ||
|  |           } | ||
|  |         } | ||
|  |       } | ||
|  | 
 | ||
|  |       for (const listNode of listNodes) { | ||
|  |         let insertionPoint: ListNode | ParagraphNode = listNode; | ||
|  | 
 | ||
|  |         const listItems = $getAllListItems(listNode); | ||
|  | 
 | ||
|  |         for (const listItemNode of listItems) { | ||
|  |           const paragraph = $createParagraphNode(); | ||
|  | 
 | ||
|  |           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) { | ||
|  |             selection.anchor.set(paragraph.getKey(), 0, 'element'); | ||
|  |           } | ||
|  |           if (listItemNode.__key === selection.focus.key) { | ||
|  |             selection.focus.set(paragraph.getKey(), 0, 'element'); | ||
|  |           } | ||
|  | 
 | ||
|  |           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. | ||
|  |  */ | ||
|  | export function updateChildrenListItemValue(list: ListNode): void { | ||
|  |   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 | ||
|  |  */ | ||
|  | export function mergeNextSiblingListIfSameType(list: ListNode): void { | ||
|  |   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. | ||
|  |  */ | ||
|  | export function $handleIndent(listItemNode: ListItemNode): void { | ||
|  |   // go through each node and decide where to move it.
 | ||
|  |   const removed = new Set<NodeKey>(); | ||
|  | 
 | ||
|  |   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<ListItemNode>() as ListItemNode; | ||
|  |   const previousSibling = | ||
|  |     listItemNode.getPreviousSibling<ListItemNode>() as ListItemNode; | ||
|  |   // 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(); | ||
|  |       const newList = $createListNode(parent.getListType()); | ||
|  |       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). | ||
|  |  */ | ||
|  | export function $handleOutdent(listItemNode: ListItemNode): void { | ||
|  |   // 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 succesfully, false if there is no selection | ||
|  |  * or the selection does not contain a ListItemNode or the node already holds text. | ||
|  |  */ | ||
|  | export function $handleListInsertParagraph(): boolean { | ||
|  |   const selection = $getSelection(); | ||
|  | 
 | ||
|  |   if (!$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(); | ||
|  | 
 | ||
|  |   invariant( | ||
|  |     $isListNode(parent), | ||
|  |     'A ListItemNode must have a ListNode for a parent.', | ||
|  |   ); | ||
|  | 
 | ||
|  |   const grandparent = parent.getParent(); | ||
|  | 
 | ||
|  |   let replacementNode; | ||
|  | 
 | ||
|  |   if ($isRootOrShadowRoot(grandparent)) { | ||
|  |     replacementNode = $createParagraphNode(); | ||
|  |     topListNode.insertAfter(replacementNode); | ||
|  |   } else if ($isListItemNode(grandparent)) { | ||
|  |     replacementNode = $createListItemNode(); | ||
|  |     grandparent.insertAfter(replacementNode); | ||
|  |   } else { | ||
|  |     return false; | ||
|  |   } | ||
|  |   replacementNode.select(); | ||
|  | 
 | ||
|  |   const nextSiblings = anchor.getNextSiblings(); | ||
|  | 
 | ||
|  |   if (nextSiblings.length > 0) { | ||
|  |     const newList = $createListNode(parent.getListType()); | ||
|  | 
 | ||
|  |     if ($isParagraphNode(replacementNode)) { | ||
|  |       replacementNode.insertAfter(newList); | ||
|  |     } else { | ||
|  |       const newListItem = $createListItemNode(); | ||
|  |       newListItem.append(newList); | ||
|  |       replacementNode.insertAfter(newListItem); | ||
|  |     } | ||
|  |     nextSiblings.forEach((sibling) => { | ||
|  |       sibling.remove(); | ||
|  |       newList.append(sibling); | ||
|  |     }); | ||
|  |   } | ||
|  | 
 | ||
|  |   // Don't leave hanging nested empty lists
 | ||
|  |   $removeHighestEmptyListParent(anchor); | ||
|  | 
 | ||
|  |   return true; | ||
|  | } |