列表节点

lexical
Created 7/17/2023
Updated 8/2/2023

列表节点

Lexical 推出了一个模块包 @lexical/list 提供了一个自定义节点 LinkNode 链接节点

参考

该模块导出了两个自定义节点 ListNodeListItemNode 分别对应于列表和列表项,以及一些相关的方法和 type 类型

ListNode

列表节点 ListNode 有三种类型

ts
export type ListType = 'number' | 'bullet' | 'check';
// 'number' 类型对应于有序列表
// 'bullet' 类型对应于无序列表
// 'check' 类型对应于待办事项(列表)

列表节点对应的 HTML 元素有两类 <ul> 无序列表元素和 <ol> 有序列表元素

ts
export type ListNodeTagType = 'ul' | 'ol';

这两种类型有默认/预设的对照关系

ts
const TAG_TO_LIST_TYPE: Record<string, ListType> = {
  ol: 'number',
  ul: 'bullet',
};

以下是 ListNode 类的定义,它继承自 ElementNode 元素节点

ts
import { $createListItemNode, $isListItemNode } from './LexicalListItemNode';
import { updateChildrenListItemValue } from './formatList';

export class ListNode extends ElementNode {
  // 列表节点的一些属性(内部访问)
  /** @internal */
  __tag: ListNodeTagType; // 所对应的 HTML 标签类型,可以是 'ul' | 'ol'
  /** @internal */
  __start: number; // 针对有序列表 <ol>,设置编号的开始数值
  /** @internal */
  __listType: ListType; // 节点的具体类型,可以是 'number' | 'bullet' | 'check'


  /**
   * 所有节点都有以下两个静态方法
   * static getType()
   * static clone()
   */
  // 获取该自定义节点的名称
  static getType(): string {
    return 'list';
  }

  // 创建拷贝
  static clone(node: ListNode): ListNode {
    // 获取节点的类型 listType
    // 如果节点原本没有 listType 属性,就根据标签类型来推导(采用默认的映射关系)
    // 如果是 <ol> 标签,默认映射为 'number' 类型
    // 如果是 <ul> 标签,默认映射为 'bullet' 类型
    const listType = node.__listType || TAG_TO_LIST_TYPE[node.__tag];

    return new ListNode(listType, node.__start, node.__key);
  }

  /**
   * 实例化
   */
  constructor(listType: ListType, start: number, key?: NodeKey) {
    super(key);
    const _listType = TAG_TO_LIST_TYPE[listType] || listType;
    this.__listType = _listType;
    // HTML 标签与 ListNode 类型的对应关系
    // 只有 'number' 类型的 ListNode 对应于 <ol> 标签,其他类型的 ListNode 对应于 <ul> 标签
    this.__tag = _listType === 'number' ? 'ol' : 'ul';
    this.__start = start; // 针对 'number' 有序列表的属性
  }

  // 列表节点具有一些属性(带双下划线 __property 表示属性仅供内部访问)
  // 为了可以提供外部访问和修改的权限
  // 需要分别为这些属性设置 get 和 set 方法
  // ⚠️ 为了确保 Lexical 的 Editor State 编辑器状态满足不可变性 immutable,在读取和修改节点的属性值时,应该调用 Lexical 所提供的方法 getLatest() 和 getWritable()
  getTag(): ListNodeTagType {
    return this.__tag;
    // 应该采用 this.getLatest().__tag ❓
  }

  setListType(type: ListType): void {
    const writable = this.getWritable();
    writable.__listType = type;
    // 设置/更改 ListNode 的类型之后
    // 需要同时更新对应的 HTML 标签类型
    writable.__tag = type === 'number' ? 'ol' : 'ul';
  }

  getListType(): ListType {
    return this.__listType;
  }

  getStart(): number {
    return this.__start;
  }

  /**
   * View
   * 与视图相关的方法
   */
  // 该节点在页面上所对应的 DOM 元素结构
  createDOM(config: EditorConfig, _editor?: LexicalEditor): HTMLElement {
    // 基于该节点的属性 __tag 创建相应的 DOM
    const tag = this.__tag;
    const dom = document.createElement(tag);

    // 如果节点属性 __start 不是默认值 1️
    // 则为 DOM 设置属性 start
    if (this.__start !== 1) {
      dom.setAttribute('start', String(this.__start));
    }

    // 为 DOM 对象添加属性 __lexicalListType
    // 以存储对应的 ListNode 节点的属性 __listType ❓
    // @ts-expect-error Internal field.
    dom.__lexicalListType = this.__listType;
    // 为 DOM 元素添加在编辑器主题中 config.theme 所设置的 class 类名
    setListThemeClassNames(dom, config.theme, this);

    return dom;
  }

  // 更新节点
  updateDOM(
    prevNode: ListNode,
    dom: HTMLElement,
    config: EditorConfig,
  ): boolean {
    if (prevNode.__tag !== this.__tag) {
      // 只有当前节点的标签类型 tag 与之前的节点的标签类型不同时
      // 即列表节点所对应的 DOM 标签类型
      // 例如从 <ol> 更新为 <ul>
      // 才需要重新调用 createDOM() 创建一个新的 DOM 元素来取代页面上原有的旧元素
      return true;
    }

    // 更新 DOM 元素的 class 类名(例如改变缩进深度)
    setListThemeClassNames(dom, config.theme, this);

    // 如果列表节点所对应的 DOM 标签类型并没有改变,则不需要重新调用 createDOM() 创建一个新的 DOM 元素
    return false;
  }

