节点

lexical
Created 2/4/2023
Updated 7/18/2023

节点

参考

Nodes 节点是 Lexical 编辑器的核心,各种不同类型的节点是用户与编辑器交互的基本单位,构成了编辑器的可视化交互界面;同时这些节点是抽象数据模型的实例,构成了 Editor State 编辑器状态。

Lexical 有一个最基本/核心的节点 LexicalNode

节点属性与方法

节点可以具有一些属性 properties 用以描述其外观或行为

说明

推荐为节点(类/实例)的属性添加 __(双下划线)作为前缀,因为这表示节点的属性应该避免直接暴露给外部进行访问或修改,即节点的属性应该都是私有属性(例如 Lexical 内置的文本节点 TextNode一些属性

Lexical 推荐采用 __(双下划线)而不是 _(单下划线)作为私有属性的名称的前缀,是为了避免一些打包工具在优化代码时将单下划线移除(如果移除了下划线,,意外提供了允许外部访问的属性),可能对于通过插件制作可自定义的节点造成问题。

如果节点的某个属性 xxx 是允许从外部访问或改变的,则应该在节点种设置相应的 get-set 方法 getXxx()setXxx(),而且为了确保 Lexical 的 Editor State 编辑器状态满足不可变性 immutable,在读取和修改该属性值时,应该调用 Lexical 所提供的方法 getLatest()getWritable()

ts
import type { NodeKey } from 'lexical';

class MyCustomNode extends SomeOtherNode {
  __foo: string; // 节点(私有)属性
  // 允许从外都读取和修改

  constructor(foo: string, key?: NodeKey) {
    super(key);
    this.__foo = foo;
  }

  // 修改 __foo 属性
  setFoo(foo: string) {
    // 先使用 getWritable() 方法将节点克隆一份
    // 以避免直接对 stable/immutable 的 Editor State 进行修改
    // 再修改该属性(副本)的值
    const self = this.getWritable();
    self.__foo = foo;
  }

  // 读取 __foo 属性
  getFoo(): string {
    // 通过 getLatest() 方法以确保获取到的节点是源自最新的 Editor State
    // 再返回在最新的 Editor State 中该节点的属性值
    const self = this.getLatest();
    return self.__foo;
  }
}
注意

由于节点需要支持序列化(如转换为 JSON 格式),所以节点属性的值局限在可序列化的数据格式,只能是 nullundefinednumberstringboolean{}[] 这些数据类型(不能使用函数、Symbol 、Map 映射、Set 集合等)

如果是属性值是对象,则它的 prototype 原型只能采用 JS 内置的,而不能进行扩展

所有节点(类)都应该具有静态方法 static getType()static clone(),例如内置节点 TextNode

js
// 一个自定义节点的例子
class MyCustomNode extends SomeOtherNode {
  __foo: string;

  static getType(): string {
    return 'custom-node'; // 该自定义节点的名称是 custom-node
  }

  static clone(node: MyCustomNode): MyCustomNode {
    // 采用相同的属性值,创建一个节点的拷贝
    return new MyCustomNode(node.__foo, node.__key);
  }

  // ...

  constructor(foo: string, key?: NodeKey) {
    super(key);
    this.__foo = foo;
  }
}
  • 使用 getType() 方法可以获取该类型节点的名称 type,这在反序列化 deserialization(构建 Editor State 编辑器状态)时会用到,正是根据 type 来寻找相匹配的节点类,并进行实例化;另外在复制+拷贝的过程也会用到
  • 使用 clone() 方法可以获取节点的拷贝,在创建新的 Editor State 编辑器状态的时会用到,可以确保不同的编辑器状态 snapshots 之间的节点的一致性

基本节点

将核心节点 LexicalNode 类进行扩展,构成 Lexical 的 5 个基本节点:

  • RootNode 根节点
    根节点是编辑器节点树的第一个节点,而且只有唯一一个(所以它没有父节点,或其他兄弟节点),它对应于页面上的 contenteditable DOM 元素
    提示

    可以通过调用根节点实例的方法 rootNode.getTextContent() 来获取整个编辑器的文本内容

    注意

    Lexical 禁止将文本节点直接插入到根节点下,这是为了避免产生关于选区的问题(需要用其他节点,如 paragraphNode 段落节点,将文本节点「包裹」着,才可以插入到根节点下)

  • LineBreakNode 换行节点
    在文本编辑器中,如果需要换行应该采用 LineBreakNode 节点(而不是 \n 换行符),这可以保证编辑器在不同浏览器中的行为表现一致
  • ElementNode 元素节点
    它的作用是充当其他节点的父节点(即可以包含其他子节点),可以理解为容器节点。既可以是块状节点(ParagraphNode 段落节点、HeadingNode 标题节点等),也可以是行内节点(如 LinkNode 链接节点)。
    它有一些属性用于描述其行为,如 isInlinecanBeEmptycanInsertTextBefore 等,可以通过插件进行修改
  • TextNode 文本节点
  • DecoratorNode 装饰节点
    这是一种强大的节点,它作为一种复杂视图的封装,可以让交互性和功能性更强大的组件嵌入到编辑器中,而且通用性很强(组件的来源并不局限于哪一种前端框架)

TextNode 文本节点

参考
  • 官方文档中介绍 TextNode部分
  • 官方文档 API 中关于 TextNode部分
  • TextNode 文本节点的源码

该节点是 LexicalNode 基础节点的子类,除了继承了一些属性和方法外,它还有一些常用的专有属性和方法

它的实例具有一个属性 textNode.__text 表示该节点所包含的文本内容

它的实例具有以下常用方法:

  • textNode.setTextContent(string) 为文本节点设置内容,返回该节点
  • textNode.canInsertTextAfter() 返回一个布尔值,以控制在该节点后(append 的方式)是否可以继续插入文本
    TextNode 默认返回 true 即可以在原来的文本节点中继续添加文本内容。
    该方法一般是提供给 TextNode 的子类进行覆写的,以改变插入文本的行为,如果该返回 false 则表示节点不能继续添加文本内容,如果创建该节点后,需要继续输入文本,则会创建一个兄弟节点 sibling node(而不是将文本添加/追加到该节点里)
    例如官方基于 TextNode 所创建的一个子类 TabNode 它用来表示一个缩进,并不允许在该节点中添加文本,所以要覆写该方法返回 false
  • textNode.canInsertTextBefore() 返回一个布尔值,以控制该节点前(prepend 的方式 ❓)是否可以继续插入文本
  • textNode.hasFormat(type: TextFormatType) 返回一个布尔值,检测文本节点是否具有某种格式
    其中入参 typeTextFormatType 类型,它可以是 "bold""underline""strikethrough""italic""highlight""code""subscript""superscript"
  • textNode.setFormat(format: number | TextFormatType) 为节点设置格式,每次只能设置一种格式,⚠️ 而且该方法会覆盖/移除掉文本原本已有的格式
  • textNode.toggleFormat(type: TextFormatType) 为节点切换(添加或移除)格式,并不影响其他类型的格式
基于选取设置文本格式

更常见的需求是为框选的特定文本区域设置格式(而不是为整个文本节点设置格式)

Lexical 提供了基于选区设置文本格式的方法 rangeSelection.formatText(type: TextFormatType)

说明

文本节点默认采用 <span> 元素对内容进行包裹

当文本节点设置了格式后,对应的 DOM 元素一般会发生相应的变化,采用相应语义的 HTML 元素对内容进行包裹:

  • 对于文本格式设置了 bold 则会采用 <strong> 元素对内容进行包裹
  • 对于文本格式设置了 italic 则会采用 <em> 元素对内容进行包裹
  • 对于文本格式设置了 code 则会在原来的 <span> 元素外,再使用 <code> 元素进包裹
  • 对于文本格式设置了 highlight 则会在原来的 <span> 元素外,再使用 <mark> 元素进包裹
  • 对于文本格式设置了 subscript 则会在原来的 <span> 元素外,再使用 <sub> 元素进包裹
  • 对于文本格式设置了 superscript 则会在原来的 <span> 元素外,再使用 <sup> 元素进包裹

以上这些 HTML 元素会由默认样式,所以为文本节点设置这些格式后,页面会看到视觉上的变化

但是对于设置 underlinestrikethrough 格式,默认情况下并不会造成有视觉上的变化,则需要先在编辑器的主题 theme 里进行配置,再通过 CSS 类名设置外观样式

js
const editorTheme = {
  // ...
  text: {
    bold: 'editor-textBold',
    code: 'editor-textCode',
    italic: 'editor-textItalic',
    strikethrough: 'editor-textStrikethrough',
    subscript: 'editor-textSubscript',
    superscript: 'editor-textSuperscript',
    underline: 'editor-textUnderline',
    underlineStrikethrough: 'editor-textUnderlineStrikethrough',
  },
}

注意其中 strikethroughunderline 分别设置的是文本节点具有删除线下划线格式时,对应的 DOM 元素会添加的 class 类名;而 underlineStrikethrough 所设置的是在文本节点同时具有删除线和下划线时,对应的 DOM 元素的 class 类名

例如对于 underline 文本格式,如果文本节点具有该格式时,则对应的 DOM 元素会添加上 editor-textUnderline class 类名,同时为页面添加以下的 CSS style 就可以让文本具有下划线

css
.editor-textUnderline {
  text-decoration-line: underline;
}
  • textNode.isComposing() 返回一个布尔值,检测文本节点的内容是否正在处于输入状态中(可以被变更)
  • textNode.isDirectionless() 返回一个布尔值,表示文本是否无方向性,如果为 true 则在 RTLLTR 模式之间切换文本并不会变化
  • textNode.toggleDirectionless() 切换文本节点是否具有方向性
  • textNode.isSegmented() 返回一个布尔值,表示文本节点是否处于 segmented 模式。
    处于这种模式下,文本节点的选取可以基于单个字符,但是删除时就只能基于空格键的分割出的 space-delimited 各部分(看作一个个整体)进行删除
  • textNode.isToken() 返回一个布尔值,表示文本节点是否处于 token 模式。
    处于这种模式下,文本节点的选取可以基于单个字符,但是删除时就只能看作是一个整体进行删除
  • textNode.isSimpleText() 返回一个布尔值,以表示它是否为一个「简单」的文本节点,没有额外的设置,例如没有设置为 segmentedtoken 模式
  • textNode.isUnmergeable() 返回一个布尔值,以表示它是否不可以和临近的兄弟文本节点进行 merge 连接融合为一个文本节点
  • textNode.toggleUnmergeable() 切换文本节点的可否 merge 的属性
  • textNode.mergeWithSibling(target) 与兄弟节点(文本节点)target 进行合并为一个新的文本节点,并同时删除原本的 target 节点,最后返回融合后的文本节点
  • textNode.select(_anchorOffset?, _focusOffset?) 选择该文本节点(或根据入参值,选择给定的部分)并返回一个 RangeSelection 范围选区
  • textNode.setMode(type: TextModeType) 为文本节点设置特定模式
    其中入参 type 的类型是 TextModeType,它可以是 "normal" "token""segmented"
  • textNode.setStyle(type: string) 为文本节点所对应的 DOM 元素设置行内 CSS 样式,入参是字符串作为 inline style
  • textNode.spliceText(offset, delCount, newText, moveSelection?) 删除旧文本并插入新文本
    在特定的位置 offset 删除特定数量 delCount 字符,并插入新的内容 newText。最后的(可选)参数 moveSelection 是一个布尔值,以决定是否将光标移动到插入内容的最后
  • textNode.splitText(...splitOffsets) 将一个文本节点「切分」为多个文本节点,并以这些文本节点取代原来的文本节点。返回一个数组,包含切分后的文本节点
    其中入参 ...splitOffsets 表示一个数组,里面的元素都是数字,表示切分的位置

覆写节点

除了以上列出的 5 种节点,Lexical 还对它们进行扩展,创建了一系列实用的自定义节点,如官方制作了一些自定义的节点可供参考:ParagraphNode 段落节点HeadingNode 标题节点QuoteNode 引文节点代码相关的 CodeNode 节点CodeHighlightNode 节点列表相关的 LexicalListNode 列表节点LexicalListItemNode 列表项节点

这些节点定义在 Lexical 的相关模块包里,以便可以开箱即用,但在实际的项目中这些内置的节点不完全适合,可能需要进一步定制。

一般方式是以这些内置节点作为父类,定义出子类以扩展出新的自定义节点再使用。

提示

另一种更简便的方式是采用 Lexical 所提供的覆写节点 Node Override 功能,只需要在编辑器的配置对象中进行设置,就可以将编辑器内容中的某一类节点(全部实例)都替换为另一个种类型的节点。

js
const editorConfig = {
    // ...
    nodes: [
        // 在使用 CustomParagraphNode 自定义节点前
        // 请别忘记在编辑器上进行注册
        CustomParagraphNode,
        // 针对 ParagraphNode 这一内置节点
        // 将它在编辑器中的所有实例都替换为 CustomParagraphNode
        {
            replace: ParagraphNode,
            with: (node: ParagraphNode) => {
                return new CustomParagraphNode();
            }
        }
    ]
}

这在升级编辑器后,某些旧节点舍弃用新的节点替代,而为了兼容导入以前导出的序列化数据时特别有用

自定节点

lexical 核心包暴露出/导出 ElementNodeTextNodeDecoratorNode 这三种基本节点,可以对它们进行扩展,用于构造自定义的节点

扩展 ElementNode 元素节点

提示

Lexical 官方对 ElementNode 进行扩展制作了一些定制化的节点,例如 ParagraphNode 段落节点

可以参照和学习官方的定制化节点来制作自己的节点

以下是通过扩展 ElementNode 元素节点类,制作一个自定义段落节点的示例

js
import { ElementNode } from 'lexical';

export class CustomParagraph extends ElementNode {
  static getType(): string {
    return 'custom-paragraph'; // 该节点的名称
  }

  // 复制节点的方法
  static clone(node: ParagraphNode): ParagraphNode {
    return new CustomParagraph(node.__key);
  }

  // 该节点所对应的 DOM 结构(节点的视图,抽象数据模型的实现)
  createDOM(): HTMLElement {
    // Define the DOM element here
    // 以 <p> 元素表示
    const dom = document.createElement('p');
    return dom;
  }

  // 更新节点时,是否需要调用 createDOM() 方法重新创建一个新的 DOM 元素来取代页面上原有的旧元素 ❓
  updateDOM(prevNode: CustomParagraph, dom: HTMLElement): boolean {
    // 返回 false 表示不需要使用 createDOM() 方法创建一个新的 DOM 元素
    // 因为段落节点一般只是作为容器元素,一般更新时所变动的内容是其子节点(如文本节点),所以不需要再创建一个新的 DOM 元素
    return false;
  }
}
推荐

如果节点需要提供一些可供外部调用的方法,推荐使用 $ 作为前缀进行标记

其中一种比较常见的需求是验证给定的节点类型是否与该节点相同

ts
export function $isCustomParagraphNode(node: ?LexicalNode): boolean {
  return node instanceof CustomParagraph;
}

另一种常见的需求是基于已有的节点创建一个新的节点

ts
export function $createCustomParagraphNode(): ParagraphNode {
  return new CustomParagraph();
}

扩展 TextNode 文本节点

以下是通过扩展 TextNode 元素节点类,制作一个自定义叶子节点的示例

ts
// 该节点的作用是为文本添加颜色
export class ColoredNode extends TextNode {
  __color: string; // 颜色值

  constructor(text: string, color: string, key?: NodeKey): void {
    super(text, key);
    this.__color = color;
  }

  static getType(): string {
    return 'colored'; // 该节点的名称
  }

  static clone(node: ColoredNode): ColoredNode {
    return new ColoredNode(node.__text, node.__color, node.__key);
  }

  // 该节点所对应的 DOM 结构
  createDOM(config: EditorConfig): HTMLElement {
    const element = super.createDOM(config);
    element.style.color = this.__color;
    return element;
  }

  // 更新节点时
  updateDOM(
    prevNode: ColoredNode,
    dom: HTMLElement,
    config: EditorConfig,
  ): boolean {
    // 更新时是否需要调用 createDOM() 方法重新创建一个新的 DOM 元素来取代页面原有的 DOM 元素
    // 取决于父类(文本节点)的更新行为
    const isUpdated = super.updateDOM(prevNode, dom, config);
    // 如果新节点的颜色与原来节点的颜色不同
    // 则更新 DOM 的样式 color 属性的值
    if (prevNode.__color !== this.__color) {
      dom.style.color = this.__color;
    }
    return isUpdated;
  }
}

export function $createColoredNode(text: string, color: string): ColoredNode {
  return new ColoredNode(text, color);
}

export function $isColoredNode(node: ?LexicalNode): boolean {
  return node instanceof ColoredNode;
}

扩展 DecoratorNode 装饰节点

以下是通过扩展 DecoratorNode 元素节点类,制作一个自定义装饰节点的示例

ts
export class VideoNode extends DecoratorNode<ReactNode> {
  __id: string; // 该节点具有一个 id 属性,作为唯一标记符

  static getType(): string {
    return 'video'; // 该节点的名称
  }

  static clone(node: VideoNode): VideoNode {
    return new VideoNode(node.__id, node.__key);
  }

  constructor(id: string, key?: NodeKey) {
    super(key);
    this.__id = id;
  }

  // 该节点所对应的 DOM 结构
  createDOM(): HTMLElement {
    return document.createElement('div');
  }

  // 更新该节点时并不需要重新调用 createDOM() 方法重新创建一个新的 DOM 元素
  updateDOM(): false {
    return false;
  }

  // 装饰内容是一个 React 组件
  decorate(): ReactNode {
    return <VideoPlayer videoID={this.__id} />;
  }
}

export function $createVideoNode(id: string): VideoNode {
  return new VideoNode(id);
}

export function $isVideoNode(node: ?LexicalNode): boolean {
  return node instanceof VideoNode;
}

Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes