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 {
263 if (this.getTextContent().trim() === '' && this.isLastChild()) {
264 const list = this.getParentOrThrow<ListNode>();
265 if (!$isListItemNode(list.getParent())) {
266 const paragraph = $createParagraphNode();
267 list.insertAfter(paragraph, restoreSelection);
273 const newElement = $createListItemNode(
274 this.__checked == null ? undefined : false,
277 this.insertAfter(newElement, restoreSelection);
282 collapseAtStart(selection: RangeSelection): true {
283 const paragraph = $createParagraphNode();
284 const children = this.getChildren();
285 children.forEach((child) => paragraph.append(child));
286 const listNode = this.getParentOrThrow();
287 const listNodeParent = listNode.getParentOrThrow();
288 const isIndented = $isListItemNode(listNodeParent);
290 if (listNode.getChildrenSize() === 1) {
292 // if the list node is nested, we just want to remove it,
293 // effectively unindenting it.
295 listNodeParent.select();
297 listNode.insertBefore(paragraph);
299 // If we have selection on the list item, we'll need to move it
301 const anchor = selection.anchor;
302 const focus = selection.focus;
303 const key = paragraph.getKey();
305 if (anchor.type === 'element' && anchor.getNode().is(this)) {
306 anchor.set(key, anchor.offset, 'element');
309 if (focus.type === 'element' && focus.getNode().is(this)) {
310 focus.set(key, focus.offset, 'element');
314 listNode.insertBefore(paragraph);
322 const self = this.getLatest();
327 setValue(value: number): void {
328 const self = this.getWritable();
329 self.__value = value;
332 getChecked(): boolean | undefined {
333 const self = this.getLatest();
335 let listType: ListType | undefined;
337 const parent = this.getParent();
338 if ($isListNode(parent)) {
339 listType = parent.getListType();
342 return listType === 'check' ? Boolean(self.__checked) : undefined;
345 setChecked(checked?: boolean): void {
346 const self = this.getWritable();
347 self.__checked = checked;
350 toggleChecked(): void {
351 this.setChecked(!this.__checked);
354 getIndent(): number {
355 // If we don't have a parent, we are likely serializing
356 const parent = this.getParent();
357 if (parent === null) {
358 return this.getLatest().__indent;
360 // ListItemNode should always have a ListNode for a parent.
361 let listNodeParent = parent.getParentOrThrow();
363 while ($isListItemNode(listNodeParent)) {
364 listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow();
371 setIndent(indent: number): this {
372 invariant(typeof indent === 'number', 'Invalid indent value.');
373 indent = Math.floor(indent);
374 invariant(indent >= 0, 'Indent value must be non-negative.');
375 let currentIndent = this.getIndent();
376 while (currentIndent !== indent) {
377 if (currentIndent < indent) {
381 $handleOutdent(this);
389 /** @deprecated @internal */
390 canInsertAfter(node: LexicalNode): boolean {
391 return $isListItemNode(node);
394 /** @deprecated @internal */
395 canReplaceWith(replacement: LexicalNode): boolean {
396 return $isListItemNode(replacement);
399 canMergeWith(node: LexicalNode): boolean {
400 return $isParagraphNode(node) || $isListItemNode(node);
403 extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
404 if (!$isRangeSelection(selection)) {
408 const anchorNode = selection.anchor.getNode();
409 const focusNode = selection.focus.getNode();
412 this.isParentOf(anchorNode) &&
413 this.isParentOf(focusNode) &&
414 this.getTextContent().length === selection.getTextContent().length
418 isParentRequired(): true {
422 createParentElementNode(): ElementNode {
423 return $createListNode('bullet');
426 canMergeWhenEmpty(): true {
431 function $setListItemThemeClassNames(
433 editorThemeClasses: EditorThemeClasses,
436 const classesToAdd = [];
437 const classesToRemove = [];
438 const listTheme = editorThemeClasses.list;
439 const listItemClassName = listTheme ? listTheme.listitem : undefined;
440 let nestedListItemClassName;
442 if (listTheme && listTheme.nested) {
443 nestedListItemClassName = listTheme.nested.listitem;
446 if (listItemClassName !== undefined) {
447 classesToAdd.push(...normalizeClassNames(listItemClassName));
451 const parentNode = node.getParent();
453 $isListNode(parentNode) && parentNode.getListType() === 'check';
454 const checked = node.getChecked();
456 if (!isCheckList || checked) {
457 classesToRemove.push(listTheme.listitemUnchecked);
460 if (!isCheckList || !checked) {
461 classesToRemove.push(listTheme.listitemChecked);
466 checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
471 if (nestedListItemClassName !== undefined) {
472 const nestedListItemClasses = normalizeClassNames(nestedListItemClassName);
474 if (node.getChildren().some((child) => $isListNode(child))) {
475 classesToAdd.push(...nestedListItemClasses);
477 classesToRemove.push(...nestedListItemClasses);
481 if (classesToRemove.length > 0) {
482 removeClassNamesFromElement(dom, ...classesToRemove);
485 if (classesToAdd.length > 0) {
486 addClassNamesToElement(dom, ...classesToAdd);
490 function updateListItemChecked(
492 listItemNode: ListItemNode,
493 prevListItemNode: ListItemNode | null,
496 // Only add attributes for leaf list items
497 if ($isListNode(listItemNode.getFirstChild())) {
498 dom.removeAttribute('role');
499 dom.removeAttribute('tabIndex');
500 dom.removeAttribute('aria-checked');
502 dom.setAttribute('role', 'checkbox');
503 dom.setAttribute('tabIndex', '-1');
507 listItemNode.__checked !== prevListItemNode.__checked
511 listItemNode.getChecked() ? 'true' : 'false',
517 function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
518 const isGitHubCheckList = domNode.classList.contains('task-list-item');
519 if (isGitHubCheckList) {
520 for (const child of domNode.children) {
521 if (child.tagName === 'INPUT') {
522 return $convertCheckboxInput(child);
527 const ariaCheckedAttr = domNode.getAttribute('aria-checked');
529 ariaCheckedAttr === 'true'
531 : ariaCheckedAttr === 'false'
534 return {node: $createListItemNode(checked)};
537 function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
538 const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
539 if (!isCheckboxInput) {
542 const checked = domNode.hasAttribute('checked');
543 return {node: $createListItemNode(checked)};
547 * Creates a new List Item node, passing true/false will convert it to a checkbox input.
548 * @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.
549 * @returns The new List Item.
551 export function $createListItemNode(checked?: boolean): ListItemNode {
552 return $applyNodeReplacement(new ListItemNode(undefined, checked));
556 * Checks to see if the node is a ListItemNode.
557 * @param node - The node to be checked.
558 * @returns true if the node is a ListItemNode, false otherwise.
560 export function $isListItemNode(
561 node: LexicalNode | null | undefined,
562 ): node is ListItemNode {
563 return node instanceof ListItemNode;