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 type {ListNode, ListType} from './';
20 SerializedElementNode,
25 $applyNodeReplacement,
33 import invariant from 'lexical/shared/invariant';
35 import {$createListNode, $isListNode} from './';
36 import {mergeLists} from './formatList';
37 import {isNestedListNode} from './utils';
38 import {el} from "../../utils/dom";
40 export type SerializedListItemNode = Spread<
42 checked: boolean | undefined;
49 export class ListItemNode extends ElementNode {
55 static getType(): string {
59 static clone(node: ListItemNode): ListItemNode {
60 return new ListItemNode(node.__value, node.__checked, node.__key);
63 constructor(value?: number, checked?: boolean, key?: NodeKey) {
65 this.__value = value === undefined ? 1 : value;
66 this.__checked = checked;
69 createDOM(config: EditorConfig): HTMLElement {
70 const element = document.createElement('li');
71 const parent = this.getParent();
73 if ($isListNode(parent) && parent.getListType() === 'check') {
74 updateListItemChecked(element, this);
77 element.value = this.__value;
79 if ($hasNestedListWithoutLabel(this)) {
80 element.style.listStyle = 'none';
87 prevNode: ListItemNode,
91 const parent = this.getParent();
92 if ($isListNode(parent) && parent.getListType() === 'check') {
93 updateListItemChecked(dom, this);
96 dom.style.listStyle = $hasNestedListWithoutLabel(this) ? 'none' : '';
97 // @ts-expect-error - this is always HTMLListItemElement
98 dom.value = this.__value;
103 static transform(): (node: LexicalNode) => void {
104 return (node: LexicalNode) => {
105 invariant($isListItemNode(node), 'node is not a ListItemNode');
106 if (node.__checked == null) {
109 const parent = node.getParent();
110 if ($isListNode(parent)) {
111 if (parent.getListType() !== 'check' && node.getChecked() != null) {
112 node.setChecked(undefined);
118 static importDOM(): DOMConversionMap | null {
121 conversion: $convertListItemElement,
127 static importJSON(serializedNode: SerializedListItemNode): ListItemNode {
128 const node = $createListItemNode();
129 node.setChecked(serializedNode.checked);
130 node.setValue(serializedNode.value);
131 node.setDirection(serializedNode.direction);
135 exportDOM(editor: LexicalEditor): DOMExportOutput {
136 const element = this.createDOM(editor._config);
138 if (element.classList.contains('task-list-item')) {
139 const input = el('input', {
141 disabled: 'disabled',
143 if (element.hasAttribute('checked')) {
144 input.setAttribute('checked', 'checked');
145 element.removeAttribute('checked');
148 element.prepend(input);
156 exportJSON(): SerializedListItemNode {
158 ...super.exportJSON(),
159 checked: this.getChecked(),
161 value: this.getValue(),
166 append(...nodes: LexicalNode[]): this {
167 for (let i = 0; i < nodes.length; i++) {
168 const node = nodes[i];
170 if ($isElementNode(node) && this.canMergeWith(node)) {
171 const children = node.getChildren();
172 this.append(...children);
182 replace<N extends LexicalNode>(
184 includeChildren?: boolean,
186 if ($isListItemNode(replaceWithNode)) {
187 return super.replace(replaceWithNode);
189 const list = this.getParentOrThrow();
190 if (!$isListNode(list)) {
191 return replaceWithNode;
193 if (list.__first === this.getKey()) {
194 list.insertBefore(replaceWithNode);
195 } else if (list.__last === this.getKey()) {
196 list.insertAfter(replaceWithNode);
199 const newList = $createListNode(list.getListType());
200 let nextSibling = this.getNextSibling();
201 while (nextSibling) {
202 const nodeToAppend = nextSibling;
203 nextSibling = nextSibling.getNextSibling();
204 newList.append(nodeToAppend);
206 list.insertAfter(replaceWithNode);
207 replaceWithNode.insertAfter(newList);
209 if (includeChildren) {
211 $isElementNode(replaceWithNode),
212 'includeChildren should only be true for ElementNodes',
214 this.getChildren().forEach((child: LexicalNode) => {
215 replaceWithNode.append(child);
219 if (list.getChildrenSize() === 0) {
222 return replaceWithNode;
225 insertAfter(node: LexicalNode, restoreSelection = true): LexicalNode {
226 const listNode = this.getParentOrThrow();
228 if (!$isListNode(listNode)) {
231 'insertAfter: list node is not parent of list item node',
235 if ($isListItemNode(node)) {
236 return super.insertAfter(node, restoreSelection);
239 const siblings = this.getNextSiblings();
241 // Split the lists and insert the node in between them
242 listNode.insertAfter(node, restoreSelection);
244 if (siblings.length !== 0) {
245 const newListNode = $createListNode(listNode.getListType());
247 siblings.forEach((sibling) => newListNode.append(sibling));
249 node.insertAfter(newListNode, restoreSelection);
255 remove(preserveEmptyParent?: boolean): void {
256 const prevSibling = this.getPreviousSibling();
257 const nextSibling = this.getNextSibling();
258 super.remove(preserveEmptyParent);
263 isNestedListNode(prevSibling) &&
264 isNestedListNode(nextSibling)
266 mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild());
267 nextSibling.remove();
273 restoreSelection = true,
274 ): ListItemNode | ParagraphNode | null {
276 if (this.getTextContent().trim() === '' && this.isLastChild()) {
277 const list = this.getParentOrThrow<ListNode>();
278 const parentListItem = list.getParent();
279 if ($isListItemNode(parentListItem)) {
280 // Un-nest list item if empty nested item
281 parentListItem.insertAfter(this);
285 // Insert empty paragraph after list if adding after last empty child
286 const paragraph = $createParagraphNode();
287 list.insertAfter(paragraph, restoreSelection);
293 const newElement = $createListItemNode(
294 this.__checked == null ? undefined : false,
297 this.insertAfter(newElement, restoreSelection);
302 collapseAtStart(selection: RangeSelection): true {
303 const paragraph = $createParagraphNode();
304 const children = this.getChildren();
305 children.forEach((child) => paragraph.append(child));
306 const listNode = this.getParentOrThrow();
307 const listNodeParent = listNode.getParentOrThrow();
308 const isIndented = $isListItemNode(listNodeParent);
310 if (listNode.getChildrenSize() === 1) {
312 // if the list node is nested, we just want to remove it,
313 // effectively unindenting it.
315 listNodeParent.select();
317 listNode.insertBefore(paragraph);
319 // If we have selection on the list item, we'll need to move it
321 const anchor = selection.anchor;
322 const focus = selection.focus;
323 const key = paragraph.getKey();
325 if (anchor.type === 'element' && anchor.getNode().is(this)) {
326 anchor.set(key, anchor.offset, 'element');
329 if (focus.type === 'element' && focus.getNode().is(this)) {
330 focus.set(key, focus.offset, 'element');
334 listNode.insertBefore(paragraph);
342 const self = this.getLatest();
347 setValue(value: number): void {
348 const self = this.getWritable();
349 self.__value = value;
352 getChecked(): boolean | undefined {
353 const self = this.getLatest();
355 let listType: ListType | undefined;
357 const parent = this.getParent();
358 if ($isListNode(parent)) {
359 listType = parent.getListType();
362 return listType === 'check' ? Boolean(self.__checked) : undefined;
365 setChecked(checked?: boolean): void {
366 const self = this.getWritable();
367 self.__checked = checked;
370 toggleChecked(): void {
371 this.setChecked(!this.__checked);
374 /** @deprecated @internal */
375 canInsertAfter(node: LexicalNode): boolean {
376 return $isListItemNode(node);
379 /** @deprecated @internal */
380 canReplaceWith(replacement: LexicalNode): boolean {
381 return $isListItemNode(replacement);
384 canMergeWith(node: LexicalNode): boolean {
385 return $isParagraphNode(node) || $isListItemNode(node);
388 extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
389 if (!$isRangeSelection(selection)) {
393 const anchorNode = selection.anchor.getNode();
394 const focusNode = selection.focus.getNode();
397 this.isParentOf(anchorNode) &&
398 this.isParentOf(focusNode) &&
399 this.getTextContent().length === selection.getTextContent().length
403 isParentRequired(): true {
407 createParentElementNode(): ElementNode {
408 return $createListNode('bullet');
411 canMergeWhenEmpty(): true {
416 function $hasNestedListWithoutLabel(node: ListItemNode): boolean {
417 const children = node.getChildren();
418 let hasLabel = false;
419 let hasNestedList = false;
421 for (const child of children) {
422 if ($isListNode(child)) {
423 hasNestedList = true;
424 } else if (child.getTextContent().trim().length > 0) {
429 return hasNestedList && !hasLabel;
432 function updateListItemChecked(
434 listItemNode: ListItemNode,
436 // Only set task list attrs for leaf list items
437 const shouldBeTaskItem = !$isListNode(listItemNode.getFirstChild());
438 dom.classList.toggle('task-list-item', shouldBeTaskItem);
439 if (listItemNode.__checked) {
440 dom.setAttribute('checked', 'checked');
442 dom.removeAttribute('checked');
446 function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
447 const isGitHubCheckList = domNode.classList.contains('task-list-item');
448 if (isGitHubCheckList) {
449 for (const child of domNode.children) {
450 if (child.tagName === 'INPUT') {
451 return $convertCheckboxInput(child);
456 const ariaCheckedAttr = domNode.getAttribute('aria-checked');
458 ariaCheckedAttr === 'true'
460 : ariaCheckedAttr === 'false'
463 return {node: $createListItemNode(checked)};
466 function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
467 const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
468 if (!isCheckboxInput) {
471 const checked = domNode.hasAttribute('checked');
472 return {node: $createListItemNode(checked)};
476 * Creates a new List Item node, passing true/false will convert it to a checkbox input.
477 * @param checked - Is the List Item a checkbox and, if so, is it checked? undefined/null: not a checkbox, true/false is a checkbox and checked/unchecked, respectively.
478 * @returns The new List Item.
480 export function $createListItemNode(checked?: boolean): ListItemNode {
481 return $applyNodeReplacement(new ListItemNode(undefined, checked));
485 * Checks to see if the node is a ListItemNode.
486 * @param node - The node to be checked.
487 * @returns true if the node is a ListItemNode, false otherwise.
489 export function $isListItemNode(
490 node: LexicalNode | null | undefined,
491 ): node is ListItemNode {
492 return node instanceof ListItemNode;