2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
9 import {$getNearestNodeOfType} from '@lexical/utils';
24 import invariant from 'lexical/shared/invariant';
34 import {ListType} from './LexicalListNode';
38 $removeHighestEmptyListParent,
42 function $isSelectingEmptyListItem(
43 anchorNode: ListItemNode | LexicalNode,
44 nodes: Array<LexicalNode>,
47 $isListItemNode(anchorNode) &&
48 (nodes.length === 0 ||
49 (nodes.length === 1 &&
50 anchorNode.is(nodes[0]) &&
51 anchorNode.getChildrenSize() === 0))
56 * Inserts a new ListNode. If the selection's anchor node is an empty ListItemNode and is a child of
57 * the root/shadow root, it will replace the ListItemNode with a ListNode and the old ListItemNode.
58 * Otherwise it will replace its parent with a new ListNode and re-insert the ListItemNode and any previous children.
59 * If the selection's anchor node is not an empty ListItemNode, it will add a new ListNode or merge an existing ListNode,
60 * 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
61 * a new ListNode, or create a new ListNode at the nearest root/shadow root.
62 * @param editor - The lexical editor.
63 * @param listType - The type of list, "number" | "bullet" | "check".
65 export function insertList(editor: LexicalEditor, listType: ListType): void {
67 const selection = $getSelection();
69 if (selection !== null) {
70 const nodes = selection.getNodes();
71 if ($isRangeSelection(selection)) {
72 const anchorAndFocus = selection.getStartEndPoints();
74 anchorAndFocus !== null,
75 'insertList: anchor should be defined',
77 const [anchor] = anchorAndFocus;
78 const anchorNode = anchor.getNode();
79 const anchorNodeParent = anchorNode.getParent();
81 if ($isSelectingEmptyListItem(anchorNode, nodes)) {
82 const list = $createListNode(listType);
84 if ($isRootOrShadowRoot(anchorNodeParent)) {
85 anchorNode.replace(list);
86 const listItem = $createListItemNode();
87 list.append(listItem);
88 } else if ($isListItemNode(anchorNode)) {
89 const parent = anchorNode.getParentOrThrow();
90 append(list, parent.getChildren());
98 const handled = new Set();
99 for (let i = 0; i < nodes.length; i++) {
100 const node = nodes[i];
103 $isElementNode(node) &&
105 !$isListItemNode(node) &&
106 !handled.has(node.getKey())
108 $createListOrMerge(node, listType);
112 if ($isLeafNode(node)) {
113 let parent = node.getParent();
114 while (parent != null) {
115 const parentKey = parent.getKey();
117 if ($isListNode(parent)) {
118 if (!handled.has(parentKey)) {
119 const newListNode = $createListNode(listType);
120 append(newListNode, parent.getChildren());
121 parent.replace(newListNode);
122 handled.add(parentKey);
127 const nextParent = parent.getParent();
129 if ($isRootOrShadowRoot(nextParent) && !handled.has(parentKey)) {
130 handled.add(parentKey);
131 $createListOrMerge(parent, listType);
144 function append(node: ElementNode, nodesToAppend: Array<LexicalNode>) {
145 node.splice(node.getChildrenSize(), 0, nodesToAppend);
148 function $createListOrMerge(node: ElementNode, listType: ListType): ListNode {
149 if ($isListNode(node)) {
153 const previousSibling = node.getPreviousSibling();
154 const nextSibling = node.getNextSibling();
155 const listItem = $createListItemNode();
156 append(listItem, node.getChildren());
159 $isListNode(previousSibling) &&
160 listType === previousSibling.getListType()
162 previousSibling.append(listItem);
164 // if the same type of list is on both sides, merge them.
166 if ($isListNode(nextSibling) && listType === nextSibling.getListType()) {
167 append(previousSibling, nextSibling.getChildren());
168 nextSibling.remove();
170 return previousSibling;
172 $isListNode(nextSibling) &&
173 listType === nextSibling.getListType()
175 nextSibling.getFirstChildOrThrow().insertBefore(listItem);
179 const list = $createListNode(listType);
180 list.append(listItem);
187 * A recursive function that goes through each list and their children, including nested lists,
188 * appending list2 children after list1 children and updating ListItemNode values.
189 * @param list1 - The first list to be merged.
190 * @param list2 - The second list to be merged.
192 export function mergeLists(list1: ListNode, list2: ListNode): void {
193 const listItem1 = list1.getLastChild();
194 const listItem2 = list2.getFirstChild();
199 isNestedListNode(listItem1) &&
200 isNestedListNode(listItem2)
202 mergeLists(listItem1.getFirstChild(), listItem2.getFirstChild());
206 const toMerge = list2.getChildren();
207 if (toMerge.length > 0) {
208 list1.append(...toMerge);
215 * Searches for the nearest ancestral ListNode and removes it. If selection is an empty ListItemNode
216 * it will remove the whole list, including the ListItemNode. For each ListItemNode in the ListNode,
217 * removeList will also generate new ParagraphNodes in the removed ListNode's place. Any child node
218 * inside a ListItemNode will be appended to the new ParagraphNodes.
219 * @param editor - The lexical editor.
221 export function removeList(editor: LexicalEditor): void {
222 editor.update(() => {
223 const selection = $getSelection();
225 if ($isRangeSelection(selection)) {
226 const listNodes = new Set<ListNode>();
227 const nodes = selection.getNodes();
228 const anchorNode = selection.anchor.getNode();
230 if ($isSelectingEmptyListItem(anchorNode, nodes)) {
231 listNodes.add($getTopListNode(anchorNode));
233 for (let i = 0; i < nodes.length; i++) {
234 const node = nodes[i];
236 if ($isLeafNode(node)) {
237 const listItemNode = $getNearestNodeOfType(node, ListItemNode);
239 if (listItemNode != null) {
240 listNodes.add($getTopListNode(listItemNode));
246 for (const listNode of listNodes) {
247 let insertionPoint: ListNode | ParagraphNode = listNode;
249 const listItems = $getAllListItems(listNode);
251 for (const listItemNode of listItems) {
252 const paragraph = $createParagraphNode();
254 append(paragraph, listItemNode.getChildren());
256 insertionPoint.insertAfter(paragraph);
257 insertionPoint = paragraph;
259 // When the anchor and focus fall on the textNode
260 // we don't have to change the selection because the textNode will be appended to
261 // the newly generated paragraph.
262 // When selection is in empty nested list item, selection is actually on the listItemNode.
263 // When the corresponding listItemNode is deleted and replaced by the newly generated paragraph
264 // we should manually set the selection's focus and anchor to the newly generated paragraph.
265 if (listItemNode.__key === selection.anchor.key) {
266 selection.anchor.set(paragraph.getKey(), 0, 'element');
268 if (listItemNode.__key === selection.focus.key) {
269 selection.focus.set(paragraph.getKey(), 0, 'element');
272 listItemNode.remove();
281 * Takes the value of a child ListItemNode and makes it the value the ListItemNode
282 * should be if it isn't already. Also ensures that checked is undefined if the
283 * parent does not have a list type of 'check'.
284 * @param list - The list whose children are updated.
286 export function updateChildrenListItemValue(list: ListNode): void {
287 const isNotChecklist = list.getListType() !== 'check';
288 let value = list.getStart();
289 for (const child of list.getChildren()) {
290 if ($isListItemNode(child)) {
291 if (child.getValue() !== value) {
292 child.setValue(value);
294 if (isNotChecklist && child.getLatest().__checked != null) {
295 child.setChecked(undefined);
297 if (!$isListNode(child.getFirstChild())) {
305 * Merge the next sibling list if same type.
306 * <ul> will merge with <ul>, but NOT <ul> with <ol>.
307 * @param list - The list whose next sibling should be potentially merged
309 export function mergeNextSiblingListIfSameType(list: ListNode): void {
310 const nextSibling = list.getNextSibling();
312 $isListNode(nextSibling) &&
313 list.getListType() === nextSibling.getListType()
315 mergeLists(list, nextSibling);
320 * Adds an empty ListNode/ListItemNode chain at listItemNode, so as to
321 * create an indent effect. Won't indent ListItemNodes that have a ListNode as
322 * a child, but does merge sibling ListItemNodes if one has a nested ListNode.
323 * @param listItemNode - The ListItemNode to be indented.
325 export function $handleIndent(listItemNode: ListItemNode): void {
326 // go through each node and decide where to move it.
327 const removed = new Set<NodeKey>();
329 if (isNestedListNode(listItemNode) || removed.has(listItemNode.getKey())) {
333 const parent = listItemNode.getParent();
335 // We can cast both of the below `isNestedListNode` only returns a boolean type instead of a user-defined type guards
337 listItemNode.getNextSibling<ListItemNode>() as ListItemNode;
338 const previousSibling =
339 listItemNode.getPreviousSibling<ListItemNode>() as ListItemNode;
340 // if there are nested lists on either side, merge them all together.
342 if (isNestedListNode(nextSibling) && isNestedListNode(previousSibling)) {
343 const innerList = previousSibling.getFirstChild();
345 if ($isListNode(innerList)) {
346 innerList.append(listItemNode);
347 const nextInnerList = nextSibling.getFirstChild();
349 if ($isListNode(nextInnerList)) {
350 const children = nextInnerList.getChildren();
351 append(innerList, children);
352 nextSibling.remove();
353 removed.add(nextSibling.getKey());
356 } else if (isNestedListNode(nextSibling)) {
357 // if the ListItemNode is next to a nested ListNode, merge them
358 const innerList = nextSibling.getFirstChild();
360 if ($isListNode(innerList)) {
361 const firstChild = innerList.getFirstChild();
363 if (firstChild !== null) {
364 firstChild.insertBefore(listItemNode);
367 } else if (isNestedListNode(previousSibling)) {
368 const innerList = previousSibling.getFirstChild();
370 if ($isListNode(innerList)) {
371 innerList.append(listItemNode);
374 // otherwise, we need to create a new nested ListNode
376 if ($isListNode(parent)) {
377 const newListItem = $createListItemNode();
378 const newList = $createListNode(parent.getListType());
379 newListItem.append(newList);
380 newList.append(listItemNode);
382 if (previousSibling) {
383 previousSibling.insertAfter(newListItem);
384 } else if (nextSibling) {
385 nextSibling.insertBefore(newListItem);
387 parent.append(newListItem);
394 * Removes an indent by removing an empty ListNode/ListItemNode chain. An indented ListItemNode
395 * has a great grandparent node of type ListNode, which is where the ListItemNode will reside
397 * @param listItemNode - The ListItemNode to remove the indent (outdent).
399 export function $handleOutdent(listItemNode: ListItemNode): void {
400 // go through each node and decide where to move it.
402 if (isNestedListNode(listItemNode)) {
405 const parentList = listItemNode.getParent();
406 const grandparentListItem = parentList ? parentList.getParent() : undefined;
407 const greatGrandparentList = grandparentListItem
408 ? grandparentListItem.getParent()
410 // If it doesn't have these ancestors, it's not indented.
413 $isListNode(greatGrandparentList) &&
414 $isListItemNode(grandparentListItem) &&
415 $isListNode(parentList)
417 // if it's the first child in it's parent list, insert it into the
418 // great grandparent list before the grandparent
419 const firstChild = parentList ? parentList.getFirstChild() : undefined;
420 const lastChild = parentList ? parentList.getLastChild() : undefined;
422 if (listItemNode.is(firstChild)) {
423 grandparentListItem.insertBefore(listItemNode);
425 if (parentList.isEmpty()) {
426 grandparentListItem.remove();
428 // if it's the last child in it's parent list, insert it into the
429 // great grandparent list after the grandparent.
430 } else if (listItemNode.is(lastChild)) {
431 grandparentListItem.insertAfter(listItemNode);
433 if (parentList.isEmpty()) {
434 grandparentListItem.remove();
437 // otherwise, we need to split the siblings into two new nested lists
438 const listType = parentList.getListType();
439 const previousSiblingsListItem = $createListItemNode();
440 const previousSiblingsList = $createListNode(listType);
441 previousSiblingsListItem.append(previousSiblingsList);
443 .getPreviousSiblings()
444 .forEach((sibling) => previousSiblingsList.append(sibling));
445 const nextSiblingsListItem = $createListItemNode();
446 const nextSiblingsList = $createListNode(listType);
447 nextSiblingsListItem.append(nextSiblingsList);
448 append(nextSiblingsList, listItemNode.getNextSiblings());
449 // put the sibling nested lists on either side of the grandparent list item in the great grandparent.
450 grandparentListItem.insertBefore(previousSiblingsListItem);
451 grandparentListItem.insertAfter(nextSiblingsListItem);
452 // replace the grandparent list item (now between the siblings) with the outdented list item.
453 grandparentListItem.replace(listItemNode);
459 * Attempts to insert a ParagraphNode at selection and selects the new node. The selection must contain a ListItemNode
460 * or a node that does not already contain text. If its grandparent is the root/shadow root, it will get the ListNode
461 * (which should be the parent node) and insert the ParagraphNode as a sibling to the ListNode. If the ListNode is
462 * nested in a ListItemNode instead, it will add the ParagraphNode after the grandparent ListItemNode.
463 * Throws an invariant if the selection is not a child of a ListNode.
464 * @returns true if a ParagraphNode was inserted succesfully, false if there is no selection
465 * or the selection does not contain a ListItemNode or the node already holds text.
467 export function $handleListInsertParagraph(): boolean {
468 const selection = $getSelection();
470 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
473 // Only run this code on empty list items
474 const anchor = selection.anchor.getNode();
476 if (!$isListItemNode(anchor) || anchor.getChildrenSize() !== 0) {
479 const topListNode = $getTopListNode(anchor);
480 const parent = anchor.getParent();
484 'A ListItemNode must have a ListNode for a parent.',
487 const grandparent = parent.getParent();
491 if ($isRootOrShadowRoot(grandparent)) {
492 replacementNode = $createParagraphNode();
493 topListNode.insertAfter(replacementNode);
494 } else if ($isListItemNode(grandparent)) {
495 replacementNode = $createListItemNode();
496 grandparent.insertAfter(replacementNode);
500 replacementNode.select();
502 const nextSiblings = anchor.getNextSiblings();
504 if (nextSiblings.length > 0) {
505 const newList = $createListNode(parent.getListType());
507 if ($isParagraphNode(replacementNode)) {
508 replacementNode.insertAfter(newList);
510 const newListItem = $createListItemNode();
511 newListItem.append(newList);
512 replacementNode.insertAfter(newListItem);
514 nextSiblings.forEach((sibling) => {
516 newList.append(sibling);
520 // Don't leave hanging nested empty lists
521 $removeHighestEmptyListParent(anchor);