  // 将 DOM 元素反序列化为节点时调用的方法(静态方法)
  // 需要分别针对 <ol> 和 <ul> 两种不同的 DOM 元素
  static importDOM(): DOMConversionMap | null {
    return {
      ol: (node: Node) => ({
        conversion: convertListNode, // 💡 所使用的转换函数,具体可以查看后文
        priority: 0,
      }),
      ul: (node: Node) => ({
        conversion: convertListNode, // 💡 所使用的转换函数,具体可以查看后文
        priority: 0,
      }),
    };
  }

  // 将 JSON 数据反序列化为节点时调用的方法(静态方法)
  static importJSON(serializedNode: SerializedListNode): ListNode {
    // 💡 使用方法 $createListNode() 创建列表节点,具体可以查看后文
    const node = $createListNode(serializedNode.listType, serializedNode.start);
    // 并调用(父类)ElementNode 元素节点的相应方法设置节点的相应属性
    node.setFormat(serializedNode.format);
    node.setIndent(serializedNode.indent);
    node.setDirection(serializedNode.direction);
    return node;
  }

  // 节点序列化为 DOM 元素(字符串)时调用的方法
  exportDOM(editor: LexicalEditor): DOMExportOutput {
    // 先采用祖先类 LexicalNode 的方法 exportDOM() 进行序列化
    // 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalNode.ts#L773-L776
    const {element} = super.exportDOM(editor);

    // 根据 element 的特点进行一些处理
    if (element) {
      if (this.__start !== 1) {
        // 如果列表节点的属性 __start 并不是默认值,则需要设置 DOM 元素的 start 属性
        element.setAttribute('start', String(this.__start));
      }
      if (this.__listType === 'check') {
        // 如果列表节点的类型是 'check'
        // 则需要设置 DOM 元素的特性 '__lexicalListType' 为 'check'
        element.setAttribute('__lexicalListType', 'check');
      }
    }
    return {
      element,
    };
  }

  // 节点序列化为 JSON 格式时调用的方法
  exportJSON(): SerializedListNode {
    return {
      // 这里先使用了父类 ElementNode 元素节点的 exportJSON() 方法
      // 并进行扩展,覆盖了属性 type 和 version
      // 添加了一些其他属性 listType、start、tag,以便储存列表节点的相关信息
      ...super.exportJSON(),
      listType: this.getListType(),
      start: this.getStart(),
      tag: this.getTag(),
      type: 'list',
      version: 1,
    };
  }

  /**
   * Mutation
   * 与节点变换相关的方法
   */
  // 该节点不能为空(要有子节点,一般为文本节点)
  canBeEmpty(): false {
    return false;
  }

  // 能否缩进 ❓
  canIndent(): false {
    return false;
  }

  // 覆写父节点(ElementNode 元素节点)的 append 方法
  // 在列表节点中插入其他节点时需要遵循不同的规则
  // 确保列表节点的**直接子节点都是列表项节点**
  // 入参是需要插入的一系列节点所构成数组
  append(...nodesToAppend: LexicalNode[]): this {
    for (let i = 0; i < nodesToAppend.length; i++) {
      const currentNode = nodesToAppend[i];

      if ($isListItemNode(currentNode)) {
        // 如果当前所遍历的节点本身就是列表项节点
        // 则采用默认行为(父节点 ElementNode 的插入方法)
        super.append(currentNode);
      } else {
        // 如果当前所遍历的节点不是列表项节点
        // 则需要创建一个列表项节点将其「包裹」再插入到节点中
        const listItemNode = $createListItemNode();

        if ($isListNode(currentNode)) {
          // 如果当前所遍历的节点属于列表节点
          // 可以将其插入到前面所创建的列表项节点中
          listItemNode.append(currentNode);
        } else if ($isElementNode(currentNode)) {
          // 如果不是列表节点,但是为 ElementNode 元素节点
          // 则将其中的文本内容提取出来,并创建一个 TextNode 文本节点来承载它们
          // 相当于将 block 类型的节点转换为 inline 类型的节点(以便列表项进行「包裹」)
          const textNode = $createTextNode(currentNode.getTextContent());
          // 最后再用列表项节点将这个文本节点进行「包裹」
          listItemNode.append(textNode);
        } else {
          // 如果不是列表节点,且不是 ElementNode 节点
          // 可以直接插入到列表项节点中
          listItemNode.append(currentNode);
        }
        // 最后将列表项节点插入到该列表节点中
        super.append(listItemNode);
      }
    }

    // 当节点插入到列表节点后,需要调用方法 updateChildrenListItemValue()
    // 以更新其中的列表项的编号 value 值是正确的
    // 💡 该方法从 ./formatList 模块导出
    updateChildrenListItemValue(this);
    return this;
  }

  // 该方法用于 @lexical/clipboard 模块中
  // 以实现复制粘贴的相关功能
  // 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-clipboard/src/clipboard.ts#L489
  // 返回的是一个**布尔值**用于设置复制的行为,当复制该节点的子节点时(由于 ElementNode 元素节点一般不易选中),是否同时用该节点进行包裹
  // 如果返回 true 则复制时,会用该链接节点对选中的文本进行包裹,即相当于复制了一个链接节点;如果返回 false 则复制时,只是复制了选中的文本
  extractWithChild(child: LexicalNode): boolean {
    // 根据选中的内容是否为一个完整的列表项节点,来决定是否需要用列表节点对其进行包裹
    return $isListItemNode(child);
  }
}

前面所使用的方法 setListThemeClassNames() 是为列表节点所对应的 DOM 元素添加相应的 class 类名:

  • 以下变量 listClassName 存储了列表的通用类名,无序列表元素 <ul> 和有序列表元素 <ol> 所添加的 class 类名不同,支持一个字符串中设置多个 class 类名(用空格分隔)
  • 以下变量 listLevelClassName 存储了不同嵌套层级列表的样式类名,不同嵌套深度设置不同的 class 类名
  • 以下变量 nestedListClassName 存储了嵌套列表的样式类名,支持一个字符串中设置多个 class 类名(用空格分隔)

