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 './';
21 SerializedElementNode,
26 addClassNamesToElement,
27 removeClassNamesFromElement,
28 } from '@lexical/utils';
30 $applyNodeReplacement,
38 import invariant from 'lexical/shared/invariant';
39 import normalizeClassNames from 'lexical/shared/normalizeClassNames';
41 import {$createListNode, $isListNode} from './';
42 import {$handleIndent, $handleOutdent, mergeLists} from './formatList';
43 import {isNestedListNode} from './utils';
45 export type SerializedListItemNode = Spread<
47 checked: boolean | undefined;
54 export class ListItemNode extends ElementNode {
60 static getType(): string {
64 static clone(node: ListItemNode): ListItemNode {
65 return new ListItemNode(node.__value, node.__checked, node.__key);
68 constructor(value?: number, checked?: boolean, key?: NodeKey) {
70 this.__value = value === undefined ? 1 : value;
71 this.__checked = checked;
74 createDOM(config: EditorConfig): HTMLElement {
75 const element = document.createElement('li');
76 const parent = this.getParent();
77 if ($isListNode(parent) && parent.getListType() === 'check') {
78 updateListItemChecked(element, this, null, parent);
80 element.value = this.__value;
81 $setListItemThemeClassNames(element, config.theme, this);
86 prevNode: ListItemNode,
90 const parent = this.getParent();
91 if ($isListNode(parent) && parent.getListType() === 'check') {
92 updateListItemChecked(dom, this, prevNode, parent);
94 // @ts-expect-error - this is always HTMLListItemElement
95 dom.value = this.__value;
96 $setListItemThemeClassNames(dom, config.theme, this);
101 static transform(): (node: LexicalNode) => void {
102 return (node: LexicalNode) => {
103 invariant($isListItemNode(node), 'node is not a ListItemNode');
104 if (node.__checked == null) {
107 const parent = node.getParent();
108 if ($isListNode(parent)) {
109 if (parent.getListType() !== 'check' && node.getChecked() != null) {
110 node.setChecked(undefined);
116 static importDOM(): DOMConversionMap | null {
119 conversion: $convertListItemElement,
125 static importJSON(serializedNode: SerializedListItemNode): ListItemNode {
126 const node = $createListItemNode();
127 node.setChecked(serializedNode.checked);
128 node.setValue(serializedNode.value);
129 node.setDirection(serializedNode.direction);
133 exportDOM(editor: LexicalEditor): DOMExportOutput {
134 const element = this.createDOM(editor._config);
140 exportJSON(): SerializedListItemNode {
142 ...super.exportJSON(),
143 checked: this.getChecked(),
145 value: this.getValue(),
150 append(...nodes: LexicalNode[]): this {
151 for (let i = 0; i < nodes.length; i++) {
152 const node = nodes[i];
154 if ($isElementNode(node) && this.canMergeWith(node)) {
155 const children = node.getChildren();
156 this.append(...children);
166 replace<N extends LexicalNode>(
168 includeChildren?: boolean,
170 if ($isListItemNode(replaceWithNode)) {
171 return super.replace(replaceWithNode);
173 const list = this.getParentOrThrow();
174 if (!$isListNode(list)) {
175 return replaceWithNode;
177 if (list.__first === this.getKey()) {
178 list.insertBefore(replaceWithNode);
179 } else if (list.__last === this.getKey()) {
180 list.insertAfter(replaceWithNode);
183 const newList = $createListNode(list.getListType());
184 let nextSibling = this.getNextSibling();
185 while (nextSibling) {
186 const nodeToAppend = nextSibling;
187 nextSibling = nextSibling.getNextSibling();
188 newList.append(nodeToAppend);
190 list.insertAfter(replaceWithNode);
191 replaceWithNode.insertAfter(newList);
193 if (includeChildren) {
195 $isElementNode(replaceWithNode),
196 'includeChildren should only be true for ElementNodes',
198 this.getChildren().forEach((child: LexicalNode) => {
199 replaceWithNode.append(child);
203 if (list.getChildrenSize() === 0) {
206 return replaceWithNode;
209 insertAfter(node: LexicalNode, restoreSelection = true): LexicalNode {
210 const listNode = this.getParentOrThrow();
212 if (!$isListNode(listNode)) {
215 'insertAfter: list node is not parent of list item node',
219 if ($isListItemNode(node)) {
220 return super.insertAfter(node, restoreSelection);
223 const siblings = this.getNextSiblings();
225 // Split the lists and insert the node in between them
226 listNode.insertAfter(node, restoreSelection);
228 if (siblings.length !== 0) {
229 const newListNode = $createListNode(listNode.getListType());
231 siblings.forEach((sibling) => newListNode.append(sibling));
233 node.insertAfter(newListNode, restoreSelection);
239 remove(preserveEmptyParent?: boolean): void {
240 const prevSibling = this.getPreviousSibling();
241 const nextSibling = this.getNextSibling();
242 super.remove(preserveEmptyParent);
247 isNestedListNode(prevSibling) &&
248 isNestedListNode(nextSibling)
250 mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild());
251 nextSibling.remove();
257 restoreSelection = true,
258 ): ListItemNode | ParagraphNode {
260 if (this.getTextContent().trim() === '' && this.isLastChild()) {
261 const list = this.getParentOrThrow<ListNode>();
262 if (!$isListItemNode(list.getParent())) {
263 const paragraph = $createParagraphNode();
264 list.insertAfter(paragraph, restoreSelection);
270 const newElement = $createListItemNode(
271 this.__checked == null ? undefined : false,
274 this.insertAfter(newElement, restoreSelection);
279 collapseAtStart(selection: RangeSelection): true {
280 const paragraph = $createParagraphNode();
281 const children = this.getChildren();
282 children.forEach((child) => paragraph.append(child));
283 const listNode = this.getParentOrThrow();
284 const listNodeParent = listNode.getParentOrThrow();
285 const isIndented = $isListItemNode(listNodeParent);
287 if (listNode.getChildrenSize() === 1) {
289 // if the list node is nested, we just want to remove it,
290 // effectively unindenting it.
292 listNodeParent.select();
294 listNode.insertBefore(paragraph);
296 // If we have selection on the list item, we'll need to move it
298 const anchor = selection.anchor;
299 const focus = selection.focus;
300 const key = paragraph.getKey();
302 if (anchor.type === 'element' && anchor.getNode().is(this)) {
303 anchor.set(key, anchor.offset, 'element');
306 if (focus.type === 'element' && focus.getNode().is(this)) {
307 focus.set(key, focus.offset, 'element');
311 listNode.insertBefore(paragraph);
319 const self = this.getLatest();
324 setValue(value: number): void {
325 const self = this.getWritable();
326 self.__value = value;
329 getChecked(): boolean | undefined {
330 const self = this.getLatest();
332 let listType: ListType | undefined;
334 const parent = this.getParent();
335 if ($isListNode(parent)) {
336 listType = parent.getListType();
339 return listType === 'check' ? Boolean(self.__checked) : undefined;
342 setChecked(checked?: boolean): void {
343 const self = this.getWritable();
344 self.__checked = checked;
347 toggleChecked(): void {
348 this.setChecked(!this.__checked);
351 /** @deprecated @internal */
352 canInsertAfter(node: LexicalNode): boolean {
353 return $isListItemNode(node);
356 /** @deprecated @internal */
357 canReplaceWith(replacement: LexicalNode): boolean {
358 return $isListItemNode(replacement);
361 canMergeWith(node: LexicalNode): boolean {
362 return $isParagraphNode(node) || $isListItemNode(node);
365 extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
366 if (!$isRangeSelection(selection)) {
370 const anchorNode = selection.anchor.getNode();
371 const focusNode = selection.focus.getNode();
374 this.isParentOf(anchorNode) &&
375 this.isParentOf(focusNode) &&
376 this.getTextContent().length === selection.getTextContent().length
380 isParentRequired(): true {
384 createParentElementNode(): ElementNode {
385 return $createListNode('bullet');
388 canMergeWhenEmpty(): true {
393 function $setListItemThemeClassNames(
395 editorThemeClasses: EditorThemeClasses,
398 const classesToAdd = [];
399 const classesToRemove = [];
400 const listTheme = editorThemeClasses.list;
401 const listItemClassName = listTheme ? listTheme.listitem : undefined;
402 let nestedListItemClassName;
404 if (listTheme && listTheme.nested) {
405 nestedListItemClassName = listTheme.nested.listitem;
408 if (listItemClassName !== undefined) {
409 classesToAdd.push(...normalizeClassNames(listItemClassName));
413 const parentNode = node.getParent();
415 $isListNode(parentNode) && parentNode.getListType() === 'check';
416 const checked = node.getChecked();
418 if (!isCheckList || checked) {
419 classesToRemove.push(listTheme.listitemUnchecked);
422 if (!isCheckList || !checked) {
423 classesToRemove.push(listTheme.listitemChecked);
428 checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
433 if (nestedListItemClassName !== undefined) {
434 const nestedListItemClasses = normalizeClassNames(nestedListItemClassName);
436 if (node.getChildren().some((child) => $isListNode(child))) {
437 classesToAdd.push(...nestedListItemClasses);
439 classesToRemove.push(...nestedListItemClasses);
443 if (classesToRemove.length > 0) {
444 removeClassNamesFromElement(dom, ...classesToRemove);
447 if (classesToAdd.length > 0) {
448 addClassNamesToElement(dom, ...classesToAdd);
452 function updateListItemChecked(
454 listItemNode: ListItemNode,
455 prevListItemNode: ListItemNode | null,
458 // Only add attributes for leaf list items
459 if ($isListNode(listItemNode.getFirstChild())) {
460 dom.removeAttribute('role');
461 dom.removeAttribute('tabIndex');
462 dom.removeAttribute('aria-checked');
464 dom.setAttribute('role', 'checkbox');
465 dom.setAttribute('tabIndex', '-1');
469 listItemNode.__checked !== prevListItemNode.__checked
473 listItemNode.getChecked() ? 'true' : 'false',
479 function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
480 const isGitHubCheckList = domNode.classList.contains('task-list-item');
481 if (isGitHubCheckList) {
482 for (const child of domNode.children) {
483 if (child.tagName === 'INPUT') {
484 return $convertCheckboxInput(child);
489 const ariaCheckedAttr = domNode.getAttribute('aria-checked');
491 ariaCheckedAttr === 'true'
493 : ariaCheckedAttr === 'false'
496 return {node: $createListItemNode(checked)};
499 function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
500 const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
501 if (!isCheckboxInput) {
504 const checked = domNode.hasAttribute('checked');
505 return {node: $createListItemNode(checked)};
509 * Creates a new List Item node, passing true/false will convert it to a checkbox input.
510 * @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.
511 * @returns The new List Item.
513 export function $createListItemNode(checked?: boolean): ListItemNode {
514 return $applyNodeReplacement(new ListItemNode(undefined, checked));
518 * Checks to see if the node is a ListItemNode.
519 * @param node - The node to be checked.
520 * @returns true if the node is a ListItemNode, false otherwise.
522 export function $isListItemNode(
523 node: LexicalNode | null | undefined,
524 ): node is ListItemNode {
525 return node instanceof ListItemNode;