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;
 | |
| }
 |