它的具体代码如下

ts
import {$getListDepth} from './utils';

function setListThemeClassNames(
  dom: HTMLElement,
  editorThemeClasses: EditorThemeClasses,
  node: ListNode,
): void {
  const classesToAdd = []; // 需要添加的 class 类名(数组)
  const classesToRemove = []; // 需要删除的 class 类名(数组)
  // 在 config.theme.list 中设置 class 类名
  const listTheme = editorThemeClasses.list;

  if (listTheme !== undefined) {
    // 在主题中通过 list.ulDepth 或 list.olDepth 属性为不同深度的(嵌套)列表设置 class 类名
    // 该属性值是一个数组,可以为不同的嵌套深度的列表节点设置不同的 class 类名
    const listLevelsClassNames = listTheme[`${node.__tag}Depth`] || [];
    // 获取当前列表节点所在深度
    const listDepth = $getListDepth(node) - 1;
    // 💡 标准化深度值:通过取余数,让深度值和 class 类名的映射关系可以更适用(更大的数值)
    const normalizedListDepth = listDepth % listLevelsClassNames.length;
    // 取出与当前节点的嵌套深度相匹配的 class 类名
    const listLevelClassName = listLevelsClassNames[normalizedListDepth];

    // 在主题中通过 list.ul 或 list.ol 属性为无序列表和有序列表设置 class 类名
    const listClassName = listTheme[node.__tag];

    let nestedListClassName;
    const nestedListTheme = listTheme.nested;

    if (nestedListTheme !== undefined && nestedListTheme.list) {
      // 通过 list.nested.list 属性设置嵌套列表节点的 class 类名
      // 💡 这比前面针对特定嵌套深度的 class 类名更通用,只要是嵌套的列表节点都会添加以下的类名
      nestedListClassName = nestedListTheme.list;
    }

    if (listClassName !== undefined) {
      classesToAdd.push(listClassName);
    }

    if (listLevelClassName !== undefined) {
      // 在 theme 中设置不同深度的列表的 class 类名时,支持多个(用空格分隔)
      // 这里通过 str.split(' ')(转换为数组)从字符串中提取出其中的一系列的 class 类名
      const listItemClasses = listLevelClassName.split(' ');
      classesToAdd.push(...listItemClasses);
      // ❓❓❓
      // 这里根据列表节点当前的嵌套深度,删掉 DOM 元素的一些 class 类名
      // 例如无序列表 depth = 2 时,删掉 ul0 和 ul1
      // 这个是之前版本的代码,并在后续的更新中删除
      // 参考 https://github.com/facebook/lexical/blob/e0080ccc6da521be53721baa073520cc83f87805/packages/lexical/src/extensions/LexicalListNode.js
      // 以及 https://github.com/facebook/lexical/blob/e0080ccc6da521be53721baa073520cc83f87805/packages/lexical/src/core/LexicalEditor.js#L60-L72
      for (let i = 0; i < listLevelsClassNames.length; i++) {
        if (i !== normalizedListDepth) {
          classesToRemove.push(node.__tag + i);
        }
      }
    }

    if (nestedListClassName !== undefined) {
      // 在 theme 中设置嵌套列表的 class 类名时,支持多个(用空格分隔)
      // 这里通过 str.split(' ')(转换为数组)从字符串中提取出其中的一系列的 class 类名
      const nestedListItemClasses = nestedListClassName.split(' ');

      // 根据当前列表的嵌套深度是否大于 1,而决定增/删专门针对嵌套列表而设置的 class 类名
      if (listDepth > 1) {
        classesToAdd.push(...nestedListItemClasses);
      } else {
        classesToRemove.push(...nestedListItemClasses);
      }
    }
  }

  if (classesToRemove.length > 0) {
    removeClassNamesFromElement(dom, ...classesToRemove);
  }

  if (classesToAdd.length > 0) {
    addClassNamesToElement(dom, ...classesToAdd);
  }
}

前面将 DOM 元素反序列化所使用的转换函数 convertListNode 的具体代码如下

ts
function convertListNode(domNode: Node): DOMConversionOutput {
  const nodeName = domNode.nodeName.toLowerCase();
  let node = null;
  if (nodeName === 'ol') {
    // 如果转换的 DOM 元素是 <ol> 有序列表,则读取它的 start 属性,以记录编号的开始数值
    // @ts-ignore
    const start = domNode.start;
    // 创建一个 'number' 类型的列表节点,并传入 start 参数
    node = $createListNode('number', start);
  } else if (nodeName === 'ul') {
    // 如果转换的 DOM 元素是 <ul> 无序列表
    // 根据 DOM 元素的特性 __lexicallisttype 的值是否为 'check' 来决定创建哪一种列表节点
    if (
      isHTMLElement(domNode) &&
      domNode.getAttribute('__lexicallisttype') === 'check'
    ) {
      // 如果 DOM 元素的特性 __lexicallisttype 值为 'check' 则创建一个 'check' 类型的列表节点
      node = $createListNode('check');
    } else {
      // 如果 DOM 元素的特性 __lexicallisttype 值为 'bullet' 则创建一个 'bullet' 类型的列表节点
      node = $createListNode('bullet');
    }
  }

  // 最后返回的转换结果中,除了创建的列表节点 node,还设置了 after 钩子函数
  // 以便对生成的列表节点的子节点进行标准处理
  // 确保列表节点的**直接子元素**都是列表项节点 ListItemNode
  // 而且**列表项节点的子节点**只能包含/嵌套一个列表节点,或包含一系列 inline 节点
  return {
    after: normalizeChildren,
    node,
  };
}

