525 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			525 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();
 | 
						|
            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();
 | 
						|
  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;
 | 
						|
}
 |