节点
参考
Nodes 节点是 Lexical 编辑器的核心,各种不同类型的节点是用户与编辑器交互的基本单位,构成了编辑器的可视化交互界面;同时这些节点是抽象数据模型的实例,构成了 Editor State 编辑器状态。
Lexical 有一个最基本/核心的节点 LexicalNode 类。
节点属性与方法
节点可以具有一些属性 properties 用以描述其外观或行为
说明
推荐为节点(类/实例)的属性添加 __(双下划线)作为前缀,因为这表示节点的属性应该避免直接暴露给外部进行访问或修改,即节点的属性应该都是私有属性(例如 Lexical 内置的文本节点 TextNode 的一些属性)
Lexical 推荐采用 __(双下划线)而不是 _(单下划线)作为私有属性的名称的前缀,是为了避免一些打包工具在优化代码时将单下划线移除(如果移除了下划线,,意外提供了允许外部访问的属性),可能对于通过插件制作可自定义的节点造成问题。
如果节点的某个属性 xxx 是允许从外部访问或改变的,则应该在节点种设置相应的 get-set 方法 getXxx() 和 setXxx(),而且为了确保 Lexical 的 Editor State 编辑器状态满足不可变性 immutable,在读取和修改该属性值时,应该调用 Lexical 所提供的方法 getLatest() 和 getWritable()
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 格式),所以节点属性的值局限在可序列化的数据格式,只能是 null、undefined、number、string、boolean、{}、[] 这些数据类型(不能使用函数、Symbol 、Map 映射、Set 集合等)
如果是属性值是对象,则它的 prototype 原型只能采用 JS 内置的,而不能进行扩展
所有节点(类)都应该具有静态方法 static getType() 和 static clone(),例如内置节点 TextNode
// 一个自定义节点的例子
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根节点
根节点是编辑器节点树的第一个节点,而且只有唯一一个(所以它没有父节点,或其他兄弟节点),它对应于页面上的contenteditableDOM 元素提示
可以通过调用根节点实例的方法
rootNode.getTextContent()来获取整个编辑器的文本内容注意
Lexical 禁止将文本节点直接插入到根节点下,这是为了避免产生关于选区的问题(需要用其他节点,如
paragraphNode段落节点,将文本节点「包裹」着,才可以插入到根节点下)LineBreakNode换行节点
在文本编辑器中,如果需要换行应该采用LineBreakNode节点(而不是\n换行符),这可以保证编辑器在不同浏览器中的行为表现一致ElementNode元素节点
它的作用是充当其他节点的父节点(即可以包含其他子节点),可以理解为容器节点。既可以是块状节点(ParagraphNode段落节点、HeadingNode标题节点等),也可以是行内节点(如LinkNode链接节点)。
它有一些属性用于描述其行为,如isInline、canBeEmpty、canInsertTextBefore等,可以通过插件进行修改TextNode文本节点DecoratorNode装饰节点
这是一种强大的节点,它作为一种复杂视图的封装,可以让交互性和功能性更强大的组件嵌入到编辑器中,而且通用性很强(组件的来源并不局限于哪一种前端框架)
TextNode 文本节点
该节点是 LexicalNode 基础节点的子类,除了继承了一些属性和方法外,它还有一些常用的专有属性和方法
它的实例具有一个属性 textNode.__text 表示该节点所包含的文本内容
它的实例具有以下常用方法:
textNode.setTextContent(string)为文本节点设置内容,返回该节点textNode.canInsertTextAfter()返回一个布尔值,以控制在该节点后(append 的方式)是否可以继续插入文本TextNode默认返回true即可以在原来的文本节点中继续添加文本内容。
该方法一般是提供给TextNode的子类进行覆写的,以改变插入文本的行为,如果该返回false则表示节点不能继续添加文本内容,如果创建该节点后,需要继续输入文本,则会创建一个兄弟节点 sibling node(而不是将文本添加/追加到该节点里)
例如官方基于TextNode所创建的一个子类TabNode它用来表示一个缩进,并不允许在该节点中添加文本,所以要覆写该方法返回falsetextNode.canInsertTextBefore()返回一个布尔值,以控制该节点前(prepend 的方式 ❓)是否可以继续插入文本textNode.hasFormat(type: TextFormatType)返回一个布尔值,检测文本节点是否具有某种格式
其中入参type是TextFormatType类型,它可以是"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 元素会由默认样式,所以为文本节点设置这些格式后,页面会看到视觉上的变化
但是对于设置 underline 和 strikethrough 格式,默认情况下并不会造成有视觉上的变化,则需要先在编辑器的主题 theme 里进行配置,再通过 CSS 类名设置外观样式
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',
},
}
注意其中 strikethrough 和 underline 分别设置的是文本节点具有删除线或下划线格式时,对应的 DOM 元素会添加的 class 类名;而 underlineStrikethrough 所设置的是在文本节点同时具有删除线和下划线时,对应的 DOM 元素的 class 类名
例如对于 underline 文本格式,如果文本节点具有该格式时,则对应的 DOM 元素会添加上 editor-textUnderline class 类名,同时为页面添加以下的 CSS style 就可以让文本具有下划线
.editor-textUnderline {
text-decoration-line: underline;
}
textNode.isComposing()返回一个布尔值,检测文本节点的内容是否正在处于输入状态中(可以被变更)textNode.isDirectionless()返回一个布尔值,表示文本是否无方向性,如果为true则在RTL或LTR模式之间切换文本并不会变化textNode.toggleDirectionless()切换文本节点是否具有方向性textNode.isSegmented()返回一个布尔值,表示文本节点是否处于segmented模式。
处于这种模式下,文本节点的选取可以基于单个字符,但是删除时就只能基于空格键的分割出的 space-delimited 各部分(看作一个个整体)进行删除textNode.isToken()返回一个布尔值,表示文本节点是否处于token模式。
处于这种模式下,文本节点的选取可以基于单个字符,但是删除时就只能看作是一个整体进行删除textNode.isSimpleText()返回一个布尔值,以表示它是否为一个「简单」的文本节点,没有额外的设置,例如没有设置为segmented或token模式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 styletextNode.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 功能,只需要在编辑器的配置对象中进行设置,就可以将编辑器内容中的某一类节点(全部实例)都替换为另一个种类型的节点。
const editorConfig = {
// ...
nodes: [
// 在使用 CustomParagraphNode 自定义节点前
// 请别忘记在编辑器上进行注册
CustomParagraphNode,
// 针对 ParagraphNode 这一内置节点
// 将它在编辑器中的所有实例都替换为 CustomParagraphNode
{
replace: ParagraphNode,
with: (node: ParagraphNode) => {
return new CustomParagraphNode();
}
}
]
}
这在升级编辑器后,某些旧节点舍弃用新的节点替代,而为了兼容导入以前导出的序列化数据时特别有用
自定节点
lexical 核心包暴露出/导出 ElementNode、TextNode、DecoratorNode 这三种基本节点,可以对它们进行扩展,用于构造自定义的节点
扩展 ElementNode 元素节点
提示
Lexical 官方对 ElementNode 进行扩展制作了一些定制化的节点,例如 ParagraphNode 段落节点
可以参照和学习官方的定制化节点来制作自己的节点
以下是通过扩展 ElementNode 元素节点类,制作一个自定义段落节点的示例
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;
}
}
推荐
如果节点需要提供一些可供外部调用的方法,推荐使用 $ 作为前缀进行标记
其中一种比较常见的需求是验证给定的节点类型是否与该节点相同
export function $isCustomParagraphNode(node: ?LexicalNode): boolean {
return node instanceof CustomParagraph;
}
另一种常见的需求是基于已有的节点创建一个新的节点
export function $createCustomParagraphNode(): ParagraphNode {
return new CustomParagraph();
}
扩展 TextNode 文本节点
以下是通过扩展 TextNode 元素节点类,制作一个自定义叶子节点的示例
// 该节点的作用是为文本添加颜色
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 元素节点类,制作一个自定义装饰节点的示例
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;
}