上面所使用的方法 $createListNode() 用于创建列表节点,其具体代码如下

ts
/**
 * Creates a ListNode of listType.
 * @param listType - The type of list to be created. Can be 'number', 'bullet', or 'check'.
 * @param start - Where an ordered list starts its count, start = 1 if left undefined.
 * @returns The new ListNode
 */
export function $createListNode(listType: ListType, start = 1): ListNode {
  // 在创建列表节点时,不直接返回 ListNode 节点实例
  // 而是先经过 $applyNodeReplacement() 方法的处理
  // 因为在 Lexical 系统中设计了一个覆盖节点的功能
  // 可以在全局的层面将特定类型的节点替换为指定的节点
  // 具体可以查看官方文档 https://lexical.dev/docs/concepts/node-replacement
  // 或笔记的相关部分 https://frontend-note.benbinbin.com/article/lexical/lexical-concept-node#覆写节点
  return $applyNodeReplacement(new ListNode(listType, start));
}

前面所使用的用于标准化列表节点的子节点的方法 normalizeChildren具体代码如下

ts
import { $isListItemNode, ListItemNode } from './LexicalListItemNode';
import { wrapInListItem} from './utils';

/*
 * This function normalizes the children of a ListNode after the conversion from HTML,
 * ensuring that they are all ListItemNodes and contain either a single nested ListNode
 * or some other inline content.
 */
// 对列表节点的子节点进行标准化处理
// 确保列表节点的直接子元素都是列表项节点 ListItemNode
// 而且列表项节点的子节点只能包含/嵌套一个列表节点,或包含一系列 inline 节点
function normalizeChildren(nodes: Array<LexicalNode>): Array<ListItemNode> {
  const normalizedListItems: Array<ListItemNode> = [];
  for (let i = 0; i < nodes.length; i++) {
    // 遍历给定的节点(列表节点的子节点)
    const node = nodes[i];
    if ($isListItemNode(node)) {
      // 如果当前所遍历的节点是列表项节点
      // 则该节点不需要标准化
      normalizedListItems.push(node);
      const children = node.getChildren();
      // 再考察当前所遍历的列表项所包含的子节点
      if (children.length > 1) {
        // 如果列表项节点所包含的子节点数量大于 1
        children.forEach((child) => {
          // 则判断这些子节点的类型
          if ($isListNode(child)) {
            // 如果它是嵌套的列表节点,则需要调用 wrapInListItem() 方法,该方法从 utils 模块中导出
            // 创建一个列表项节点 ListItemNode 对其进行「二次」包裹
            // 以确保每一个列表项中只包含一个嵌套的列表
            normalizedListItems.push(wrapInListItem(child));
          }
        });
      }
    } else {
      // 如果该节点不是列表项节点,则需要调用 wrapInListItem() 方法
      // 创建一个列表项节点 ListItemNode 对其进行包裹
      normalizedListItems.push(wrapInListItem(node));
    }
  }
  return normalizedListItems;
}

该模块还导出了一个判断给定节点 node 是否为列表节点的方法

ts
/**
 * Checks to see if the node is a ListNode.
 * @param node - The node to be checked.
 * @returns true if the node is a ListNode, false otherwise.
 */
export function $isListNode(
  node: LexicalNode | null | undefined,
): node is ListNode {
  return node instanceof ListNode;
}

ListItemNode

以下是 ListNode 类的定义,它继承自 ElementNode 元素节点

ts
import {
  $createParagraphNode,
  $isElementNode,
  $isParagraphNode,
} from 'lexical';
import {
  updateChildrenListItemValue,
  mergeLists,
  $handleIndent,
  $handleOutdent
} from './formatList';
import {isNestedListNode} from './utils';

/** @noInheritDoc */
export class ListItemNode extends ElementNode {
  // 列表项节点的一些属性(内部访问)
  /** @internal */
  __value: number; // (如果列表节点的类型为 'number' 有序列表时)列表项的序号,默认值是 1
  /** @internal */
  __checked?: boolean; // (如果列表节点的类型为 'check' 待办事项时)列表项是否完成 checked

