列表节点
Lexical 推出了一个模块包 @lexical/list 提供了一个自定义节点 LinkNode 链接节点
参考
- lexical-list - Github
- @lexical/list - documentation
该模块导出了两个自定义节点 ListNode 和 ListItemNode 分别对应于列表和列表项,以及一些相关的方法和 type 类型
ListNode
列表节点 ListNode 有三种类型
export type ListType = 'number' | 'bullet' | 'check';
// 'number' 类型对应于有序列表
// 'bullet' 类型对应于无序列表
// 'check' 类型对应于待办事项(列表)
列表节点对应的 HTML 元素有两类 <ul> 无序列表元素和 <ol> 有序列表元素
export type ListNodeTagType = 'ul' | 'ol';
这两种类型有默认/预设的对照关系
const TAG_TO_LIST_TYPE: Record<string, ListType> = {
ol: 'number',
ul: 'bullet',
};
以下是 ListNode 类的定义,它继承自 ElementNode 元素节点
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 类名(用空格分隔)
它的具体代码如下
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 的具体代码如下
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() 用于创建列表节点,其具体代码如下
/**
* 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具体代码如下
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 是否为列表节点的方法
/**
* 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 元素节点
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 具体代码如下
// 调用这个方法的前提是列表项节点的父节点是列表节点,即这里传入的 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.listitemUnchecked和config.theme.list.listitem.listitemChecked所设置的类名
它的具体代码如下
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 的具体代码如下
function convertListItemElement(domNode: Node): DOMConversionOutput {
// 根据 DOM 元素中是否具有属性 'aria-checked' 而且该属性值为 'true'
// 以此判断列表项节点是否为待办事项类型
const checked =
isHTMLElement(domNode) && domNode.getAttribute('aria-checked') === 'true';
return {node: $createListItemNode(checked)};
}
上面所使用的方法 $createListItemNode 用于创建列表项节点,其具体代码如下
/**
* 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));
}