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 if ($isElementNode(anchorNode)) {
88 listItem.setFormat(anchorNode.getFormatType());
89 listItem.setIndent(anchorNode.getIndent());
91 list.append(listItem);
92 } else if ($isListItemNode(anchorNode)) {
93 const parent = anchorNode.getParentOrThrow();
94 append(list, parent.getChildren());
102 const handled = new Set();
103 for (let i = 0; i < nodes.length; i++) {
104 const node = nodes[i];
107 $isElementNode(node) &&
109 !$isListItemNode(node) &&
110 !handled.has(node.getKey())
112 $createListOrMerge(node, listType);
116 if ($isLeafNode(node)) {
117 let parent = node.getParent();
118 while (parent != null) {
119 const parentKey = parent.getKey();
121 if ($isListNode(parent)) {
122 if (!handled.has(parentKey)) {
123 const newListNode = $createListNode(listType);
124 append(newListNode, parent.getChildren());
125 parent.replace(newListNode);
126 handled.add(parentKey);
131 const nextParent = parent.getParent();
133 if ($isRootOrShadowRoot(nextParent) && !handled.has(parentKey)) {
134 handled.add(parentKey);
135 $createListOrMerge(parent, listType);
148 function append(node: ElementNode, nodesToAppend: Array<LexicalNode>) {
149 node.splice(node.getChildrenSize(), 0, nodesToAppend);
152 function $createListOrMerge(node: ElementNode, listType: ListType): ListNode {
153 if ($isListNode(node)) {
157 const previousSibling = node.getPreviousSibling();
158 const nextSibling = node.getNextSibling();
159 const listItem = $createListItemNode();
160 listItem.setFormat(node.getFormatType());
161 listItem.setIndent(node.getIndent());
162 append(listItem, node.getChildren());
165 $isListNode(previousSibling) &&
166 listType === previousSibling.getListType()
168 previousSibling.append(listItem);
170 // if the same type of list is on both sides, merge them.
172 if ($isListNode(nextSibling) && listType === nextSibling.getListType()) {
173 append(previousSibling, nextSibling.getChildren());
174 nextSibling.remove();
176 return previousSibling;
178 $isListNode(nextSibling) &&
179 listType === nextSibling.getListType()
181 nextSibling.getFirstChildOrThrow().insertBefore(listItem);
185 const list = $createListNode(listType);
186 list.append(listItem);
193 * A recursive function that goes through each list and their children, including nested lists,
194 * appending list2 children after list1 children and updating ListItemNode values.
195 * @param list1 - The first list to be merged.
196 * @param list2 - The second list to be merged.
198 export function mergeLists(list1: ListNode, list2: ListNode): void {
199 const listItem1 = list1.getLastChild();
200 const listItem2 = list2.getFirstChild();
205 isNestedListNode(listItem1) &&
206 isNestedListNode(listItem2)
208 mergeLists(listItem1.getFirstChild(), listItem2.getFirstChild());
212 const toMerge = list2.getChildren();
213 if (toMerge.length > 0) {
214 list1.append(...toMerge);
221 * Searches for the nearest ancestral ListNode and removes it. If selection is an empty ListItemNode
222 * it will remove the whole list, including the ListItemNode. For each ListItemNode in the ListNode,
223 * removeList will also generate new ParagraphNodes in the removed ListNode's place. Any child node
224 * inside a ListItemNode will be appended to the new ParagraphNodes.
225 * @param editor - The lexical editor.
227 export function removeList(editor: LexicalEditor): void {
228 editor.update(() => {
229 const selection = $getSelection();
231 if ($isRangeSelection(selection)) {
232 const listNodes = new Set<ListNode>();
233 const nodes = selection.getNodes();
234 const anchorNode = selection.anchor.getNode();
236 if ($isSelectingEmptyListItem(anchorNode, nodes)) {
237 listNodes.add($getTopListNode(anchorNode));
239 for (let i = 0; i < nodes.length; i++) {
240 const node = nodes[i];
242 if ($isLeafNode(node)) {
243 const listItemNode = $getNearestNodeOfType(node, ListItemNode);
245 if (listItemNode != null) {
246 listNodes.add($getTopListNode(listItemNode));
252 for (const listNode of listNodes) {
253 let insertionPoint: ListNode | ParagraphNode = listNode;
255 const listItems = $getAllListItems(listNode);
257 for (const listItemNode of listItems) {
258 const paragraph = $createParagraphNode();
260 append(paragraph, listItemNode.getChildren());
262 insertionPoint.insertAfter(paragraph);
263 insertionPoint = paragraph;
265 // When the anchor and focus fall on the textNode
266 // we don't have to change the selection because the textNode will be appended to
267 // the newly generated paragraph.
268 // When selection is in empty nested list item, selection is actually on the listItemNode.
269 // When the corresponding listItemNode is deleted and replaced by the newly generated paragraph
270 // we should manually set the selection's focus and anchor to the newly generated paragraph.
271 if (listItemNode.__key === selection.anchor.key) {
272 selection.anchor.set(paragraph.getKey(), 0, 'element');
274 if (listItemNode.__key === selection.focus.key) {
275 selection.focus.set(paragraph.getKey(), 0, 'element');
278 listItemNode.remove();
287 * Takes the value of a child ListItemNode and makes it the value the ListItemNode
288 * should be if it isn't already. Also ensures that checked is undefined if the
289 * parent does not have a list type of 'check'.
290 * @param list - The list whose children are updated.
292 export function updateChildrenListItemValue(list: ListNode): void {
293 const isNotChecklist = list.getListType() !== 'check';
294 let value = list.getStart();
295 for (const child of list.getChildren()) {
296 if ($isListItemNode(child)) {
297 if (child.getValue() !== value) {
298 child.setValue(value);
300 if (isNotChecklist && child.getLatest().__checked != null) {
301 child.setChecked(undefined);
303 if (!$isListNode(child.getFirstChild())) {
311 * Merge the next sibling list if same type.
312 * <ul> will merge with <ul>, but NOT <ul> with <ol>.
313 * @param list - The list whose next sibling should be potentially merged
315 export function mergeNextSiblingListIfSameType(list: ListNode): void {
316 const nextSibling = list.getNextSibling();
318 $isListNode(nextSibling) &&
319 list.getListType() === nextSibling.getListType()
321 mergeLists(list, nextSibling);
326 * Adds an empty ListNode/ListItemNode chain at listItemNode, so as to
327 * create an indent effect. Won't indent ListItemNodes that have a ListNode as
328 * a child, but does merge sibling ListItemNodes if one has a nested ListNode.
329 * @param listItemNode - The ListItemNode to be indented.
331 export function $handleIndent(listItemNode: ListItemNode): void {
332 // go through each node and decide where to move it.
333 const removed = new Set<NodeKey>();
335 if (isNestedListNode(listItemNode) || removed.has(listItemNode.getKey())) {
339 const parent = listItemNode.getParent();
341 // We can cast both of the below `isNestedListNode` only returns a boolean type instead of a user-defined type guards
343 listItemNode.getNextSibling<ListItemNode>() as ListItemNode;
344 const previousSibling =
345 listItemNode.getPreviousSibling<ListItemNode>() as ListItemNode;
346 // if there are nested lists on either side, merge them all together.
348 if (isNestedListNode(nextSibling) && isNestedListNode(previousSibling)) {
349 const innerList = previousSibling.getFirstChild();
351 if ($isListNode(innerList)) {
352 innerList.append(listItemNode);
353 const nextInnerList = nextSibling.getFirstChild();
355 if ($isListNode(nextInnerList)) {
356 const children = nextInnerList.getChildren();
357 append(innerList, children);
358 nextSibling.remove();
359 removed.add(nextSibling.getKey());
362 } else if (isNestedListNode(nextSibling)) {
363 // if the ListItemNode is next to a nested ListNode, merge them
364 const innerList = nextSibling.getFirstChild();
366 if ($isListNode(innerList)) {
367 const firstChild = innerList.getFirstChild();
369 if (firstChild !== null) {
370 firstChild.insertBefore(listItemNode);
373 } else if (isNestedListNode(previousSibling)) {
374 const innerList = previousSibling.getFirstChild();
376 if ($isListNode(innerList)) {
377 innerList.append(listItemNode);
380 // otherwise, we need to create a new nested ListNode
382 if ($isListNode(parent)) {
383 const newListItem = $createListItemNode();
384 const newList = $createListNode(parent.getListType());
385 newListItem.append(newList);
386 newList.append(listItemNode);
388 if (previousSibling) {
389 previousSibling.insertAfter(newListItem);
390 } else if (nextSibling) {
391 nextSibling.insertBefore(newListItem);
393 parent.append(newListItem);
400 * Removes an indent by removing an empty ListNode/ListItemNode chain. An indented ListItemNode
401 * has a great grandparent node of type ListNode, which is where the ListItemNode will reside
403 * @param listItemNode - The ListItemNode to remove the indent (outdent).
405 export function $handleOutdent(listItemNode: ListItemNode): void {
406 // go through each node and decide where to move it.
408 if (isNestedListNode(listItemNode)) {
411 const parentList = listItemNode.getParent();
412 const grandparentListItem = parentList ? parentList.getParent() : undefined;
413 const greatGrandparentList = grandparentListItem
414 ? grandparentListItem.getParent()
416 // If it doesn't have these ancestors, it's not indented.
419 $isListNode(greatGrandparentList) &&
420 $isListItemNode(grandparentListItem) &&
421 $isListNode(parentList)
423 // if it's the first child in it's parent list, insert it into the
424 // great grandparent list before the grandparent
425 const firstChild = parentList ? parentList.getFirstChild() : undefined;
426 const lastChild = parentList ? parentList.getLastChild() : undefined;
428 if (listItemNode.is(firstChild)) {
429 grandparentListItem.insertBefore(listItemNode);
431 if (parentList.isEmpty()) {
432 grandparentListItem.remove();
434 // if it's the last child in it's parent list, insert it into the
435 // great grandparent list after the grandparent.
436 } else if (listItemNode.is(lastChild)) {
437 grandparentListItem.insertAfter(listItemNode);
439 if (parentList.isEmpty()) {
440 grandparentListItem.remove();
443 // otherwise, we need to split the siblings into two new nested lists
444 const listType = parentList.getListType();
445 const previousSiblingsListItem = $createListItemNode();
446 const previousSiblingsList = $createListNode(listType);
447 previousSiblingsListItem.append(previousSiblingsList);
449 .getPreviousSiblings()
450 .forEach((sibling) => previousSiblingsList.append(sibling));
451 const nextSiblingsListItem = $createListItemNode();
452 const nextSiblingsList = $createListNode(listType);
453 nextSiblingsListItem.append(nextSiblingsList);
454 append(nextSiblingsList, listItemNode.getNextSiblings());
455 // put the sibling nested lists on either side of the grandparent list item in the great grandparent.
456 grandparentListItem.insertBefore(previousSiblingsListItem);
457 grandparentListItem.insertAfter(nextSiblingsListItem);
458 // replace the grandparent list item (now between the siblings) with the outdented list item.
459 grandparentListItem.replace(listItemNode);
465 * Attempts to insert a ParagraphNode at selection and selects the new node. The selection must contain a ListItemNode
466 * or a node that does not already contain text. If its grandparent is the root/shadow root, it will get the ListNode
467 * (which should be the parent node) and insert the ParagraphNode as a sibling to the ListNode. If the ListNode is
468 * nested in a ListItemNode instead, it will add the ParagraphNode after the grandparent ListItemNode.
469 * Throws an invariant if the selection is not a child of a ListNode.
470 * @returns true if a ParagraphNode was inserted succesfully, false if there is no selection
471 * or the selection does not contain a ListItemNode or the node already holds text.
473 export function $handleListInsertParagraph(): boolean {
474 const selection = $getSelection();
476 if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
479 // Only run this code on empty list items
480 const anchor = selection.anchor.getNode();
482 if (!$isListItemNode(anchor) || anchor.getChildrenSize() !== 0) {
485 const topListNode = $getTopListNode(anchor);
486 const parent = anchor.getParent();
490 'A ListItemNode must have a ListNode for a parent.',
493 const grandparent = parent.getParent();
497 if ($isRootOrShadowRoot(grandparent)) {
498 replacementNode = $createParagraphNode();
499 topListNode.insertAfter(replacementNode);
500 } else if ($isListItemNode(grandparent)) {
501 replacementNode = $createListItemNode();
502 grandparent.insertAfter(replacementNode);
506 replacementNode.select();
508 const nextSiblings = anchor.getNextSiblings();
510 if (nextSiblings.length > 0) {
511 const newList = $createListNode(parent.getListType());
513 if ($isParagraphNode(replacementNode)) {
514 replacementNode.insertAfter(newList);
516 const newListItem = $createListItemNode();
517 newListItem.append(newList);
518 replacementNode.insertAfter(newListItem);
520 nextSiblings.forEach((sibling) => {
522 newList.append(sibling);
526 // Don't leave hanging nested empty lists
527 $removeHighestEmptyListParent(anchor);