构建富文本编辑器
Lexical 推出了一个模块包 @lexical/rich-text 可用于快速构建一个富文本编辑器,通过解读其源码来学习如何使用 Lexical 的核心库和其他模块。
初始化
和《构建纯文本编辑器》的初始化步骤一致
与纯文本编辑器相比,富文本编辑器需要创建一系列的自定义节点,例如 HeadingNode、ParagraphNode、QuoteNode 等
所以在使用 createEditor(config) 创建编辑器实例时,需要在配置对象 config 的 nodes 属性中列出所需要使用的节点类型
import { createEditor } from 'lexical';
// 假设需要使用 HeadingNode 标题节点
import { registerRichText, HeadingNode } from '@lexical/rich-text'
const config = {
namespace: 'MyEditor',
nodes: [HeadingNode],
onError: console.error
};
const editor = createEditor(config)
registerRichText(editor)
// contentEditableDOM 是指页面上的可编辑 DOM 元素
editor.setRootElement(contentEditableDOM)
提示
可以通过 editor._nodes(一个映射)获取已在编辑器中注册的节点类型
Lexical 官方所提供了 @lexical/rich-text 模块就自定义了两种节点:HeadingNode 标题节点和 QuoteNode 引文节点
此外该模块也有导出一个方法 registerRichText(editor) 可以为编辑器 editor 一次性添加上多种功能
引文节点
该模块基于 ElementNode 元素节点进行扩展,创建一个 QuoteNode 引文节点
export class QuoteNode extends ElementNode {
/**
* 所有节点都有的两个静态方法
*/
// * static getType()
// * static clone()
// 获取该自定义节点的名称
static getType(): string {
return 'quote'; // 该自定义节点的名称是 'quote'
}
// 创建一个该节点的拷贝
static clone(node: QuoteNode): QuoteNode {
// 基于该节点的唯一标识符 __key 创建一个拷贝
return new QuoteNode(node.__key);
}
// 实例化
constructor(key?: NodeKey) {
// 通过 key 创建一个节点
super(key);
}
/**
* View
* 与视图相关的方法
*/
// 该节点在页面上所对应的 DOM 元素结构
createDOM(config: EditorConfig): HTMLElement {
const element = document.createElement('blockquote');
// 该节点支持设置 theme 主题
// 可以在初始化编辑器时,设置配置对象 config 的 theme.quote 属性
// 这样引文节点所对应的 DOM 元素就会添加上相应的 class 类名
// 参考 https://lexical.dev/docs/getting-started/theming
// 使用 addClassNamesToElement() 方法将该类名添加到 DOM 元素上
// 该方法从 @lexical/utils 模块导出
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-utils/src/index.ts#L38-L48
addClassNamesToElement(element, config.theme.quote);
return element;
}
// 更新节点
updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean {
// 并不需要重新调用 createDOM() 创建一个新的 DOM 元素来取代页面上原有的旧元素
return false;
}
// 将 DOM 元素反序列化为节点时调用的方法(静态方法)
static importDOM(): DOMConversionMap | null {
// 它将 <blockquote> DOM 元素转换为 QuoteNode 节点实例
return {
blockquote: (node: Node) => ({
conversion: convertBlockquoteElement,
priority: 0, // 优先级是 0(最低)
}),
};
}
// 将 JSON 数据反序列化为节点时调用的方法(静态方法)
// 入参是一个对象,是序列化后的节点
static importJSON(serializedNode: SerializedQuoteNode): QuoteNode {
// 创建一个节点实例
// 如果在创建编辑器时,没有在配置对象的 replace 中设置取代 QuoteNode 引文节点的方法
// 则这里创建的就是 QuoteNode 引文节点
// 否则就是相应的 replace 取代节点
const node = $createQuoteNode();
// 并调用(父类)ElementNode 元素节点的方法
// 将 serializedNode 的相应字段设置为节点实例的相应属性
// 设置元素节点的格式方式
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalElementNode.ts#L357-L361
node.setFormat(serializedNode.format);
// 设置元素节点的缩进等级
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalElementNode.ts#L362-L366
node.setIndent(serializedNode.indent);
// 设置元素节点的方向(文本方向,从左往右或从右往左)
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalElementNode.ts#L352-L356
node.setDirection(serializedNode.direction);
return node; // 最后返回该节点
}
// 节点序列化为 JSON 格式时调用的方法
exportJSON(): SerializedElementNode {
return {
// 这里使用了父类 ElementNode 元素节点的 exportJSON() 方法
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalElementNode.ts#L498-L507
// 并进行扩展,覆盖了属性 type
// 父类返回的对象中属性 type 的值是 'element'
// 这里改成 'quote'
...super.exportJSON(),
type: 'quote',
};
}
/**
* Mutation
* 与节点变换相关的方法
* insertNewAfter() 方法是用户在节点内(具体是指范围选区的锚定端点在节点内)按下回车键时,范围选区会调用的方法
* 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalSelection.ts#L1611-L1719
* collapseAtStart() 方法是用户在节点开头(具体是指范围选区的锚定端点在节点的开头)按下 `Backspace` 键时,范围选区会调用的方法(而且该节点位于编辑器的**开头** ❓)
* 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalSelection.ts#L1934-L2035
*/
// 设置在该节点上按回车键时的行为 ❓
// 预期行为是「跳出」该节点 ❓ 所以该方法是在该节点之后插入一个 ParagraphNode 段落节点
// 可以浏览 https://playground.lexical.dev/ 试试在线 DEMO
// 了解该类型节点的行为
insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ParagraphNode {
const newBlock = $createParagraphNode();
const direction = this.getDirection();
newBlock.setDirection(direction);
// 调用祖先类 LexicalNode 的方法 currentNode.insertAfter(nodeToInsert, restoreSelection?)
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalNode.ts#L702-L760
// 该方法会在当前节点 currentNode 后面插入一个节点
// 即 nodeToInsert 节点的唯一标识符 NodeKey 会作为当前节点的属性 __next 的值
// 并根据入参 restoreSelection 来判断是否重置选区(默认为 true)
// 所以这里的操作是将新建的段落节点 newBlock 插入到 this 该引文节点之后
this.insertAfter(newBlock, restoreSelection);
return newBlock;
}
// 所设置的行为是当该节点位于编辑器的**开头**按下 Backspace 键的行为 ❓
// 按下 Backspace 键才会将选区 collapse 到该节点的开头
// 如果该节点位于编辑器中间,则按下 Backspace 键选取会「跳入」上一个 ElementNode 节点
// 则当前的节点会自动转换/合并到前一个 ElementNode 节点
// 该方法是专门针对一种场景所设置的处理逻辑,即通过按 `Backspace` 键把光标移动到节点开头
// 因为将光标移动到 ElementNode 元素节点开头,分两种场景
// * 一种是将该元素节点的内容(子节点)刚好删除完,则光标就刚好位于该元素节点的开头
// * 另一种是该元素节点还有内容(子节点)
// **** 假设是在一个 ParagraphNode 内有一个 TextNode,而文本节点存在就说明还有文字内容在页面上
// **** 虽然可以将光标置于段落开头,在视觉上光标是位于段落节点的开头
// **** 但实际上光标是位于文本节点(子节点)的开头
// **** 所以需要再**按一遍 `Backspace` 键**,才可以将光标移动到 ParagraphNode 开头
// 该方法就是需要设置这两种情况下的处理逻辑
// collapse 是指范围选区的锚点和焦点一致,即变成一个光标时
// at start 是指光标在该节点(内部)的开头 ❓
// 返回一个布尔值,表示该方法是否已经处理完成了这一次(用户的按键操作)交互 ❓
// 预期行为是「取消」引文节点 ❓ 将原来属于引文节点的内容(后代节点)都移到一个段落节点内
collapseAtStart(): true {
// 创建一个 ParagraphNode 段落节点实例
const paragraph = $createParagraphNode();
// 如果节点原来是有内容的(即有子节点)
const children = this.getChildren();
// 则将这些子节点依次 append 移到新建的段落节点内
children.forEach((child) => paragraph.append(child));
// 这里调用了祖先类 LexicalNode 的方法 replace() 对段落节点进行处理
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalNode.ts#L645-L700
// 因为在 Lexical 系统中设计了一个覆盖节点的功能
// 可以在全局的层面将特定类型的节点替换为指定的节点
this.replace(paragraph);
return true; // 返回 true 表示交互已经处理完成 ❓
}
}
其中在 importDOM() 方法中会调用的转换方法 convertBlockquoteElement() 如下
function convertBlockquoteElement(): DOMConversionOutput {
const node = $createQuoteNode();
return {node};
}
// 上面所使用到的一个方法 $createQuoteNode()
// 它是该模块导出的一个方法
// 用于创建一个 QuoteNode 节点实例
// 这里不直接返回 QuoteNode 节点实例
// 而是先经过 $applyNodeReplacement() 方法的处理
// 因为在 Lexical 系统中设计了一个覆盖节点的功能
// 可以在全局的层面将特定类型的节点替换为指定的节点
// 具体可以查看官方文档 https://lexical.dev/docs/concepts/node-replacement
// 或笔记的相关部分 https://frontend-note.benbinbin.com/article/lexical/lexical-concept-node#覆写节点
// 这里采用的方法 $applyNodeReplacement() 由 lexical 的核心模块提供
// 具体参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalUtils.ts#L1346-L1370
export function $createQuoteNode(): QuoteNode {
return $applyNodeReplacement(new QuoteNode());
}
Typescript
与以上代码相关的一些 TypeScript 代码,用于设置一些变量或参数的类型
// Spread 是一个自定义的类型
// 它接受两个变量 Spread<T1, T2>
// 作用是使用 T1 拓展 T2 对象的属性(如果 T1 和 T2 有相同的属性,则采用 T1 的属性)
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalEditor.ts#LL40
// QuoteNode 引文节点序列化后的类型
export type SerializedQuoteNode = Spread<
{
type: 'quote';
version: 1;
},
SerializedElementNode
>;
// DOMConversionMap 是一个映射类型
// 将一个 DOM 的名称(小写)与一个函数对应起来
// 该函数返回一个 DOMConversion 对象,其中包含着属性 conversion(转换方法)和属性 priority(转换优先级)
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalNode.ts#L135-L138
export type DOMConversionMap<T extends HTMLElement = HTMLElement> = Record<
NodeName,
(node: T) => DOMConversion<T> | null
>;
该模块还提供了一个关于 QuoteNode 引文节点的方法 $isQuoteNode(node) 用于判断给定的 node 节点是否为 QuoteNode 引文节点实例
export function $isQuoteNode(
node: LexicalNode | null | undefined,
): node is QuoteNode {
return node instanceof QuoteNode;
}
标题节点
该模块基于 ElementNode 元素节点进行扩展,创建一个 HeadingNode 引文节点
export class HeadingNode extends ElementNode {
/**
* @internal
* HeadingNode 标题节点的私有属性
*/
// 表示标题的层级(共 6 级)
__tag: HeadingTagType;
/**
* 所有节点都有的两个静态方法
*/
// * static getType()
// * static clone()
// 获取该自定义节点的名称
static getType(): string {
return 'heading'; // 该自定义节点的名称是 'heading'
}
// 创建一个该节点的拷贝
static clone(node: HeadingNode): HeadingNode {
// 基于该节点的属性 __tag 和它的唯一标识符 __key 创建一个拷贝
return new HeadingNode(node.__tag, node.__key);
}
// 实例化
constructor(tag: HeadingTagType, key?: NodeKey) {
super(key); // 除了通过 key 创建一个节点
// 还需要设置它的 __tag 属性,即标题的层级
this.__tag = tag;
}
// 获取该标题节点的层级
// 即返回该节点属性 __tag 的值
getTag(): HeadingTagType {
return this.__tag;
}
/**
* View
* 与视图相关的方法
*/
// 该节点在页面上所对应的 DOM 元素结构
createDOM(config: EditorConfig): HTMLElement {
// 基于该标题节点的属性 __tag 创建相应的 DOM 元素
const tag = this.__tag;
const element = document.createElement(tag);
// 该节点支持设置 theme 主题
// 例如可以在初始化编辑器时
// 设置配置对象 config 的 theme.heading.h2 属性
// 为「二级」标题节点所对应的 DOM 元素 <h2> 添加上相应的 class 类名
const theme = config.theme;
const classNames = theme.heading;
if (classNames !== undefined) {
// 从配置对象中获取相应标题层级的类名
const className = classNames[tag];
addClassNamesToElement(element, className);
}
return element;
}
// 更新节点
updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean {
// 并不需要重新调用 createDOM() 创建一个新的 DOM 元素来取代页面上原有的旧元素
return false;
}
// 将 DOM 元素反序列化为节点时调用的方法(静态方法)
static importDOM(): DOMConversionMap | null {
// 将 <h1> 至 <h6> DOM 元素转换为相应层级的 HeadingNode 节点实例
// 还匹配了 <p> 和 <span> DOM 元素这是为了兼容 Google Doc
return {
h1: (node: Node) => ({
conversion: convertHeadingElement,
priority: 0,
}),
h2: (node: Node) => ({
conversion: convertHeadingElement,
priority: 0,
}),
h3: (node: Node) => ({
conversion: convertHeadingElement,
priority: 0,
}),
h4: (node: Node) => ({
conversion: convertHeadingElement,
priority: 0,
}),
h5: (node: Node) => ({
conversion: convertHeadingElement,
priority: 0,
}),
h6: (node: Node) => ({
conversion: convertHeadingElement,
priority: 0,
}),
// 这是为了兼容从 Google Doc 拷贝过来的内容
p: (node: Node) => {
// Node is a <p> since we matched it by nodeName
const paragraph = node as HTMLParagraphElement;
const firstChild = paragraph.firstChild;
if (firstChild !== null && isGoogleDocsTitle(firstChild)) {
return {
conversion: () => ({node: null}),
priority: 3,
};
}
return null;
},
span: (node: Node) => {
if (isGoogleDocsTitle(node)) {
return {
conversion: (domNode: Node) => {
return {
node: $createHeadingNode('h1'),
};
},
priority: 3,
};
}
return null;
},
};
}
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 && this.isEmpty()) {
// 这里采用父类 ElementNode 的方法 isEmpty() 判断是否有子节点(即标题是否内容)
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalElementNode.ts#L120-L122
// 如果没有内容就在导出 DOM 时添加一个 <br/> 元素,作为占位符 ❓
element.append(document.createElement('br'));
}
if (element) {
const formatType = this.getFormatType();
element.style.textAlign = formatType;
const direction = this.getDirection();
if (direction) {
element.dir = direction;
}
}
return {
element,
};
}
// 将 JSON 数据反序列化为节点时调用的方法(静态方法)
static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
// 根据序列化后的字段 tag 创建一个层级相应的 HeadingNode 标题节点
const node = $createHeadingNode(serializedNode.tag);
// 并调用(父类)ElementNode 元素节点的相应方法设置节点的相应属性
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}
// 节点序列化为 JSON 格式时调用的方法
exportJSON(): SerializedHeadingNode {
return {
// 这里先使用了父类 ElementNode 元素节点的 exportJSON() 方法
// 并进行扩展,覆盖了属性 type 和 version
// 添加了 tag 属性,以便储存该标题节点的层级信息
...super.exportJSON(),
tag: this.getTag(),
type: 'heading',
version: 1,
};
}
/**
* Mutation
* 与节点变换相关的方法
*/
// 设置在该节点上按回车键时的行为
// 这里根据光标的锚点位置而采取不同的处理逻辑
insertNewAfter(
selection?: RangeSelection,
restoreSelection = true,
): ParagraphNode | HeadingNode {
// 当前编辑器的范围选区中锚定一侧端点的位置(即框选开始时光标的位置)
const anchorOffset = selection ? selection.anchor.offset : 0;
// 创建的节点根据锚点是否落在标题节点内(其实是落在子节点,文本节点内)而定
// 如果在标题中,则按下回车键后,所创建的下一个节点就是一个和当前层级一样的标题节点
// 否则在按下回车键后创建的下一个节点就会是段落节点
// 但是如果锚点在标题的开头行为好像不一致 ❓
// 此时 anchorOffset = 0 不在所讨论的条件中 ❓
// 默认行为是也会创建一个相同的标题节点(这似乎是 LexicalSelection 的内置逻辑)
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalSelection.ts#L1611-L1719
const newElement =
anchorOffset > 0 && anchorOffset < this.getTextContentSize()
? $createHeadingNode(this.getTag())
: $createParagraphNode();
const direction = this.getDirection();
newElement.setDirection(direction);
// 将新建的节点 newElement 插入到该标题节点之后
this.insertAfter(newElement, restoreSelection);
return newElement;
}
// 设置光标移动到节点开头的行为
collapseAtStart(): true {
// 如果该标题节点此时没有内容(子节点),则创建一个段落节点
// 如果该标题节点还有内容,则创建一个层级相同的标题节点
// 从交互而言,如果该节点位于编辑器的开头,将光标移到最前面,并按下 `Backspace` 键
// 当节点为空时,会删除标题节点,「恢复」为段落节点;当节点含有内容,则按下 `Backspace` 键并没有变换
const newElement = !this.isEmpty()
? $createHeadingNode(this.getTag())
: $createParagraphNode();
// 将子节点都移到新建的节点内
const children = this.getChildren();
children.forEach((child) => newElement.append(child));
this.replace(newElement);
return true;
}
// 该方法用于 @lexical/clipboard 模块中
// 以实现复制粘贴的相关功能
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-clipboard/src/clipboard.ts#L489
extractWithChild(): boolean {
return true;
}
}
其中在 importDOM() 方法中调用的转换方法 convertHeadingElement() 和判断节点是否为 Google Doc 标题的方法 isGoogleDocsTitle() 如下
function convertHeadingElement(domNode: Node): DOMConversionOutput {
const nodeName = domNode.nodeName.toLowerCase();
let node = null;
// 这里先判断 DOM 元素的名称,是否为 h1 至 h6 中的任意之一
// 其实并不需要,因为调用该函数之前已经进行约束了
if (
nodeName === 'h1' ||
nodeName === 'h2' ||
nodeName === 'h3' ||
nodeName === 'h4' ||
nodeName === 'h5' ||
nodeName === 'h6'
) {
// 基于 DOM 元素的名称(小写)生成相应层级的 HeadingNode 标题节点
node = $createHeadingNode(nodeName);
}
return {node};
}
// 该模块导出的一个方法
// 用于创建一个 HeadingNode 标题实例
export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode {
// 创建 HeadingNode 标题节点实例时,传入的参数用作标题的层级,即属性 __tag
// 不直接返回 HeadingNode 而是先经过 $applyNodeReplacement() 方法的处理
// 因为在 Lexical 系统中设计了一个覆盖节点的功能
return $applyNodeReplacement(new HeadingNode(headingTag));
}
// 判断 DOM 元素是否为 Google Doc 的标题
function isGoogleDocsTitle(domNode: Node): boolean {
// 该 DOM 元素需要是 <span>
if (domNode.nodeName.toLowerCase() === 'span') {
// 且字体大小为 26pt
return (domNode as HTMLSpanElement).style.fontSize === '26pt';
}
return false;
}
Typescript
与以上代码相关的一些 TypeScript 代码,用于设置一些变量或参数的类型
// HeadingNode 标题节点共有 6 种层级
// 对应于 DOM 元素 <h1> 到 <h6>
export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
// HeadingNode 标题节点序列化后的类型
export type SerializedHeadingNode = Spread<
{
// 用 tag 表示标题节点的层级
tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
type: 'heading';
version: 1;
},
SerializedElementNode
>;
该模块还提供了一个关于 HeadingNode 标题节点的方法 $isHeadingNode(node) 用于判断给定的 node 节点是否为 HeadingNode 标题节点实例
export function $isHeadingNode(
node: LexicalNode | null | undefined,
): node is HeadingNode {
return node instanceof HeadingNode;
}
和 @lexical/plain-text 模块包 类似,该模块包也调用了一系列以 register 开头的方法,为不同的指令注册处理函数
相应地,该模块导出一个方法 registerRichText(editor) 可以为编辑器 editor 一次性添加上多种功能
点击行为
当用户在编辑器中点击时,Lexical 会分发 CLICK_COMMAND 指令。
点击的默认行为是将光标移动到指定的位置,这是文本节点内的常见行为,更准确而言是 RangeSelection 范围选区的行为。
例如在纯文本编辑器中,框选了一段文字,如果用户在编辑器的任意位置点击,则原有的范围选区就会取消,在点击的位置构成一个新的范围选区(此时依然构成一个范围选区,只是光标的锚点和焦点 collapsed 「坍缩」到同一个位置)
但是在富文本编辑器中,叶子节点不仅有文本节点,还可能有其他类型的节点,例如图像节点。如果点击这些非文本的叶子节点时,默认行为 ❓ 应该是取消原有的选区,并创建一个 NodeSelection 节点选区(而不是 RangeSelection 范围选区)
该模块则通过为指令 CLICK_COMMAND 设置处理函数,block 「屏蔽」了点击非文本的叶子节点时会创建 NodeSelection 节点选区的这个默认行为
editor.registerCommand(
CLICK_COMMAND,
// 入参是鼠标事件 MouseEvent
(payload) => {
// 获取点击后(将要 ❓)创建的选区
const selection = $getSelection();
if ($isNodeSelection(selection)) {
// 如果是节点选区
// 则调用 NodeSelection 节点选区的方法 selection.clear() 将其选中的节点清空
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalSelection.ts#L298-L302
// (同时会导致节点选区的取消 ❓)
selection.clear();
return true; // 返回 true 表示该指令已处理完毕
}
return false; // 否则返回 false 可以调用其他处理函数
},
0, // 优先级最低
)
// 所以点击非文本的叶子节点时
// 并不会创建 `NodeSelection` 节点选区
// 也不会改变原有的选区
提示
对于 ElementNode 元素节点,它们一般作为其他子节点的容器,在 ElementNode 元素内进行点击时,(范围选区)光标一般落在其子节点内,即也是与子节点(例如其中的文本节点)交互而不是 ElementNode 元素节点直接交互
删除内容
该模块中实现删除功能的主要方式也是借助 KEY_BACKSPACE_COMMAND 和 KEY_DELETE_COMMAND 这两个(内置)指令,以下分别是它们的处理函数
/**
* 注册 KEY_BACKSPACE_COMMAND 指令的处理函数
*/
editor.registerCommand<KeyboardEvent>(
KEY_BACKSPACE_COMMAND,
// 入参是键盘事件 KeyboardEvent
(event) => {
// 而 `Backspace` 键的监听器是注册在编辑器的 root element 根元素的
// 所以只要焦点在编辑器内的时候,按下 `Backspace` 键就会分发 KEY_BACKSPACE_COMMAND 指令
// 但是富文本编辑器中可能有 DecoratorNode 装饰节点
// DecoratorNode 装饰节点一般就是内嵌在编辑器中的组件
// 很多时候它的行为和编辑器并不一致
// 所以它针对 `Backspace` 键的处理逻辑可能并不是和编辑器(默认是删掉内容)的处理逻辑一致
// 所以这里先判度触发按键事件 event 的 DOM 元素所对应的节点是否为 DecoratorNode 装饰节点
// 如果是,则直接返回 false,不执行该处理函数后面的部分
// 这里使用的方法 $isTargetWithinDecorator() 也是定义在该模块中
if ($isTargetWithinDecorator(event.target as HTMLElement)) {
return false;
}
// 获取当前的选区
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
event.preventDefault();
// 获取范围选区的锚点
const {anchor} = selection;
// 获取锚点位置的节点
const anchorNode = anchor.getNode();
// 如果范围选区是 collapsed (锚点和焦点「坍缩」到同一个位置),即以光标形式存在
// 且位于节点的开头
// 而且不是在根节点上
if (
selection.isCollapsed() &&
anchor.offset === 0 &&
!$isRootNode(anchorNode)
) {
// 则寻找该节点的最近的 ElementNode 元素节点(嵌套结构的上一层容器)
// 该方法由核心库导出
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-utils/src/index.ts#L184-L201
const element = $getNearestBlockElementAncestorOrThrow(anchorNode);
if (element.getIndent() > 0) {
// 如果该 ElementNode 元素节点缩进量大于 0
// 则「二级」分发 `OUTDENT_CONTENT_COMMAND` 指令
// 所以当光标在一个具有缩进的 ELementNode 开头时
// 那么按下 `Backspace` 键时,其预期行为是减少缩进
return editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
}
}
// 否则「二级」分发 `DELETE_CHARACTER_COMMAND` 指令
// 其预期行为是删除一个字符串
// 因为按下的是 `Backspace` 键,所以传入的第二个参数是 true
// 表示光标需要回退以删除前面的内容
return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
},
COMMAND_PRIORITY_EDITOR, // 相当于 0
)
/**
* 注册 KEY_DELETE_COMMAND 指令的处理函数
*/
editor.registerCommand<KeyboardEvent>(
KEY_DELETE_COMMAND,
(event) => {
// 先判断是否位于 DecoratorNode 装饰节点内
if ($isTargetWithinDecorator(event.target as HTMLElement)) {
return false;
}
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
event.preventDefault();
// 因为按下的是 `Delete` 键,所以传入的第二个参数是 false
// 表示光标不需要回退,删除的是光标后面的内容
return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false);
},
COMMAND_PRIORITY_EDITOR,
)
提示
以上代码中使用了一些方法的源码如下
// 用于判断该 DOM 元素所对应的节点是否为 DecoratorNode 装饰节点
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-rich-text/src/index.ts#L467-L470
function $isTargetWithinDecorator(target: HTMLElement): boolean {
// 方法 $getNearestNodeFromDOMNode(target) 可以基于 DOM 元素获取所对应的(最近的)节点
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalUtils.ts#L405-L418
const node = $getNearestNodeFromDOMNode(target);
// 方法 $isDecoratorNode(node) 判断该节点 node 是否为装饰节点
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalDecoratorNode.ts#L40-L44
return $isDecoratorNode(node);
}
而 DELETE_CHARACTER_COMMAND(内置)指令的处理函数则是
editor.registerCommand<boolean>(
DELETE_CHARACTER_COMMAND,
(isBackward) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
// 调用选区的方法 deleteCharacter() 删除内容
selection.deleteCharacter(isBackward);
return true;
},
COMMAND_PRIORITY_EDITOR,
)
和纯文本编辑器类似,该模块也针对 DELETE_WORD_COMMAND 和 DELETE_LINE_COMMAND(内置)指令设置了处理函数,以实现删除一个单词或一行内容
editor.registerCommand<boolean>(
DELETE_WORD_COMMAND,
(isBackward) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
selection.deleteWord(isBackward);
return true;
},
COMMAND_PRIORITY_EDITOR,
)
editor.registerCommand<boolean>(
DELETE_LINE_COMMAND,
(isBackward) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
selection.deleteLine(isBackward);
return true;
},
COMMAND_PRIORITY_EDITOR,
)
插入内容
和纯文本编辑器类似,该模块实现输入功能是也通过响应 CONTROLLED_TEXT_INSERTION_COMMAND 这个内置指令来实现的
以下是 CONTROLLED_TEXT_INSERTION_COMMAND(内置)指令的处理函数
editor.registerCommand(
CONTROLLED_TEXT_INSERTION_COMMAND,
// 入参 eventOrText 是输入事件 InputEvent 或输入的文本 string
(eventOrText) => {
const selection = $getSelection();
// 判断参数 eventOrText 的数据类型,以采取不同的处理方式
if (typeof eventOrText === 'string') {
// 判断入参是否就是文本内容
if ($isRangeSelection(selection)) {
// 判断选区是否为范围选区
// 如果是,则将文本插入到选区/光标处
selection.insertText(eventOrText);
} else if (DEPRECATED_$isGridSelection(selection)) {
// 针对 GridSelection 网格选区的操作
// 一般是指在 table 表格中的操作,但是该处理逻辑已经迁移到 Table Plugin ❓
// 参考 https://lexical.dev/docs/demos/plugins/tables
// TODO: Insert into the first cell & clear selection.
}
} else {
// 如果当前的选区不是 RangeSelection 范围选区或 GridSelection 网格选区
// 即为 NodeSelection 节点选区,则不进行处理
// 例如将文本以拖拽的方式 drop 到某个非文本的叶子节点(如图片节点)上时,并不会进行处理
if (
!$isRangeSelection(selection) &&
!DEPRECATED_$isGridSelection(selection)
) {
return false;
}
const dataTransfer = eventOrText.dataTransfer;
if (dataTransfer != null) {
// 兼容通过拖拽进行文本输入操作
$insertDataTransferForRichText(dataTransfer, selection, editor);
} else if ($isRangeSelection(selection)) {
const data = eventOrText.data;
if (data) {
selection.insertText(data);
}
return true;
}
}
return true;
},
COMMAND_PRIORITY_EDITOR,
)
提示
类似地,该模块也对 REMOVE_TEXT_COMMAND 指令设置了处理函数,从编辑器中移除相应的内容。
editor.registerCommand(
REMOVE_TEXT_COMMAND,
() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
selection.removeText();
return true;
},
COMMAND_PRIORITY_EDITOR,
)
这是针对 IME 将输入按键转换为其他语言的字元的输入法或以拖拽方式移除内容(但是从以上的代码可以看出,该模块并没有支持 ❓)的场景。
换行
换行操作一般是通过按下 Enter 键 来实现的。
当用户按下 Enter 键 时,编辑器会自动分发 KEY_ENTER_COMMAND 指令,所以可以为该指令设置处理函数并在其中实现换行相关的逻辑。
要实现换行,除了插入 LineBreakNode 换行节点(一般用于纯文本编辑器 ❓),还可以插入一个新的 ElementNode 元素节点,例如 ParagraphNode(一般用于富文本编辑器 ❓),针对不同需求采用不同的方式。
所以该模块在 KEY_ENTER_COMMAND(内置)指令的处理函数中「二级」分发了 INSERT_LINE_BREAK_COMMAND 或 INSERT_PARAGRAPH_COMMAND 指令
editor.registerCommand<KeyboardEvent | null>(
KEY_ENTER_COMMAND,
(event) => {
// 通过 $getSelection() 方法获取当前的选区
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
if (event !== null) {
// If we have beforeinput, then we can avoid blocking
// the default behavior. This ensures that the iOS can
// intercept that we're actually inserting a paragraph,
// and autocomplete, autocapitalize etc work as intended.
// This can also cause a strange performance issue in
// Safari, where there is a noticeable pause due to
// preventing the key down of enter.
if (
(IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) &&
CAN_USE_BEFORE_INPUT
) {
return false;
}
event.preventDefault();
// 如果同时按下了 `Shift` 键则「二级」分发 `INSERT_LINE_BREAK_COMMAND` 以实现换行
if (event.shiftKey) {
return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
}
}
// 如果**没有**同时按下了 `Shift` 键则「二级」分发 `INSERT_PARAGRAPH_COMMAND` 以实现换行
return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
},
COMMAND_PRIORITY_EDITOR,
)
以下是 INSERT_LINE_BREAK_COMMAND(内置)指令的处理函数
editor.registerCommand<boolean>(
INSERT_LINE_BREAK_COMMAND,
(selectStart) => {
// 通过 $getSelection() 方法获取当前的选区
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
selection.insertLineBreak(selectStart);
return true;
},
COMMAND_PRIORITY_EDITOR,
)
而以下则是 INSERT_PARAGRAPH_COMMAND(内置)指令的处理函数
editor.registerCommand(
INSERT_PARAGRAPH_COMMAND,
() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
// 调用选区的方法 insertParagraph() 插入一个 ElementNode 元素节点
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalSelection.ts#L1611-L1719
// 这个 ElementNode 元素节点究竟是什么,取决于按下回车键时光标/选区所在的节点其父节点是什么
// 因为会调用 parent.insertNewAfter() 来生成一个 ElementNode 元素节点
selection.insertParagraph();
return true;
},
COMMAND_PRIORITY_EDITOR,
)
格式化
有两种类型的格式化
- 文本的格式化:顾名思义就是指文字的外观形式,例如是否进行了加粗、是否具有下划线、是否作为上标或下标等,文本节点通过属性
__format来表示(一个数值,表示相应的格式组合)
可以通过调用文本节点的方法TextNode.setFormat(format)进行设置,其中入参format可以是'bold'、'italic'、'underline'、'strikethrough'、'code'、'subscript'、'superscript'、'highlight'之中的任一值 - 元素节点的格式化:是指元素节点(内容)的对齐方式,元素节点通过属性
__format来表示(一个数值,表示相应的格式组合)
可以通过调用文本节点的方法ElementNode.setFormat(format)进行设置,其中入参format可以是'left'、'start'、'center'、'right'、'end'、'justify'、''之中的任一值
该模块分别为 FORMAT_TEXT_COMMAND 和 FORMAT_ELEMENT_COMMAND 这两种(内置)指令设置处理函数,以实现上述的两种类型的格式化
editor.registerCommand<TextFormatType>(
FORMAT_TEXT_COMMAND,
(format) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
// 调用选区的方法 formatText(formatType: TextFormatType) 设置选区内的文本格式
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalSelection.ts#L1223-L1345
selection.formatText(format);
return true;
},
COMMAND_PRIORITY_EDITOR,
)
editor.registerCommand<ElementFormatType>(
FORMAT_ELEMENT_COMMAND,
(format) => {
const selection = $getSelection();
if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
return false;
}
// 调用(范围选区或节点选区)选区的方法 getNodes() 获取选区中节点
const nodes = selection.getNodes();
// 遍历节点
for (const node of nodes) {
// (通过迭代回溯)获取距离当前节点的**最近的 ElementNode 类型的节点**(父节点)
// 该方法由 @lexical/utils 模块导出
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-utils/src/index.ts#L263-L278
const element = $findMatchingParent(
node,
(parentNode) =>
$isElementNode(parentNode) && !parentNode.isInline(),
);
if (element !== null) {
element.setFormat(format);
}
}
return true;
},
COMMAND_PRIORITY_EDITOR,
)
相应地,可以为富文本编辑器添加一个工具栏 toolbar,其中设置有一系列按钮以分发 FORMAT_TEXT_COMMAND 或 FORMAT_ELEMENT_COMMAND 指令(并传递相应的参数)
// 例如通过点击按钮可以加粗文本内容
btn.addEventListener('click', () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
})
缩进
缩进(或减少缩进)一般是按 Tab 键(减少缩进一般需要同时按下 Shift 键)来实现的。
当用户按下 Tab 键 时,编辑器会自动分发 KEY_TAB_COMMAND 指令,所以可以为该指令设置处理函数并在其中实现缩进相关的逻辑。
Lexical 内置提供了两个与缩进相关的指令 INDENT_CONTENT_COMMAND 和 OUTDENT_CONTENT_COMMAND,该模块都分别为这两个(内置)指令设置了处理函数,实现缩进和减少缩进的相关逻辑
// 缩进
editor.registerCommand(
INDENT_CONTENT_COMMAND,
() => {
// 调用核心函数是 `handleIndentAndOutdent()` 进行处理
// 该方法也是在该模块中定义的
// 它所接受的两个参数都是函数
// 分别对应于处理缩进的两种方法,根据节点的属性采用其中一种方式
handleIndentAndOutdent(
// 针对文本节点的处理方式 ❓
() => {
// 直接在文本内容中插入制表符
editor.dispatchCommand(CONTROLLED_TEXT_INSERTION_COMMAND, '\t');
},
// 针对元素节点的处理方式 ❓
(block) => {
const indent = block.getIndent();
// 最大的缩进「程度」是 10
if (indent !== 10) {
// 设置节点的 __indent 属性,增加 1
block.setIndent(indent + 1);
}
},
);
return true;
},
COMMAND_PRIORITY_EDITOR,
)
// 减少缩进
editor.registerCommand(
OUTDENT_CONTENT_COMMAND,
() => {
handleIndentAndOutdent(
// 针对文本节点处理方式
(node) => {
if ($isTextNode(node)) {
const textContent = node.getTextContent();
const character = textContent[textContent.length - 1];
// 如果该文本节点的内容就是制表符
if (character === '\t') {
// 直接删掉制表符
editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
}
}
},
//针对元素节点的处理方式
(block) => {
const indent = block.getIndent();
if (indent !== 0) {
// 设置节点的 __indent 属性,增加 1
block.setIndent(indent - 1);
}
},
);
return true;
},
COMMAND_PRIORITY_EDITOR,
)
提示
改变节点的 __indent 属性会触发 DOM 元素的 CSS 属性 padding-inline-start 的改变,以实现缩进的效果
在以上代码中所调用的一个核心函数是 handleIndentAndOutdent,该函数也是在该模块中定义的
// 处理缩进和减少缩进的核心函数
// 它所接受的两个参数都是函数
// 分别对应于处理缩进的两种方法,根据节点的属性采用其中一种方式:
// * 直接插入制表符
// * 或对节点的属性 __indent 进行设置
function handleIndentAndOutdent(
insertTab: (node: LexicalNode) => void,
indentOrOutdent: (block: ElementNode) => void,
): void {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
// 使用集合 set 来记录节点(父节点,通过 NodeKey 来表示)是否已经进行处理
const alreadyHandled = new Set();
// 调用范围选区的方法 getNodes() 获取选区中节点
const nodes = selection.getNodes();
// 遍历节点
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const key = node.getKey(); // 获取节点的唯一标识符 NodeKey
// 先判断它是否已经处理
if (alreadyHandled.has(key)) {
continue;
}
// 获取该节点最近的 ElementNode 类型的节点(父节点)
const parentBlock = $getNearestBlockElementAncestorOrThrow(node);
const parentKey = parentBlock.getKey();
// 根据父节点的属性,采用不同的缩进处理方式
// ElementNode 元素节点默认不支持在内部直接插入制表符
// 所以默认采用设置 __indent 属性再通过改变 CSS 的属性 `padding-inline-start` 的方式来实现缩进
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/nodes/LexicalElementNode.ts#L515-L520
if (parentBlock.canInsertTab()) {
// 如果父节点(内部)支持插入制表符,则调用 insertTab() 方法
insertTab(node);
alreadyHandled.add(key);
} else if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) {
// 如果父节点(内部)不支持插入制表符
// 但支持缩进操作,且未经过处理
// 则用 indentOrOutdent() 方法
// 并加入 set 集合中
alreadyHandled.add(parentKey);
indentOrOutdent(parentBlock);
}
}
}
提示
但是该模块并没有为 KEY_TAB_COMMAND (内置)指令设置处理函数,而且该模块只是在 KEY_BACKSPACE_COMMAND 指令的处理函数中「二级」分发了 OUTDENT_CONTENT_COMMAND 指令,即该模块只在特定的情况下(在具有缩进的节点开头按下 Backspace 键时)才会触发减少缩进的行为
可以添加以下代码片段,让编辑器支持用 Tab 键 作为缩进(或减少缩进)的快捷键
import { KEY_TAB_COMMAND, INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND } from 'lexical';
editor.registerCommand(
KEY_TAB_COMMAND,
(event) => {
event.preventDefault()
if(event.shiftKey) {
// 如果同时按下 `Shift` 键则减少缩进
// 不需要传递额外的数据,所以第二个参数 payload 为 undefined
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
} else {
// 缩进
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
}
return true
},
COMMAND_PRIORITY_EDITOR
)
提示
Tab 键在表格中的预期行为和文本中的不一样。
Tab 键在表格中更常见的操作是实现焦点在相邻单元格之间的切换,可以设置一个更高优先级的 INDENT_CONTENT_COMMAND 和 OUTDENT_CONTENT_COMMAND 指令处理函数,专门实现 Tab 键在表格中的行为逻辑。
具体实现可以参考这个插件(虽然它是为 lexical-react 设计的)。
方向键
对于方向键的响应,Lexical 编辑器默认使用 contenteditable DOM 元素对于方向键的(原生)处理方式,同时会分发相应的指令(KEY_ARROW_LEFT_COMMAND、KEY_ARROW_RIGHT_COMMAND、KEY_ARROW_UP_COMMAND、KEY_ARROW_DOWN_COMMAND 这四种指令之一)
所以即使不为以上四种指令设置处理函数,编辑器也可以正常响应用户按下方向键的操作
而在该模块中为这 4 种(内置)指令都设置了处理函数,以便处理一些特定的场景
// 向上
// 预期行为是光标跳转到上一个 Element 元素节点中(即在该节点中创建一个范围选区)
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_UP_COMMAND,
// 入参是按键事件 KeyboardEvent
(event) => {
// 通过 $getSelection() 方法获取当前的选区
const selection = $getSelection();
// 基于选区的类型采取不通过的处理
if (
$isNodeSelection(selection) &&
!$isTargetWithinDecorator(event.target as HTMLElement)
) {
/**
* 富文本编辑器中可能存在 DecoratorNode 装饰节点
* 因为 DecoratorNode 装饰节点一般是组件,其行为逻辑和编辑器的逻辑可能不同
* 如果该按键事件是在 DecoratorNode 装饰节点触发的
* 则一般不在编辑器中进行响应处理,而是采用组件的内部逻辑
*/
// 如果选区是节点选区(例如选中了一个图片节点)
// 而且不是在 DecoratorNode 装饰节点中触发按键事件
// 则预期行为是「跳出」节点选区,「返回」到邻近的(前一个)范围选区中
// If selection is on a node, let's try and move selection
// back to being a range selection.
const nodes = selection.getNodes(); // 获取选区所包含的节点
if (nodes.length > 0) {
// 获取第一个节点
// 并调用其祖先类 LexicalNode 的方法 `selectPrevious()` 选中该节点之前的内容
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalNode.ts#L804-L818
// 根据该节点的不同情况,返回一个恰当的 RangeSelection 范围选区
nodes[0].selectPrevious(); // 返回一个 RangeSelection 范围选区
return true;
}
} else if ($isRangeSelection(selection)) {
// 如果选区是范围选区
// 调用方法 `$getAdjacentNode()` 获取选区锚点的邻近节点
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalUtils.ts#L1123-L1151
const possibleNode = $getAdjacentNode(selection.focus, true);
// 再基于邻近节点的类型,采取不同的处理方式
if (
$isDecoratorNode(possibleNode) &&
!possibleNode.isIsolated() &&
!possibleNode.isInline()
) {
// 如果它是 DecoratorNode 装饰节点
// 则选择它之前的内容
possibleNode.selectPrevious();
event.preventDefault();
return true;
} else if (
$isElementNode(possibleNode) &&
!possibleNode.isInline() &&
!possibleNode.canBeEmpty()
) {
// 如果它是 ElementNode 节点,且不是 inline 行内节点,而且内容不为空
// 则在其中创建一个范围选区
possibleNode.select();
event.preventDefault();
return true;
}
}
// 如果以上特殊情况都不符合,则最后返回 false
// 以便调用其他处理函数(或触发默认的处理行为)
return false;
},
COMMAND_PRIORITY_EDITOR, // 相当于 0
)
// 向下
// 其预期行为和向上键相反
// 即光标跳转到下一个 Element 元素节点中(即在该节点中创建一个范围选区)
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_DOWN_COMMAND,
(event) => {
const selection = $getSelection();
if ($isNodeSelection(selection)) {
// If selection is on a node, let's try and move selection
// back to being a range selection.
const nodes = selection.getNodes();
if (nodes.length > 0) {
nodes[0].selectNext(0, 0);
return true;
}
} else if ($isRangeSelection(selection)) {
if ($isSelectionAtEndOfRoot(selection)) {
event.preventDefault();
return true;
}
const possibleNode = $getAdjacentNode(selection.focus, false);
if (
$isDecoratorNode(possibleNode) &&
!possibleNode.isIsolated() &&
!possibleNode.isInline()
) {
possibleNode.selectNext();
event.preventDefault();
return true;
}
}
// 如果以上特殊情况都不符合,则最后返回 false
// 以便调用其他处理函数(或触发默认的处理行为)
return false;
},
COMMAND_PRIORITY_EDITOR,
)
// 向左
// 其预期行为与向上键类似
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_LEFT_COMMAND,
(event) => {
const selection = $getSelection();
// 基于选区的类型采取不通过的处理
if ($isNodeSelection(selection)) {
// If selection is on a node, let's try and move selection
// back to being a range selection.
const nodes = selection.getNodes();
if (nodes.length > 0) {
event.preventDefault();
nodes[0].selectPrevious();
return true;
}
}
if (!$isRangeSelection(selection)) {
return false;
}
if ($shouldOverrideDefaultCharacterSelection(selection, true)) {
// 如果同时按下 `Shift` 键
const isHoldingShift = event.shiftKey;
event.preventDefault();
// 则调用方法 `$moveCharacter()` 将范围选区「外扩」一个字符
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-selection/src/range-selection.ts#L384-L396
$moveCharacter(selection, isHoldingShift, true);
return true;
}
// 如果以上特殊情况都不符合,则最后返回 false
// 以便调用其他处理函数(或触发默认的处理行为)
return false;
},
COMMAND_PRIORITY_EDITOR,
)
// 向右
// 其预期行为和向左键相反
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_RIGHT_COMMAND,
(event) => {
const selection = $getSelection();
if (
$isNodeSelection(selection) &&
!$isTargetWithinDecorator(event.target as HTMLElement)
) {
// If selection is on a node, let's try and move selection
// back to being a range selection.
const nodes = selection.getNodes();
if (nodes.length > 0) {
event.preventDefault();
nodes[0].selectNext(0, 0);
return true;
}
}
if (!$isRangeSelection(selection)) {
return false;
}
const isHoldingShift = event.shiftKey;
if ($shouldOverrideDefaultCharacterSelection(selection, false)) {
event.preventDefault();
$moveCharacter(selection, isHoldingShift, false);
return true;
}
// 如果以上特殊情况都不符合,则最后返回 false
// 以便调用其他处理函数(或触发默认的处理行为)
return false;
},
COMMAND_PRIORITY_EDITOR,
)
以上代码中所使用到的方法 $isSelectionAtEndOfRoot() 也是在该模块中定义的,其源码如下
// 判断范围选区/光标是否位于编辑器的末尾
function $isSelectionAtEndOfRoot(selection: RangeSelection) {
const focus = selection.focus;
return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize();
}
拖拽
当用户执行拖拽操作时,Lexical 会在相应的阶段自动分发一些相应的指令:
- 在拖拽开始时,会分发
DRAGSTART_COMMAND指令 - 在拖拽过程中,不断分发
DRAGOVER_COMMAND指令 - 在释放时,会分发
DROP_COMMAND指令
提示
Lexical 默认支持以拖拽的方式向编辑器内插入内容,并支持将选区的内容拖拽到其他地方,如果是在编辑器中进行拖拽移动,则以剪切移动的方式;如果是拖拽到其他地方,则以复制移动的方式(这和 contenteditable 的 DOM 元素的默认方式相同)
该模块为 DRAGSTART_COMMAND、DRAGOVER_COMMAND 和 DROP_COMMAND(内置)指令都设置了处理函数
// 拖拽开始
// 主要作用是阻止以拖拽的方式将文本内容移除
editor.registerCommand<DragEvent>(
DRAGSTART_COMMAND,
(event) => {
// 先判断从编辑器开始拖拽时选区的类型,以及拖拽的内容是否包含文件
// 调用 eventFiles() 方法,该方法返回一个数组,这里解构获取它的第一个元素
// 该方法也是在该模块中定义的,具体解读可以查看本文后面的内容
// 返回值(数组)的第一个元素,用于判断事件 event 所包含的数据中是否具有文件
const [isFileTransfer] = eventFiles(event);
const selection = $getSelection();
if (isFileTransfer && !$isRangeSelection(selection)) {
// 如果拖拽的内容含有/是文件,而且选区不是范围选区(可能是节点选区,例如拖拽一个图像节点)
// 则返回 false 即在该处理函数不处理,可以让调用其他的处理函数
return false;
}
// 如果拖拽的内容不含文件,或选区是范围选区
// 则采用默认行为
// 即将选区的内容以复制的方式移动到另外的地方
// ⚠️ 如果移动的目的地是页面的另一个 `contenteditable` 元素,则以剪切的方式移动内容
// 然后最后返回 true 即「屏蔽」掉该指令,其他的处理函数也不会作出响应
return true;
},
COMMAND_PRIORITY_EDITOR, // 相当于 0
)
// 拖拽期间
// 主要针对 hover 到装饰节点上时,对光标进行另类的处理
editor.registerCommand<DragEvent>(
DRAGOVER_COMMAND,
(event) => {
// 先判断编辑器此时的选区类型,以及拖拽过程中所拖拽的内容是否包含文件
const [isFileTransfer] = eventFiles(event);
const selection = $getSelection();
if (isFileTransfer && !$isRangeSelection(selection)) {
// 如果拖拽的内容含有/是文件
// 而且(在当前拖拽过程中生成的)选区不是范围选区(可能是节点选区,例如拖拽一个图像节点)
// 则返回 false 即该处理函数不执行余下的处理,但可以继续执行其他的处理函数
return false;
}
// 而不满足上述情况时,则采取以下的处理
// 获取当前鼠标指针(相对于编辑器在页面的 DOM 元素)的位置
const x = event.clientX; // 一个浮点数,表示横轴坐标
const y = event.clientY; // 纵轴坐标
// 调用 caretFromPoint() 方法
// 基于横纵轴坐标位置 (x, y) 获取相应的 DOM 元素,以及光标在该元素的偏移量
// 返回值是 null 或是一个对象 { offset, node }
// 参考 https://github.com/facebook/lexical/blob/main/packages/shared/src/caretFromPoint.ts
const eventRange = caretFromPoint(x, y);
if (eventRange !== null) {
// 调用方法 `$getNearestNodeFromDOMNode()` 基于 DOM 元素获取(最近的)节点
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalUtils.ts#L405-L418
const node = $getNearestNodeFromDOMNode(eventRange.node);
if ($isDecoratorNode(node)) {
// 如果该节点(拖拽时 hover 的节点)为 DecoratorNode 装饰节点
// 则阻止拖拽时的默认行为(将光标移动到相应位置)
// Show browser caret as the user is dragging the media across the screen.
// Won't work for DecoratorNode nor it's relevant.
event.preventDefault();
}
}
// 否则采用默认的行为
// 即光标随拖拽移动
return true;
},
COMMAND_PRIORITY_EDITOR, // 相对于 0
)
// 释放时
editor.registerCommand<DragEvent>(
DROP_COMMAND,
(event) => {
// 先调用方法 `eventFiles()` 解析在释放时所拖拽的内容中是否包含文件
// 通过对返回值(一个数组)的解构,来获取文件对象数据
const [, files] = eventFiles(event);
if (files.length > 0) {
// 如果拖拽的内容中包含文件(例如图片文件)
// 则进行如下处理,主要是创建一个范围选区,做好文件插入到编辑器的准备 ❓
const x = event.clientX;
const y = event.clientY;
const eventRange = caretFromPoint(x, y);
if (eventRange !== null) {
// 基于释放时的鼠标指针的位置(相对于编辑器)
// 获取相应的 DOM 元素 domNode,以及光标在该元素的偏移量 domOffset
const {offset: domOffset, node: domNode} = eventRange;
// 基于 DOM 元素获取(最近的)节点
const node = $getNearestNodeFromDOMNode(domNode);
if (node !== null) {
// 创建一个范围选区实例
const selection = $createRangeSelection();
// 基于释放鼠标时光标位置所对应的节点类型,设置范围选区(其锚点和焦点)
if ($isTextNode(node)) {
selection.anchor.set(node.getKey(), domOffset, 'text');
selection.focus.set(node.getKey(), domOffset, 'text');
} else {
const parentKey = node.getParentOrThrow().getKey();
const offset = node.getIndexWithinParent() + 1;
selection.anchor.set(parentKey, offset, 'element');
selection.focus.set(parentKey, offset, 'element');
}
// 再对该范围选区进行标准化/规范化 ❓
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalNormalization.ts#L89-L93
const normalizedSelection =
$normalizeSelection__EXPERIMENTAL(selection);
// 调用方法 `$setSelection()` 将该范围选区实例应用到编辑器上
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalUtils.ts#L467-L485
$setSelection(normalizedSelection);
}
// 然后「二级」分发 `DRAG_DROP_PASTE` 指令
// 该指令是在该模块中自定义的指令,并不是内置指令
// 并传递 files 文件对象数据作为参数
// 一般在该指令的处理函数中实现将文件插入到编辑器中的逻辑
editor.dispatchCommand(DRAG_DROP_PASTE, files);
}
event.preventDefault();
return true;
}
const selection = $getSelection();
if ($isRangeSelection(selection)) {
// 如果拖拽的内容不包含文件
// 且释放时编辑器的选区类型为范围选区
// 则采用默认行为(也是将文本内容插入到编辑器中)
return true; // 而且最后返回 true,表示该指令已经处理完成
}
// 否则就返回 false,即允许其他处理函数执行
return false;
},
COMMAND_PRIORITY_EDITOR,
)
在以上代码中使用到一个方法 eventFiles() 用于判断拖拽的内容是否为文件,其源码如下
// Clipboard may contain files that we aren't allowed to read.
// While the event is arguably useless, in certain ocassions,
// we want to know whether it was a file transfer, as opposed to text.
// We control this with the first boolean flag.
// 判断入参的事件 event 的数据中是否包含文件,以及是否有 'text/html' 或 'text/plain' 格式的内容
// 返回一个三元数组
// * 第一个元素是一个布尔值,表示 event 的数据中是否包含为文件
// * 第二个元素是一个数组,是 event 所包含的文件对象
// * 第三个元素是一个布尔值,表示 event 的数据中是否具有 'text/html' 或 'text/plain' 格式的内容
export function eventFiles(
event: DragEvent | PasteCommandType,
): [boolean, Array<File>, boolean] {
let dataTransfer: null | DataTransfer = null;
// 如果 event 是拖拽事件或剪切板事件
// 则获取其传输的数据 event.dataTransfer 或 event.clipboardData
if (event instanceof DragEvent) {
dataTransfer = event.dataTransfer;
} else if (event instanceof ClipboardEvent) {
dataTransfer = event.clipboardData;
}
// 判断该 DataTransfer 对象是否为空
if (dataTransfer === null) {
return [false, [], false];
}
const types = dataTransfer.types;
// 判断它是否具有文件
const hasFiles = types.includes('Files');
// 以及是否具有 'text/html' 或 'text/plain' 格式的内容
const hasContent =
types.includes('text/html') || types.includes('text/plain');
return [hasFiles, Array.from(dataTransfer.files), hasContent];
}
在以上代码中使用到的一个指令 DRAG_DROP_PASTE 是在该模块中所定义的。
分发该指令表示往编辑器里拖放/粘贴了文件对象
export const DRAG_DROP_PASTE: LexicalCommand<Array<File>> = createCommand(
'DRAG_DROP_PASTE_FILE',
);
复制粘贴与剪切
当用户执行复制、粘贴、剪切操作时,Lexical 会自动分发对应的指令 COPY_COMMAND、PASTE_COMMAND、CUT_COMMAND
注意
Lexical 默认只支持复制,对于粘贴和剪切的快捷键无反应,需要为指令设置相应的处理函数才行
以下是 COPY_COMMAND、PASTE_COMMAND 和 CUT_COMMAND(内置)指令的处理函数
// 复制
editor.registerCommand(
COPY_COMMAND,
(event) => {
// 调用 copyToClipboard() 方法
// 将选中的内容复制到剪切板上
// 该方法由 @lexical/clipboard 模块导出
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-clipboard/src/clipboard.ts#L495-L553
// 这是一个异步的方法
copyToClipboard(
editor,
// 方法 objectKlassEquals() 由 @lexical/utils 模块导出
// 验证当前的事件是否为 ClipboardEvent 剪切板事件
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-utils/src/index.ts#L523-L530
// 需要高于 v0.11.1 版本才具有该方法
objectKlassEquals(event, ClipboardEvent)
? (event as ClipboardEvent)
: null,
// 原版的方法是直接判断 event instanceof ClipboardEvent ? event : null,
);
return true;
},
COMMAND_PRIORITY_EDITOR,
)
// 剪切
editor.registerCommand(
CUT_COMMAND,
(event) => {
// 调用 onCutForRichText() 方法
// 将选中的内容剪切到剪切板上
onCutForRichText(event, editor);
return true;
},
COMMAND_PRIORITY_EDITOR,
)
// 粘贴
editor.registerCommand(
PASTE_COMMAND,
(event) => {
// 粘贴和拖放类似
// 先判度粘贴内容是否含有文件,
const [, files, hasTextContent] = eventFiles(event);
if (files.length > 0 && !hasTextContent) {
// 如果有文件对象
// 则「二级」分发 `DRAG_DROP_PASTE` 指令
// 并传递 files 文件对象数据作为参数
// 然后可以在该指令的处理函数中实现将文件插入到编辑器的逻辑
editor.dispatchCommand(DRAG_DROP_PASTE, files);
return true;
}
// 如果粘贴操作发生在装饰节点中,则忽略该操作 ❓
// if inputs then paste within the input ignore creating a new node on paste event
if (isSelectionCapturedInDecoratorInput(event.target as Node)) {
return false;
}
const selection = $getSelection();
if (
$isRangeSelection(selection) ||
DEPRECATED_$isGridSelection(selection)
) {
// 如果没有文件(只是文本)
// 则调用 onPasteForRichText() 方法
// 将内容粘贴到编辑器中
onPasteForRichText(event, editor);
return true;
}
return false;
},
COMMAND_PRIORITY_EDITOR,
)
以上指令处理函数中的核心逻辑分别抽离到相应的函数中
// 剪切
// 这是一个异步的函数
// 与复制类似
async function onCutForRichText(
event: CommandPayloadType<typeof CUT_COMMAND>,
editor: LexicalEditor,
): Promise<void> {
// 也是使用 copyToClipboard() 方法将选区内容写入到剪切板
await copyToClipboard(
editor,
objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null,
);
// 区别在于除了要把选区的内容写入剪切板,还需要把选区的内容从编辑器中删除
editor.update(() => {
const selection = $getSelection();
// 根据选区中的节点类型执行相应的操作(移除节点)
if ($isRangeSelection(selection)) {
selection.removeText();
} else if ($isNodeSelection(selection)) {
selection.getNodes().forEach((node) => node.remove());
}
});
}
// 粘贴
function onPasteForRichText(
event: CommandPayloadType<typeof PASTE_COMMAND>,
editor: LexicalEditor,
): void {
event.preventDefault();
editor.update(
() => {
const selection = $getSelection();
const clipboardData =
event instanceof InputEvent || event instanceof KeyboardEvent
? null
: event.clipboardData;
if (
clipboardData != null &&
($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection))
) {
// 调用 $insertDataTransferForRichText() 方法
// 将剪切板的数据插入到编辑器中
// 该方法由 @lexical/clipboard 模块导出
// 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-clipboard/src/clipboard.ts#L94-L114
$insertDataTransferForRichText(clipboardData, selection, editor);
}
},
{
tag: 'paste',
},
);
}
其他
当用户按下 Esc 键时,Lexical 会分发 KEY_ESCAPE_COMMAND 指令
该模块还对 KEY_ESCAPE_COMMAND(内置)指令设置了处理函数,以让编辑器失去焦点
editor.registerCommand(
KEY_ESCAPE_COMMAND,
() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
// 调用方法 editor.blur() 让编辑器失去焦点,不在响应用户的输入
editor.blur();
return true;
},
COMMAND_PRIORITY_EDITOR,
)