resources/js/wysiwyg/lexical/list/LexicalListItemNode.ts
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {ListNode, ListType} from './';
import type {
BaseSelection,
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
EditorThemeClasses,
LexicalNode,
NodeKey,
ParagraphNode,
RangeSelection,
SerializedElementNode,
Spread,
} from 'lexical';
import {
addClassNamesToElement,
removeClassNamesFromElement,
} from '@lexical/utils';
import {
$applyNodeReplacement,
$createParagraphNode,
$isElementNode,
$isParagraphNode,
$isRangeSelection,
ElementNode,
LexicalEditor,
} from 'lexical';
import invariant from 'lexical/shared/invariant';
import normalizeClassNames from 'lexical/shared/normalizeClassNames';
import {$createListNode, $isListNode} from './';
import {$handleIndent, $handleOutdent, mergeLists} from './formatList';
import {isNestedListNode} from './utils';
export type SerializedListItemNode = Spread<
{
checked: boolean | undefined;
value: number;
},
SerializedElementNode
>;
/** @noInheritDoc */
export class ListItemNode extends ElementNode {
/** @internal */
__value: number;
/** @internal */
__checked?: boolean;
static getType(): string {
return 'listitem';
}
static clone(node: ListItemNode): ListItemNode {
return new ListItemNode(node.__value, node.__checked, node.__key);
}
constructor(value?: number, checked?: boolean, key?: NodeKey) {
super(key);
this.__value = value === undefined ? 1 : value;
this.__checked = checked;
}
createDOM(config: EditorConfig): HTMLElement {
const element = document.createElement('li');
const parent = this.getParent();
if ($isListNode(parent) && parent.getListType() === 'check') {
updateListItemChecked(element, this, null, parent);
}
element.value = this.__value;
$setListItemThemeClassNames(element, config.theme, this);
return element;
}
updateDOM(
prevNode: ListItemNode,
dom: HTMLElement,
config: EditorConfig,
): boolean {
const parent = this.getParent();
if ($isListNode(parent) && parent.getListType() === 'check') {
updateListItemChecked(dom, this, prevNode, parent);
}
// @ts-expect-error - this is always HTMLListItemElement
dom.value = this.__value;
$setListItemThemeClassNames(dom, config.theme, this);
return false;
}
static transform(): (node: LexicalNode) => void {
return (node: LexicalNode) => {
invariant($isListItemNode(node), 'node is not a ListItemNode');
if (node.__checked == null) {
return;
}
const parent = node.getParent();
if ($isListNode(parent)) {
if (parent.getListType() !== 'check' && node.getChecked() != null) {
node.setChecked(undefined);
}
}
};
}
static importDOM(): DOMConversionMap | null {
return {
li: () => ({
conversion: $convertListItemElement,
priority: 0,
}),
};
}
static importJSON(serializedNode: SerializedListItemNode): ListItemNode {
const node = $createListItemNode();
node.setChecked(serializedNode.checked);
node.setValue(serializedNode.value);
node.setFormat(serializedNode.format);
node.setDirection(serializedNode.direction);
return node;
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const element = this.createDOM(editor._config);
element.style.textAlign = this.getFormatType();
return {
element,
};
}
exportJSON(): SerializedListItemNode {
return {
...super.exportJSON(),
checked: this.getChecked(),
type: 'listitem',
value: this.getValue(),
version: 1,
};
}
append(...nodes: LexicalNode[]): this {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if ($isElementNode(node) && this.canMergeWith(node)) {
const children = node.getChildren();
this.append(...children);
node.remove();
} else {
super.append(node);
}
}
return this;
}
replace<N extends LexicalNode>(
replaceWithNode: N,
includeChildren?: boolean,
): N {
if ($isListItemNode(replaceWithNode)) {
return super.replace(replaceWithNode);
}
this.setIndent(0);
const list = this.getParentOrThrow();
if (!$isListNode(list)) {
return replaceWithNode;
}
if (list.__first === this.getKey()) {
list.insertBefore(replaceWithNode);
} else if (list.__last === this.getKey()) {
list.insertAfter(replaceWithNode);
} else {
// Split the list
const newList = $createListNode(list.getListType());
let nextSibling = this.getNextSibling();
while (nextSibling) {
const nodeToAppend = nextSibling;
nextSibling = nextSibling.getNextSibling();
newList.append(nodeToAppend);
}
list.insertAfter(replaceWithNode);
replaceWithNode.insertAfter(newList);
}
if (includeChildren) {
invariant(
$isElementNode(replaceWithNode),
'includeChildren should only be true for ElementNodes',
);
this.getChildren().forEach((child: LexicalNode) => {
replaceWithNode.append(child);
});
}
this.remove();
if (list.getChildrenSize() === 0) {
list.remove();
}
return replaceWithNode;
}
insertAfter(node: LexicalNode, restoreSelection = true): LexicalNode {
const listNode = this.getParentOrThrow();
if (!$isListNode(listNode)) {
invariant(
false,
'insertAfter: list node is not parent of list item node',
);
}
if ($isListItemNode(node)) {
return super.insertAfter(node, restoreSelection);
}
const siblings = this.getNextSiblings();
// Split the lists and insert the node in between them
listNode.insertAfter(node, restoreSelection);
if (siblings.length !== 0) {
const newListNode = $createListNode(listNode.getListType());
siblings.forEach((sibling) => newListNode.append(sibling));
node.insertAfter(newListNode, restoreSelection);
}
return node;
}
remove(preserveEmptyParent?: boolean): void {
const prevSibling = this.getPreviousSibling();
const nextSibling = this.getNextSibling();
super.remove(preserveEmptyParent);
if (
prevSibling &&
nextSibling &&
isNestedListNode(prevSibling) &&
isNestedListNode(nextSibling)
) {
mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild());
nextSibling.remove();
}
}
insertNewAfter(
_: RangeSelection,
restoreSelection = true,
): ListItemNode | ParagraphNode {
if (this.getTextContent().trim() === '' && this.isLastChild()) {
const list = this.getParentOrThrow<ListNode>();
if (!$isListItemNode(list.getParent())) {
const paragraph = $createParagraphNode();
list.insertAfter(paragraph, restoreSelection);
this.remove();
return paragraph;
}
}
const newElement = $createListItemNode(
this.__checked == null ? undefined : false,
);
this.insertAfter(newElement, restoreSelection);
return newElement;
}
collapseAtStart(selection: RangeSelection): true {
const paragraph = $createParagraphNode();
const children = this.getChildren();
children.forEach((child) => paragraph.append(child));
const listNode = this.getParentOrThrow();
const listNodeParent = listNode.getParentOrThrow();
const isIndented = $isListItemNode(listNodeParent);
if (listNode.getChildrenSize() === 1) {
if (isIndented) {
// if the list node is nested, we just want to remove it,
// effectively unindenting it.
listNode.remove();
listNodeParent.select();
} else {
listNode.insertBefore(paragraph);
listNode.remove();
// If we have selection on the list item, we'll need to move it
// to the paragraph
const anchor = selection.anchor;
const focus = selection.focus;
const key = paragraph.getKey();
if (anchor.type === 'element' && anchor.getNode().is(this)) {
anchor.set(key, anchor.offset, 'element');
}
if (focus.type === 'element' && focus.getNode().is(this)) {
focus.set(key, focus.offset, 'element');
}
}
} else {
listNode.insertBefore(paragraph);
this.remove();
}
return true;
}
getValue(): number {
const self = this.getLatest();
return self.__value;
}
setValue(value: number): void {
const self = this.getWritable();
self.__value = value;
}
getChecked(): boolean | undefined {
const self = this.getLatest();
let listType: ListType | undefined;
const parent = this.getParent();
if ($isListNode(parent)) {
listType = parent.getListType();
}
return listType === 'check' ? Boolean(self.__checked) : undefined;
}
setChecked(checked?: boolean): void {
const self = this.getWritable();
self.__checked = checked;
}
toggleChecked(): void {
this.setChecked(!this.__checked);
}
getIndent(): number {
// If we don't have a parent, we are likely serializing
const parent = this.getParent();
if (parent === null) {
return this.getLatest().__indent;
}
// ListItemNode should always have a ListNode for a parent.
let listNodeParent = parent.getParentOrThrow();
let indentLevel = 0;
while ($isListItemNode(listNodeParent)) {
listNodeParent = listNodeParent.getParentOrThrow().getParentOrThrow();
indentLevel++;
}
return indentLevel;
}
setIndent(indent: number): this {
invariant(typeof indent === 'number', 'Invalid indent value.');
indent = Math.floor(indent);
invariant(indent >= 0, 'Indent value must be non-negative.');
let currentIndent = this.getIndent();
while (currentIndent !== indent) {
if (currentIndent < indent) {
$handleIndent(this);
currentIndent++;
} else {
$handleOutdent(this);
currentIndent--;
}
}
return this;
}
/** @deprecated @internal */
canInsertAfter(node: LexicalNode): boolean {
return $isListItemNode(node);
}
/** @deprecated @internal */
canReplaceWith(replacement: LexicalNode): boolean {
return $isListItemNode(replacement);
}
canMergeWith(node: LexicalNode): boolean {
return $isParagraphNode(node) || $isListItemNode(node);
}
extractWithChild(child: LexicalNode, selection: BaseSelection): boolean {
if (!$isRangeSelection(selection)) {
return false;
}
const anchorNode = selection.anchor.getNode();
const focusNode = selection.focus.getNode();
return (
this.isParentOf(anchorNode) &&
this.isParentOf(focusNode) &&
this.getTextContent().length === selection.getTextContent().length
);
}
isParentRequired(): true {
return true;
}
createParentElementNode(): ElementNode {
return $createListNode('bullet');
}
canMergeWhenEmpty(): true {
return true;
}
}
function $setListItemThemeClassNames(
dom: HTMLElement,
editorThemeClasses: EditorThemeClasses,
node: ListItemNode,
): void {
const classesToAdd = [];
const classesToRemove = [];
const listTheme = editorThemeClasses.list;
const listItemClassName = listTheme ? listTheme.listitem : undefined;
let nestedListItemClassName;
if (listTheme && listTheme.nested) {
nestedListItemClassName = listTheme.nested.listitem;
}
if (listItemClassName !== undefined) {
classesToAdd.push(...normalizeClassNames(listItemClassName));
}
if (listTheme) {
const parentNode = node.getParent();
const isCheckList =
$isListNode(parentNode) && parentNode.getListType() === 'check';
const checked = node.getChecked();
if (!isCheckList || checked) {
classesToRemove.push(listTheme.listitemUnchecked);
}
if (!isCheckList || !checked) {
classesToRemove.push(listTheme.listitemChecked);
}
if (isCheckList) {
classesToAdd.push(
checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
);
}
}
if (nestedListItemClassName !== undefined) {
const nestedListItemClasses = normalizeClassNames(nestedListItemClassName);
if (node.getChildren().some((child) => $isListNode(child))) {
classesToAdd.push(...nestedListItemClasses);
} else {
classesToRemove.push(...nestedListItemClasses);
}
}
if (classesToRemove.length > 0) {
removeClassNamesFromElement(dom, ...classesToRemove);
}
if (classesToAdd.length > 0) {
addClassNamesToElement(dom, ...classesToAdd);
}
}
function updateListItemChecked(
dom: HTMLElement,
listItemNode: ListItemNode,
prevListItemNode: ListItemNode | null,
listNode: ListNode,
): void {
// Only add attributes for leaf list items
if ($isListNode(listItemNode.getFirstChild())) {
dom.removeAttribute('role');
dom.removeAttribute('tabIndex');
dom.removeAttribute('aria-checked');
} else {
dom.setAttribute('role', 'checkbox');
dom.setAttribute('tabIndex', '-1');
if (
!prevListItemNode ||
listItemNode.__checked !== prevListItemNode.__checked
) {
dom.setAttribute(
'aria-checked',
listItemNode.getChecked() ? 'true' : 'false',
);
}
}
}
function $convertListItemElement(domNode: HTMLElement): DOMConversionOutput {
const isGitHubCheckList = domNode.classList.contains('task-list-item');
if (isGitHubCheckList) {
for (const child of domNode.children) {
if (child.tagName === 'INPUT') {
return $convertCheckboxInput(child);
}
}
}
const ariaCheckedAttr = domNode.getAttribute('aria-checked');
const checked =
ariaCheckedAttr === 'true'
? true
: ariaCheckedAttr === 'false'
? false
: undefined;
return {node: $createListItemNode(checked)};
}
function $convertCheckboxInput(domNode: Element): DOMConversionOutput {
const isCheckboxInput = domNode.getAttribute('type') === 'checkbox';
if (!isCheckboxInput) {
return {node: null};
}
const checked = domNode.hasAttribute('checked');
return {node: $createListItemNode(checked)};
}
/**
* Creates a new List Item node, passing true/false will convert it to a checkbox input.
* @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.
* @returns The new List Item.
*/
export function $createListItemNode(checked?: boolean): ListItemNode {
return $applyNodeReplacement(new ListItemNode(undefined, checked));
}
/**
* Checks to see if the node is a ListItemNode.
* @param node - The node to be checked.
* @returns true if the node is a ListItemNode, false otherwise.
*/
export function $isListItemNode(
node: LexicalNode | null | undefined,
): node is ListItemNode {
return node instanceof ListItemNode;
}