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.setFormat(serializedNode.format);
130 node.setDirection(serializedNode.direction);
134 exportDOM(editor: LexicalEditor): DOMExportOutput {
135 const element = this.createDOM(editor._config);
136 element.style.textAlign = this.getFormatType();
142 exportJSON(): SerializedListItemNode {
144 ...super.exportJSON(),
145 checked: this.getChecked(),
147 value: this.getValue(),
152 append(...nodes: LexicalNode[]): this {
153 for (let i = 0; i < nodes.length; i++) {
154 const node = nodes[i];
156 if ($isElementNode(node) && this.canMergeWith(node)) {
157 const children = node.getChildren();
158 this.append(...children);
168 replace<N extends LexicalNode>(
170 includeChildren?: boolean,
172 if ($isListItemNode(replaceWithNode)) {
173 return super.replace(replaceWithNode);
176 const list = this.getParentOrThrow();
177 if (!$isListNode(list)) {
178 return replaceWithNode;
180 if (list.__first === this.getKey()) {
181 list.insertBefore(replaceWithNode);
182 } else if (list.__last === this.getKey()) {
183 list.insertAfter(replaceWithNode);
186 const newList = $createListNode(list.getListType());
187 let nextSibling = this.getNextSibling();
188 while (nextSibling) {
189 const nodeToAppend = nextSibling;
190 nextSibling = nextSibling.getNextSibling();
191 newList.append(nodeToAppend);
193 list.insertAfter(replaceWithNode);
194 replaceWithNode.insertAfter(newList);
196 if (includeChildren) {
198 $isElementNode(replaceWithNode),
199 'includeChildren should only be true for ElementNodes',
201 this.getChildren().forEach((child: LexicalNode) => {
202 replaceWithNode.append(child);
206 if (list.getChildrenSize() === 0) {
209 return replaceWithNode;
212 insertAfter(node: LexicalNode, restoreSelection = true): LexicalNode {
213 const listNode = this.getParentOrThrow();
215 if (!$isListNode(listNode)) {
218 'insertAfter: list node is not parent of list item node',
222 if ($isListItemNode(node)) {
223 return super.insertAfter(node, restoreSelection);
226 const siblings = this.getNextSiblings();
228 // Split the lists and insert the node in between them
229 listNode.insertAfter(node, restoreSelection);
231 if (siblings.length !== 0) {
232 const newListNode = $createListNode(listNode.getListType());
234 siblings.forEach((sibling) => newListNode.append(sibling));
236 node.insertAfter(newListNode, restoreSelection);
242 remove(preserveEmptyParent?: boolean): void {
243 const prevSibling = this.getPreviousSibling();
244 const nextSibling = this.getNextSibling();
245 super.remove(preserveEmptyParent);
250 isNestedListNode(prevSibling) &&
251 isNestedListNode(nextSibling)
253 mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild());
254 nextSibling.remove();
260 restoreSelection = true,
261 ): ListItemNode | ParagraphNode {
262 const newElement = $createListItemNode(
263 this.__checked == null ? undefined : false,
265 this.insertAfter(newElement, restoreSelection);
270 collapseAtStart(selection: RangeSelection): true {
271 const paragraph = $createParagraphNode();
272 const children = this.getChildren();
273 children.forEach((child) => paragraph.append(child));
274 const listNode = this.getParentOrThrow();
275 const listNodeParent = listNode.getParentOrThrow();
276 const isIndented = $isListItemNode(listNodeParent);
278 if (listNode.getChildrenSize() === 1) {
280 // if the list node is nested, we just want to remove it,
281 // effectively unindenting it.
283 listNodeParent.select();
285 listNode.insertBefore(paragraph);
287 // If we have selection on the list item, we'll need to move it
289 const anchor = selection.anchor;
290 const focus = selection.focus;
291 const key = paragraph.getKey();
293 if (anchor.type === 'element' && anchor.getNode().is(this)) {
294 anchor.set(key, anchor.offset, 'element');
297 if (focus.type === 'element' && focus.getNode().is(this)) {
298 focus.set(key, focus.offset, 'element');
302 listNode.insertBefore(paragraph);
310 const self = this.getLatest();
315 setValue(value: number): void {
316 const self = this.getWritable();
317 self.__value = value;
320 getChecked(): boolean | undefined {
321 const self = this.getLatest();
323 let listType: ListType | undefined;
325 const parent = this.getParent();
326 if ($isListNode(parent)) {
327 listType = parent.getListType();
330 return listType === 'check' ? Boolean(self.__checked) : undefined;
333 setChecked(checked?: boolean): void {
334 const self = this.getWritable();
335 self.__checked = checked;
338 toggleChecked(): void {
339 this.setChecked(!this.__checked);
342 getIndent(): number {
343 // If we don't have a parent, we are likely serializing
344 const parent = this.getParent();
345 if (parent === null) {
346 return this.getLatest().__indent;
348 // ListItemNode should always have a ListNode for a parent.
349 let listNodeParent = parent.getParentOrThrow();
351 while ($isListItemNode(listNodeParent)) {
352 listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow();
359 setIndent(indent: number): this {
360 invariant(typeof indent === 'number', 'Invalid indent value.');
361 indent = Math.floor(indent);
362 invariant(indent >= 0, 'Indent value must be non-negative.');
363 let currentIndent = this.getIndent();
364 while (currentIndent !== indent) {
365 if (currentIndent < indent) {
369 $handleOutdent(this);
377 /** @deprecated @internal */
378 canInsertAfter(node: LexicalNode): boolean {
379 return $isListItemNode(node);
382 /** @deprecated @internal */
383 canReplaceWith(replacement: LexicalNode): boolean {
384 return $isListItemNode(replacement);
387 canMergeWith(node: LexicalNode): boolean {
388 return $isParagraphNode(node) || $isListItemNode(node);
391 extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
392 if (!$isRangeSelection(selection)) {
396 const anchorNode = selection.anchor.getNode();
397 const focusNode = selection.focus.getNode();
400 this.isParentOf(anchorNode) &&
401 this.isParentOf(focusNode) &&
402 this.getTextContent().length === selection.getTextContent().length
406 isParentRequired(): true {
410 createParentElementNode(): ElementNode {
411 return $createListNode('bullet');
414 canMergeWhenEmpty(): true {
419 function $setListItemThemeClassNames(
421 editorThemeClasses: EditorThemeClasses,
424 const classesToAdd = [];
425 const classesToRemove = [];
426 const listTheme = editorThemeClasses.list;
427 const listItemClassName = listTheme ? listTheme.listitem : undefined;
428 let nestedListItemClassName;
430 if (listTheme && listTheme.nested) {
431 nestedListItemClassName = listTheme.nested.listitem;
434 if (listItemClassName !== undefined) {
435 classesToAdd.push(...normalizeClassNames(listItemClassName));
439 const parentNode = node.getParent();
441 $isListNode(parentNode) && parentNode.getListType() === 'check';
442 const checked = node.getChecked();
444 if (!isCheckList || checked) {
445 classesToRemove.push(listTheme.listitemUnchecked);
448 if (!isCheckList || !checked) {
449 classesToRemove.push(listTheme.listitemChecked);
454 checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
459 if (nestedListItemClassName !== undefined) {
460 const nestedListItemClasses = normalizeClassNames(nestedListItemClassName);
462 if (node.getChildren().some((child) => $isListNode(child))) {
463 classesToAdd.push(...nestedListItemClasses);
465 classesToRemove.push(...nestedListItemClasses);
469 if (classesToRemove.length > 0) {
470 removeClassNamesFromElement(dom, ...classesToRemove);
473 if (classesToAdd.length > 0) {
474 addClassNamesToElement(dom, ...classesToAdd);
478 function updateListItemChecked(
480 listItemNode: ListItemNode,
481 prevListItemNode: ListItemNode | null,
484 // Only add attributes for leaf list items
485 if ($isListNode(listItemNode.getFirstChild())) {
486 dom.removeAttribute('role');
487 dom.removeAttribute('tabIndex');
488 dom.removeAttribute('aria-checked');
490 dom.setAttribute('role', 'checkbox');
491 dom.setAttribute('tabIndex', '-1');
495 listItemNode.__checked !== prevListItemNode.__checked
499 listItemNode.getChecked() ? 'true' : 'false',
505 function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
506 const isGitHubCheckList = domNode.classList.contains('task-list-item');
507 if (isGitHubCheckList) {
508 for (const child of domNode.children) {
509 if (child.tagName === 'INPUT') {
510 return $convertCheckboxInput(child);
515 const ariaCheckedAttr = domNode.getAttribute('aria-checked');
517 ariaCheckedAttr === 'true'
519 : ariaCheckedAttr === 'false'
522 return {node: $createListItemNode(checked)};
525 function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
526 const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
527 if (!isCheckboxInput) {
530 const checked = domNode.hasAttribute('checked');
531 return {node: $createListItemNode(checked)};
535 * Creates a new List Item node, passing true/false will convert it to a checkbox input.
536 * @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.
537 * @returns The new List Item.
539 export function $createListItemNode(checked?: boolean): ListItemNode {
540 return $applyNodeReplacement(new ListItemNode(undefined, checked));
544 * Checks to see if the node is a ListItemNode.
545 * @param node - The node to be checked.
546 * @returns true if the node is a ListItemNode, false otherwise.
548 export function $isListItemNode(
549 node: LexicalNode | null | undefined,
550 ): node is ListItemNode {
551 return node instanceof ListItemNode;