  /**
   * 所有节点都有以下两个静态方法
   * static getType()
   * static clone()
   */
  // 获取该自定义节点的名称
  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;
  }

  // 列表节点具有一些属性(带双下划线 __property 表示属性仅供内部访问)
  // 为了可以提供外部访问和修改的权限
  // 需要分别为这些属性设置 get 和 set 方法
  // ⚠️ 为了确保 Lexical 的 Editor State 编辑器状态满足不可变性 immutable,在读取和修改节点的属性值时,应该调用 Lexical 所提供的方法 getLatest() 和 getWritable()
  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();

    return self.__checked;
  }

  setChecked(checked?: boolean): void {
    const self = this.getWritable();
    self.__checked = checked;
  }

  toggleChecked(): void {
    this.setChecked(!this.__checked);
  }

  /**
   * View
   * 与视图相关的方法
   */
  // 该节点在页面上所对应的 DOM 元素结构
  createDOM(config: EditorConfig): HTMLElement {
    // 创建的 DOM 元素是 <li> 列表项
    const element = document.createElement('li');
    // 获取其父节点
    const parent = this.getParent();

    if ($isListNode(parent) && parent.getListType() === 'check') {
      // 如果其父节点为列表节点,而且它的类型是 'check' 待办事项
      // 则调用 updateListItemChecked() 方法更新列表项(为 DOM 元素设置/删除一些属性 attributes)
      // 具体代码查看下文
      updateListItemChecked(element, this, null, parent);
    }

    // 将列表项节点的属性 __value 设置为元素 <li> 的属性 'value' 的值
    element.value = this.__value;
    // 为 DOM 元素添加在编辑器主题中 config.theme 所设置的 class 类名
    $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') {
      // 如果其父节点为列表节点,而且它的类型是 'check' 待办事项
      // 则待办事项的完成状态可能需要更新
      // 调用 updateListItemChecked() 方法更新列表项(为 DOM 元素设置/删除一些属性 attributes)
      // 具体代码查看下文
      updateListItemChecked(dom, this, prevNode, parent);
    }

    // 列表项的序号可能需要更新
    // 将列表项节点的属性 __value 设置为元素 <li> 的属性 'value' 的值
    // @ts-expect-error - this is always HTMLListItemElement
    dom.value = this.__value;
    // 列表项节点相应的 DOM 元素的 class 类名可能需要更新
    $setListItemThemeClassNames(dom, config.theme, this);

    // 返回 false,表示不需要重新调用 createDOM() 创建一个新的 DOM 元素
    return false;
  }

  // 覆写祖先节点的方法 LexicalNode.transform
  // 参考官方文档 https://lexical.dev/docs/api/classes/lexical.LexicalNode#transform
  // 或参考源码 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalNode.ts#L810-L821
  // 该方法是会在编辑器初始化时进行注册,作为当前类型节点(列表项节点)的转换器 transform
  // 💡 节点转换 node transform 是指当某个节点(发生变化后)符合特定的规则时,会触发相应的转换/操作
  // 💡 除了在定义节点时设置节点转换器,还可以通过 editor.registerNodeTransform() 注册节点转换器
  // 关于节点转换器的更多信息可以查看这一篇笔记 https://frontend-note.benbinbin.com/article/lexical/lexical-concept-reactive#节点转换器 和官方文档 https://lexical.dev/docs/concepts/transforms
  static transform(): (node: LexicalNode) => void {
    // 该方法返回一个节点转换器
    // 节点转换器是一个函数,其入参是该类型节点中(列表项节点)发生改变的节点
    return (node: LexicalNode) => {
      const parent = node.getParent(); // 获取父节点
      if ($isListNode(parent)) {
        // 如果父节点是列表节点
        // 调用方法 updateChildrenListItemValue() 并传入父节点(列表节点)
        // 以更新并确保列表项的编号 value 值是正确的
        // 💡 该方法从 ./formatList 模块导出
        updateChildrenListItemValue(parent);
        if (parent.getListType() !== 'check' && node.getChecked() != null) {
          // 如果父节点(列表节点)的类型并不是 'check' 待办事项
          // 但是当前列表项节点的 __checked 属性并不为 'null'
          // 则更新它的属性 __checked 的值为 undefined
          node.setChecked(undefined);
        }
      }
    };
  }

  // 将 DOM 元素反序列化为节点时调用的方法(静态方法)
  static importDOM(): DOMConversionMap | null {
    return {
      li: (node: Node) => ({
        conversion: convertListItemElement, // 💡 所使用的转换函数,具体可以查看后文
        priority: 0,
      }),
    };
  }

  // 🚫 缺少 exportDOM 方法,将链接节点序列化为 DOM 元素(字符串)

  // 将 JSON 数据反序列化为节点时调用的方法(静态方法)
  static importJSON(serializedNode: SerializedListItemNode): ListItemNode {
    // 💡 使用方法 $createListItemNode() 创建列表节点,具体可以查看后文
    const node = $createListItemNode();
    // 通过设置列表节点的属性 __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.
    node.setChecked(serializedNode.checked);
    // 并调用(父类)ElementNode 元素节点的相应方法设置节点的相应属性
    node.setValue(serializedNode.value);
    node.setFormat(serializedNode.format);
    node.setDirection(serializedNode.direction);
    return node;
  }

  // 节点序列化为 JSON 格式时调用的方法
  exportJSON(): SerializedListItemNode {
    return {
      // 这里先使用了父类 ElementNode 元素节点的 exportJSON() 方法
      // 并进行扩展,覆盖了属性 type 和 version
      // 添加了一些其他属性 checked、value,以便储存列表项节点的相关信息
      ...super.exportJSON(),
      checked: this.getChecked(),
      type: 'listitem',
      value: this.getValue(),
      version: 1,
    };
  }

  /**
   * Mutation
   * 与节点变换相关的方法
   */
  // 覆写父节点(ElementNode 元素节点)的 append 方法
  // 在列表项节点中插入其他节点时需要遵循不同的规则
  // 保证列表项节点的子节点只有一个嵌套列表节点,或一些 inline 类型的节点(而没有 ElementNode 元素节点嵌套在内)
  // 入参是需要插入的一系列节点所构成数组
  append(...nodes: LexicalNode[]): this {
    for (let i = 0; i < nodes.length; i++) {
      const node = nodes[i];

      if ($isElementNode(node) && this.canMergeWith(node)) {
        // 如果插入的节点是一个 ElementNode 元素节点
        // 而且该节点支持合并(它属于 ParagraphNode 段落节点或 ListItemNode 列表项节点)
        // 则提取该节点子节点(内容)
        // 直接这些内容插入到当前的列表项节点中(而不必产生元素节点的嵌套结果)
        const children = node.getChildren();
        this.append(...children);
        node.remove(); // 然后删除当前所遍历的元素节点 node
      } else {
        // 如果当前所遍历的节点并不是 ElementNode 或不支持合并
        // 则采用默认行为(父节点 ElementNode 的插入方法)
        super.append(node);
      }
    }

    return this;
  }

  // 覆写祖先节点 LexicalNode 的方法 replace
  // 可以参考官方文档 https://lexical.dev/docs/api/classes/lexical.LexicalNode#replace 和源码 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalNode.ts#L837-L899
  // 使用给定的节点 replaceWithNode 替换列表项节点时,需要遵循不同的规则
  // 其中参数 includeChildren 表示替换时是否需要转移 transfer(在原本节点中的)子节点(内容)
  replace<N extends LexicalNode>(
    replaceWithNode: N,
    includeChildren?: boolean,
  ): N {
    if ($isListItemNode(replaceWithNode)) {
      // 如果给定的节点也是列表项节点
      // 则采用默认行为
      return super.replace(replaceWithNode);
    }

    // 将需要替换的列表项节点的缩进值设置为 0
    this.setIndent(0);
    // 获取父节点
    const list = this.getParentOrThrow();
    // 如果父节点不是列表节点,则直接返回 replaceWithNode(进行替换 ❓)
    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);
      }
      // 而 replaceWithNode 则插入到原列表的后面(和列表节点成为兄弟节点)
      list.insertAfter(replaceWithNode);
      // 新建的节点紧随其后
      replaceWithNode.insertAfter(newList);
    }

    // 如果替换时,需要考虑转移 transfer(在原本节点中的)子节点(内容)
    if (includeChildren) {
      this.getChildren().forEach((child: LexicalNode) => {
        // 则将子节点插入到替换节点内
        replaceWithNode.append(child);
      });
    }

    // 最后移除该节点
    this.remove();

    if (list.getChildrenSize() === 0) {
      // 如果移除了该列表项节点后,其父节点(列表节点)为空
      // 则把父节点也移除
      list.remove();
    }
    return replaceWithNode;
  }

  /**
   * Mutation
   * 与节点变换相关的方法
   */
  // 覆写祖先节点的 insertAfter 方法,有不同的行为
  // 在当前节点后插入给定的节点(作为兄弟节点)
  // 第一个参数 node 是待插入的节点
  // 第二个参数 restoreSelection 是否需要重置选区/光标
  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',
      );
    }

    // 获取当前节点的后面的所有兄弟节点
    // 返回一个数组,包含所有的兄弟节点
    const siblings = this.getNextSiblings();

    // 如果待插入节点也是列表项节点
    if ($isListItemNode(node)) {
      // 则采用默认行为(调用祖先节点 LexicalNode 的 insertAfter 方法)
      // 参考官方文档 https://lexical.dev/docs/api/classes/lexical.LexicalNode#insertafter
      // 或参考源码 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalNode.ts#L901-L966
      const after = super.insertAfter(node, restoreSelection);
      // 获取待插入节点的父节点
      const afterListNode = node.getParentOrThrow();

      if ($isListNode(afterListNode)) {
        // 如果待插入节点的父节点也是列表节点
        // 则需要更新它的父节点(移除该节点后,它的父节点列表项的需要需要更新)
        updateChildrenListItemValue(afterListNode);
      }

      return after; // 返回插入后的节点
    }

    // 如果待插入的节点是列表节点
    // 如果待插入的列表节点和当前节点(列表项节点)的父节点(列表节点)的类型相同
    // 则尝试将待插入的列表节点的内容(子节点)提取出来,合并到当前节点的父节点(列表节点)中
    if ($isListNode(node)) {
      let child = node;
      // 获取待插入节点(列表节点)的子节点
      const children = node.getChildren<ListNode>();

      // 遍历子节点
      for (let i = children.length - 1; i >= 0; i--) {
        child = children[i];
        // 将它插入到当前节点后面(成为兄弟节点)
        // 因为在 Lexical 中会确保列表节点的直接子节点都是列表项节点,所遍历的子节点一般是列表项节点
        this.insertAfter(child, restoreSelection);
      }

      return child; // 返回当前所遍历的子节点
    }

    // 如果待插入的节点并不是列表节点或列表项节点
    // 则将当前节点所在的列表节点(父节点)「分割」
    // 这样就可以让待插入的节点 node 位于「分割」后的两个列表节点之间

    // 先将待插入的节点插入到当前节点父节点(列表节点)后面,作为兄弟节点
    listNode.insertAfter(node, restoreSelection);

    if (siblings.length !== 0) {
      // 再创建一个与当前节点的父节点(列表节点)类型相同的列表节点
      const newListNode = $createListNode(listNode.getListType());

      // 将当前节点后的所有兄弟节点都移到新建的列表节点内
      siblings.forEach((sibling) => newListNode.append(sibling));
      // 再将这个新建的列表节点插入到 node 节点后(作为兄弟节点)
      node.insertAfter(newListNode, restoreSelection);
    }

    return node;
  }

  // 覆写祖先节点的 remove 方法,有不同的行为
  // 删除该节点
  // 参数 preserveEmptyParent 用于设置如果删除节点后令父节点为空,是否可以保留父节点
  remove(preserveEmptyParent?: boolean): void {
    const prevSibling = this.getPreviousSibling(); // 获取前一个兄弟节点
    const nextSibling = this.getNextSibling(); // 获取后一个兄弟节点
    // 采用默认行为(调用祖先节点 LexicalNode 的 remove 方法)
    // 参考官方文档 https://lexical.dev/docs/api/classes/lexical.LexicalNode#remove
    // 或参考源码 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalNode.ts#L825-L835
    super.remove(preserveEmptyParent);

    if (
      prevSibling &&
      nextSibling &&
      isNestedListNode(prevSibling) &&
      isNestedListNode(nextSibling)
    ) {
      // 如果同时存在前后兄弟节点(列表项节点),而且它们都内嵌有列表节点
      // 则将它们「合并」起来,让列表的结构更简单
      // 即第二个参数的列表节点的所有子节点,都插入到 append 第一个参数的列表节点内
      // 不需要先考虑两个列表节点的类型是否相同 ❓
      // 并更新列表项的序号
      mergeLists(prevSibling.getFirstChild(), nextSibling.getFirstChild());
      nextSibling.remove(); // 并删除后一个兄弟节点
    } else if (nextSibling) {
      // 如果有后一个兄弟节点
      const parent = nextSibling.getParent();

      if ($isListNode(parent)) {
        // 更新列表项的序号
        updateChildrenListItemValue(parent);
      }
    }
  }

  // 设置在该节点上按回车键时的行为
  insertNewAfter(
    _: RangeSelection,
    restoreSelection = true,
  ): ListItemNode | ParagraphNode {
    // 创建一个新的列表项节点
    const newElement = $createListItemNode(
      this.__checked == null ? undefined : false,
    );
    // 将它插入到当前节点的后面(作为兄弟节点)
    this.insertAfter(newElement, restoreSelection);

    return newElement;
  }

  // 设置光标移动到节点开头的行为
  // 当该节点位于编辑器的**开头**按下 Backspace 键的行为 ❓
  collapseAtStart(selection: RangeSelection): true {
    // 创建一个 ParagraphNode 段落节点
    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;
  }

  // 获取列表节点的缩进值
  getIndent(): number {
    // If we don't have a parent, we are likely serializing
    const parent = this.getParent();
    if (parent === null) {
      // 如果该节点没有父节点
      // 则缩进值采用自身的属性 __indent 的值
      // 由于列表项节点继承自 ElementNode,而元素节点本身具有属性 __indent
      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' && indent > -1,
      'Invalid indent value.',
    );
    let currentIndent = this.getIndent();
    while (currentIndent !== indent) {
      if (currentIndent < indent) {
        // 处理增加缩进行为(创建嵌套列表节点)
        // 该方法从 ' ./formatList' 模块导出
        $handleIndent(this);
        currentIndent++;
      } else {
        // 处理减少缩进行为(创建嵌套列表节点)
        $handleOutdent(this);
        currentIndent--;
      }
    }

    return this;
  }

  // 覆写祖先节点的 insertAfter 方法,有不同的行为
  // 在当前节点前插入给定的节点(作为兄弟节点)
  insertBefore(nodeToInsert: LexicalNode): LexicalNode {
    // 如果插入的节点也是列表项节点
    if ($isListItemNode(nodeToInsert)) {
      const parent = this.getParentOrThrow();

      if ($isListNode(parent)) {
        const siblings = this.getNextSiblings();
        // 需要更新列表节点中列表项节点的序号
        updateChildrenListItemValue(parent, siblings);
      }
    }

    return super.insertBefore(nodeToInsert);
  }

  // 是否可以在该节点的后面插入节点(作为兄弟节点)
  canInsertAfter(node: LexicalNode): boolean {
    // 限制在列表项节点的后面只能插入的也是列表项节点
    return $isListItemNode(node);
  }

  // 是否可以用其他节点替换 ❓
  canReplaceWith(replacement: LexicalNode): boolean {
    // 只能用列表项节点来替换
    return $isListItemNode(replacement);
  }

  // 判断给定的节点是否可以进行合并 ❓
  // 合并是指将该节点 node 的子节点(内容)提取出来,插入到列表项节点中
  // 而该 node(它是一个 ElementNode)就直接删除,避免形成列表项节点中内嵌 ElementNode ❓
  // 需要给定的节点是段落节点或列表项节点 ❓
  canMergeWith(node: LexicalNode): boolean {
    return $isParagraphNode(node) || $isListItemNode(node);
  }

  // 该方法用于 @lexical/clipboard 模块中
  // 以实现复制粘贴的相关功能
  // 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-clipboard/src/clipboard.ts#L489
  // 返回的是一个**布尔值**用于设置复制的行为,当复制该节点的子节点时(由于 ElementNode 元素节点一般不易选中),是否同时用该节点进行包裹
  // 如果返回 true 则复制时,会用该链接节点对选中的文本进行包裹,即相当于复制了一个链接节点;如果返回 false 则复制时,只是复制了选中的文本
  extractWithChild(
    child: LexicalNode,
    selection: RangeSelection | NodeSelection | GridSelection,
  ): boolean {
    if (!$isRangeSelection(selection)) {
      return false;
    }

    const anchorNode = selection.anchor.getNode();
    const focusNode = selection.focus.getNode();

    // 如果选中列表项节点的**完整**的文本内容才返回 true,否则返回 false
    // 即整个列表项复制时,才会用列表项节点进行包裹;否则只是复制文本
    return (
      this.isParentOf(anchorNode) &&
      this.isParentOf(focusNode) &&
      this.getTextContent().length === selection.getTextContent().length
    );
  }

  // 需要存在父节点(父节点是 ListNode)
  // 用于规范复制粘贴的行为
  isParentRequired(): true {
    return true;
  }

  // 用于创建父节点
  // 当之前的方法 isParentRequired() 返回 true 时,在复制粘贴时会配合调用该方法
  createParentElementNode(): ElementNode {
    return $createListNode('bullet');
  }
}

前面用于更新待办事项的方法 updateListItemChecked 具体代码如下

ts
// 调用这个方法的前提是列表项节点的父节点是列表节点,即这里传入的 listNode 参数
// 而且这个列表节点的类型是 'check' 待办事项
function updateListItemChecked(
  dom: HTMLElement,
  listItemNode: ListItemNode,
  prevListItemNode: ListItemNode | null,
  listNode: ListNode,
): void {
  // 仅当列表项节点为叶子节点时(即它不再包含/内嵌列表节点时)才进行一些特性 attribute 设置
  if ($isListNode(listItemNode.getFirstChild())) {
    // 获取该列表项的第一个子节点,并判断它是否为列表节点(即列表项中又内嵌着一个列表)
    // 如果包含内嵌的列表节点,则移除相关属性 attributes
    dom.removeAttribute('role');
    dom.removeAttribute('tabIndex');
    dom.removeAttribute('aria-checked');
  } else {
    // 如果列表项是叶子节点((即它不再包含/内嵌列表节点时)
    // 则设置一些与待办事项列表相关的 attribute
    dom.setAttribute('role', 'checkbox'); // 将元素 <li> 的属性 'role' 设置为 'checkbox'
    // 将元素 <li> 的属性 'tabIndex' 设置为 '-1' 让列表项无法通过键盘导航聚焦
    dom.setAttribute('tabIndex', '-1');

    // 并根据列表项节点的属性 __checked 设置元素 <li> 的属性 'aria-checked' 设置为 'true' 或 'false'
    if (
      !prevListItemNode ||
      listItemNode.__checked !== prevListItemNode.__checked
    ) {
      dom.setAttribute(
        'aria-checked',
        listItemNode.getChecked() ? 'true' : 'false',
      );
    }
  }
}

前面所使用的方法 $setListItemThemeClassNames() 是为列表项节点所对应的 DOM 元素添加 class 类名:

  • 以下变量 listItemClassName 存储了列表项元素的通用类名,支持一个字符串中设置多个 class 类名(用空格分隔)
  • 以下变量 nestedListItemClassName 存储了含有/内嵌列表节点的列表项元素的类名,支持一个字符串中设置多个 class 类名(用空格分隔)
  • 对于待办事项的列表项元素(其父节点为 check 类型的列表节点),会根据其完成状态添加相应的类名,分别对应于 config.theme.list.listitem.listitemUncheckedconfig.theme.list.listitem.listitemChecked 所设置的类名

它的具体代码如下

ts
function $setListItemThemeClassNames(
  dom: HTMLElement,
  editorThemeClasses: EditorThemeClasses,
  node: ListItemNode,
): void {
  const classesToAdd = []; // 需要添加的 class 类名(数组)
  const classesToRemove = []; // 需要删除的 class 类名(数组)
  // 在 config.theme.list 中设置列表元素 class 类名
  const listTheme = editorThemeClasses.list;
  // 在 config.theme.list.listitem 中设置列表项元素 class 类名
  const listItemClassName = listTheme ? listTheme.listitem : undefined;
  let nestedListItemClassName;

  if (listTheme && listTheme.nested) {
    // 通过 list.nested.listitem 属性设置 class 类名
    // 专门针对**含有/内嵌列表节点**的列表项元素
    nestedListItemClassName = listTheme.nested.listitem;
  }

  if (listItemClassName !== undefined) {
    // 在 theme 中设置列表项元素的 class 类名时,支持多个(用空格分隔)
    // 这里通过 str.split(' ')(转换为数组)从字符串中提取出其中的一系列的 class 类名
    const listItemClasses = listItemClassName.split(' ');
    classesToAdd.push(...listItemClasses);
  }

  // 如果父节点为 `check` 类型的列表节点
  // 会根据列表项节点的完成状态为其元素 <li> 添加相应的类名
  if (listTheme) {
    const parentNode = node.getParent();
    const isCheckList =
      $isListNode(parentNode) && parentNode.getListType() === 'check';
    const checked = node.getChecked(); // 获取列表项节点的完成状态值

    // 先删除与该待办事项完成状态不相符的类名
    if (!isCheckList || checked) {
      // 如果该待办事项已完成了,则移除 config.theme.list.listitem.listitemUnchecked 所设置的类名
      classesToRemove.push(listTheme.listitemUnchecked);
    }

    if (!isCheckList || !checked) {
      // 如果该待办事项未完成了,则移除 config.theme.list.listitem.listitemChecked 所设置的类名
      classesToRemove.push(listTheme.listitemChecked);
    }

    // 再根据该待办事项的完成状态,添加相应的类名
    if (isCheckList) {
      classesToAdd.push(
        checked ? listTheme.listitemChecked : listTheme.listitemUnchecked,
      );
    }
  }

  // 通过 list.nested.listitem 属性设置**含有/内嵌列表节点**的列表项元素的 class 类名
  if (nestedListItemClassName !== undefined) {
    // 在 theme 中设置列表项元素的 class 类名时,支持多个(用空格分隔)
    const nestedListItemClasses = nestedListItemClassName.split(' ');

    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);
  }
}

前面将 DOM 元素反序列化所使用的转换函数 convertListItemElement 的具体代码如下

ts
function convertListItemElement(domNode: Node): DOMConversionOutput {
  // 根据 DOM 元素中是否具有属性 'aria-checked' 而且该属性值为 'true'
  // 以此判断列表项节点是否为待办事项类型
  const checked =
    isHTMLElement(domNode) && domNode.getAttribute('aria-checked') === 'true';
  return {node: $createListItemNode(checked)};
}

上面所使用的方法 $createListItemNode 用于创建列表项节点,其具体代码如下

ts
/**
 * 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 {
  // 在创建列表节点时,不直接返回 ListNode 节点实例
  // 而是先经过 $applyNodeReplacement() 方法的处理
  // 因为在 Lexical 系统中设计了一个覆盖节点的功能
  // 可以在全局的层面将特定类型的节点替换为指定的节点
  // 具体可以查看官方文档 https://lexical.dev/docs/concepts/node-replacement
  // 或笔记的相关部分 https://frontend-note.benbinbin.com/article/lexical/lexical-concept-node#覆写节点
  return $applyNodeReplacement(new ListItemNode(undefined, checked));
}

Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes