构建纯文本编辑器
Lexical 推出了一个模块包 @lexical/plain-text 可用于快速构建一个纯文本编辑器,通过解读其源码来学习如何使用 Lexical 的核心库和其他模块。
参考
初始化
首先创建一个编辑器实例,并将它绑定到页面上的 contenteditable 的 DOM 元素上
说明
一般项目的使用场景只需要一个编辑器实例,所以在本文中只以创建一个 editor 作为示例
import { createEditor } from 'lexical';
// 编辑器的配置项
const config = {
namespace: 'MyEditor', // ❓ 命名空间/作用域
// 设置主题
// 主要是为不同类型的节点设置不同的 CSS 类名
theme: {
// ...
},
// 错误处理函数
onError: console.error
};
// 通过方法 createEditor() 创建一个编辑器实例
const editor = createEditor(config);
// 获取页面上的 DOM 元素,作为编辑器的容器 root element
const contentEditableElement = document.getElementById('editor');
// 通过编辑器的 setRootElement() 方法设置根节点
// 将编辑器实例与页面上的 `contenteditable` 的 DOM 元素绑定
editor.setRootElement(contentEditableElement);
提示
以上创建了一个内容为空的编辑器,如果需要导入/载入之前保存的内容,可以参考另一篇笔记 Editor State 的相关部分
使用 lexical 核心库创建的编辑器其实无法直接使用,因为它没有处理输入、删除、复制粘贴等开箱即用的方法,只提供了一些必要的 utilities 基础属性和方法,还需要进一步开发才能够构建出一个基本可用的纯文本编辑器。
Lexical 官方提供了 @lexical/plain-text 模块,该模块创建了一系列的指令 command 和侦听器 listener,可以十分方便地实现一个简单的纯文本编辑器,例如具有键入功能、删除功能、复制粘贴功能、通过方向键改变选区的功能等。
注意
该模块主要导出一个方法 registerPlainText(editor) 可以为编辑器 editor 一次性添加上多种功能
删除内容
该模块中实现删除功能的主要方式是借助 KEY_BACKSPACE_COMMAND 和 KEY_DELETE_COMMAND 这两个内置指令。
当用户按下删除键 Backspace 键 时,Lexical 会自动分发 KEY_BACKSPACE_COMMAND 这个内置指令,(已在编辑器上注册的)该指令的处理函数会被调用,在其中实现删除内容的相关逻辑。
类似地,当用户按下删除键 Delete 键 时,Lexical 会自动分发 KEY_DELETE_COMMAND 这个内置指令
说明
关于 Lexical 会根据哪些特定的按键(或按键组合)分发哪一种内置的指令,具体可以查看 LexicalEvents.ts 这个文件
以下是 KEY_BACKSPACE_COMMAND(内置)指令的处理函数
editor.registerCommand<KeyboardEvent>(
KEY_BACKSPACE_COMMAND,
(event) => {
// 通过 $getSelection() 方法获取当前的选区
const selection = $getSelection();
// 通过 $isRangeSelection() 方法判断该选区是否为范围选区
if (!$isRangeSelection(selection)) {
// 如果该选区不是范围选区,则不执行该函数的后续部分
// 并返回 false,即该指令未处理完毕
// 则该指令可以继续触发其他(同级别或优先级更低的)的处理函数
// 但是在该模块中没有针对指令的其他的处理函数了
// 使用该模块的开发者可以进一步开发
// 当用户需要删除不是范围选区的场景下,编辑器应该如何处理
return false;
}
// 阻止按键事件的默认行为
event.preventDefault();
// 分发 DELETE_CHARACTER_COMMAND 指令
// 并传递 true 作为其处理函数的参数
// 根据 DELETE_CHARACTER_COMMAND 指令的处理函数,其荷载 payload(第二个参数)是一个布尔值
// 表示删除的方向 isBackward 是否需要光标回退,以删除光标前面的内容
// 因为这里是按下 `Backspace` 键而触发的指令
// 所以预期的删除行为应该是回退删除,因此传递 true
// 相应地,如果是按下 `Delete` 键而触发的指令,则应该传递 false
return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
// 这里返回一个分发指令的函数,该函数最后返回的是一个布尔值(一般是 true)
},
// 设置该指令处理函数的优先级
// 该内置变量相当于 0,优先级是最低的
COMMAND_PRIORITY_EDITOR
)
以下则是 KEY_DELETE_COMMAND(内置)指令的处理函数,和 KEY_BACKSPACE_COMMAND(内置)指令的处理函数类似
editor.registerCommand<KeyboardEvent>(
KEY_DELETE_COMMAND,
(event) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
event.preventDefault();
// 「二级」分发 `DELETE_CHARACTER_COMMAND` 指令
// 而且传递的参数是 false
// 表示删除方向是后面的内容
return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false);
},
COMMAND_PRIORITY_EDITOR,
)
说明
以下是 DELETE_CHARACTER_COMMAND(内置)指令的处理函数则是
editor.registerCommand<boolean>(
DELETE_CHARACTER_COMMAND,
(isBackward) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
// 调用选区的方法 deleteCharacter() 删除内容
// 入参是一个布尔值,表示选区/光标是否需要回退
// 即删除前面的内容
selection.deleteCharacter(isBackward);
return true; // 返回 true 表示该指令已处理完毕
},
COMMAND_PRIORITY_EDITOR, // 相当于 0
)
最后其实是通过调用 selection.deleteCharacter(isBackward) 方法执行了内容删除。
选区对象 selection 还有另外两种删除方法:
selection.deleteWord(isBackward)删除一个单词selection.deleteLine(isBackward)删除一行 ❓
另外如果用户按下 Backspace 键 或 Delete 键 的同时按下 Ctrl 键(对于 MacOS 用户则是同时按下 Alt 键),则会分发 DELETE_WORD_COMMAND 指令,预期的行为就会不同,(不是删除一个字符)而是删除一个单词,所以分发的指令也不同,(不是 DELETE_CHARACTER_COMMAND)而是 DELETE_WORD_COMMAND
以下是 DELETE_WORD_COMMAND(内置)指令的处理函数
editor.registerCommand<boolean>(
DELETE_WORD_COMMAND,
// 处理函数的入参 isBackward 是一个布尔值
// 会根据按下删除键类型而定
// 如果按下的是 `Backspace` 键,则 Lexical 分发指令时传递的参数是 true(表示需要以光标回退的方式删除光标前面的内容)
// 如果按下的是 `Delete` 键,则 Lexical 分发指令时传递的参数是 false
(isBackward) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
selection.deleteWord(isBackward);
return true;
},
COMMAND_PRIORITY_EDITOR,
)
提示
该模块还对 DELETE_LINE_COMMAND 指令设置了处理函数。
这只在 MacOS 系统中才起作用,在用户按下 Backspace 键 或 Delete 键 的同时,按下 Meta 徽标键,就会分发 DELETE_LINE_COMMAND,预期行为是删除一行内容。
插入内容
该模块实现输入功能是通过响应 CONTROLLED_TEXT_INSERTION_COMMAND 这个内置指令来实现的。
当用户向编辑器插入内容时,Lexical 会自动分发 CONTROLLED_TEXT_INSERTION_COMMAND 这个内置指令,并将 InputEvent 输入事件作为参数(如果是通过键盘输入文字,则以输入事件的数据 event.data 即文本字符作为参数)
以下是 CONTROLLED_TEXT_INSERTION_COMMAND(内置)指令的处理函数
editor.registerCommand<InputEvent | string>(
CONTROLLED_TEXT_INSERTION_COMMAND,
// 入参 eventOrText 是输入事件 InputEvent 或输入的文本 string
(eventOrText) => {
// 通过 $getSelection() 方法获取当前的选区
const selection = $getSelection();
// 通过 $isRangeSelection() 方法判断该选区是否为范围选区
if (!$isRangeSelection(selection)) {
return false;
}
if (typeof eventOrText === 'string') {
// 如果传入的参数是字符(串)
// 则调用选区的方法 insertText(text) 插入内容
selection.insertText(eventOrText);
} else {
// 如果传入的参数不是字符(串)
// 则考虑它是否存在拖拽对象(即以拖拽方式插入内容)
const dataTransfer = eventOrText.dataTransfer;
if (dataTransfer != null) {
// 先判断拖拽对象 dataTransfer 是否为空
// 如果不为空,就调用方法 $insertDataTransferForPlainText()
// 该方法会以纯文本 'text/plain' 的方式来获取/解析 dataTransfer 拖拽对象中的数据
// 再通过选区对象的方法 selection.insertRawText(text) 将纯文本内容替换范围选区的内容
// 该方法来自 @lexical/clipboard 模块包
// 具体参考 https://github.com/facebook/lexical/blob/main/packages/lexical-clipboard/src/clipboard.ts#L84-L93
$insertDataTransferForPlainText(dataTransfer, selection);
} else {
// 如果不是拖拽,而是单纯的 InputEvent
// 则获取它的数据
const data = eventOrText.data;
if (data) {
// 并调用相应的方法插入内容
selection.insertText(data);
}
}
}
return true; // 最后返回 true 表示该指令已经处理完毕
},
COMMAND_PRIORITY_EDITOR, // 相当于 0
)
提示
该模块还对 REMOVE_TEXT_COMMAND 指令设置了处理函数,从编辑器中移除相应的内容。
这是针对 IME 将输入按键转换为其他语言的字元的输入法或以拖拽方式移除内容(但是从以上的代码可以看出,该模块并没有支持 ❓)的场景。
换行
如果用户在编辑器中输入的不是文本内容,而执行换行操作(一般是按下 Enter 键),则 Lexical 会自动分发 INSERT_LINE_BREAK_COMMAND 指令(而不是 CONTROLLED_TEXT_INSERTION_COMMAND 指令)
以下是 INSERT_LINE_BREAK_COMMAND(内置)指令的处理函数
editor.registerCommand<boolean>(
INSERT_LINE_BREAK_COMMAND,
// 入参是一个布尔值,用于控制插入换行后光标的位置
(selectStart) => {
// 通过 $getSelection() 方法获取当前的选区
const selection = $getSelection();
// 通过 $isRangeSelection() 方法判断该选区是否为范围选区
if (!$isRangeSelection(selection)) {
return false;
}
// 调用选区的方法 insertLineBreak(Boolean) 插入换行节点
// 入参是一个布尔值
// 以控制换行后,光标是否位于选区的开头(一般都是 false,即光标会移到下一行)
selection.insertLineBreak(selectStart);
return true; // 最后返回 true 表示该指令已经处理完毕
},
COMMAND_PRIORITY_EDITOR, // 相当于 0
)
提示
要实现换行,除了插入换行节点(一般用于纯文本编辑器 ❓),还可以插入一个新的 ElementNode,例如 ParagraphNode(一般用于富文本编辑器 ❓),针对不同需求采用不同的方式。
针对这两种需求,Lexical 提供了两种指令 INSERT_LINE_BREAK_COMMAND 和 INSERT_PARAGRAPH_COMMAND。
(在富文本编辑器中 ❓)当用户按下一次回车键时,其输入的数据/内容是 \n,编辑器会分发 INSERT_LINE_BREAK_COMMAND;而按下两次回车键时,其输入的数据/内容是 \n\n,编辑器会分发 INSERT_PARAGRAPH_COMMAND
而在该模块(纯文本编辑器)理这两种指令时,都统一采用插入换行符的方式。
当用户按下 Enter 键 时会自动分发 KEY_ENTER_COMMAND 指令,因为在编辑器按下 Enter 键 的预期行为一般都是换行,所以该模块在 KEY_ENTER_COMMAND 指令的处理函数中「二级」分发了 INSERT_LINE_BREAK_COMMAND 指令
以下是 KEY_ENTER_COMMAND(内置)指令的处理函数
editor.registerCommand<KeyboardEvent | null>(
KEY_ENTER_COMMAND,
(event) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
// 针对 iOS 系统或 Safari 浏览器的特殊处理
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) && CAN_USE_BEFORE_INPUT) {
return false;
}
event.preventDefault();
}
// 「二级」分发 `INSERT_LINE_BREAK_COMMAND` 预期行为是换行
// 且换行后光标位于下一行
return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
},
COMMAND_PRIORITY_EDITOR,
)
方向键
对于方向键的响应,Lexical 编辑器默认使用 contenteditable DOM 元素对于方向键的(原生)处理方式,同时会分发相应的指令(KEY_ARROW_LEFT_COMMAND、KEY_ARROW_RIGHT_COMMAND、KEY_ARROW_UP_COMMAND、KEY_ARROW_DOWN_COMMAND 这四种指令之一)
所以即使不为以上四种指令设置处理函数,编辑器也可以正常响应用户按下方向键的操作
而在该模块中,分别为 KEY_ARROW_LEFT_COMMAND 和 KEY_ARROW_RIGHT_COMMAND 设置了处理函数,以便处理一些特定的场景
以下是 KEY_ARROW_LEFT_COMMAND 和 KEY_ARROW_RIGHT_COMMAND(内置)指令的处理函数
// 响应向左的方向键
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_LEFT_COMMAND,
// 入参是按键事件 KeyboardEvent
(payload) => {
// 通过 $getSelection() 方法获取当前的选区
const selection = $getSelection();
// 通过 $isRangeSelection() 方法判断该选区是否为范围选区
if (!$isRangeSelection(selection)) {
return false;
}
const event = payload;
// 是否同时按下了 Shift 键
const isHoldingShift = event.shiftKey;
// 根据条件判断是否需要覆盖默认的方向键操作行为
// 例如节点是不可选的情况
if ($shouldOverrideDefaultCharacterSelection(selection, true)) {
event.preventDefault(); // 阻止按键事件的默认行为
// 调用 $moveCharacter() 方法移动光标 ❓
// 最后一个入参是布尔值,表示光标移动的方向是否需要回退(向前)
// 该方法由 @lexical/selection 模块包导出
// 具体参考 https://github.com/facebook/lexical/blob/main/packages/lexical-selection/src/range-selection.ts#L366-L373
$moveCharacter(selection, isHoldingShift, true);
return true; // 返回 true 表示该指令已经处理完毕
}
// 如果方向键采用默认的 DOM 行为
// 则返回 false
// 以便其他的指令处理函数对该指令进一步处理
return false;
},
COMMAND_PRIORITY_EDITOR, // 相当于 0
)
// 响应向右的方向键
editor.registerCommand<KeyboardEvent>(
KEY_ARROW_RIGHT_COMMAND,
(payload) => {
// 通过 $getSelection() 方法获取当前的选区
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
const event = payload;
const isHoldingShift = event.shiftKey;
if ($shouldOverrideDefaultCharacterSelection(selection, false)) {
event.preventDefault();
$moveCharacter(selection, isHoldingShift, false);
return true;
}
return false;
},
COMMAND_PRIORITY_EDITOR,
)
复制粘贴与剪切
当用户执行复制、粘贴、剪切操作时,Lexical 会自动分发对应的指令 COPY_COMMAND、PASTE_COMMAND、CUT_COMMAND
注意
Lexical 默认只支持复制,对于粘贴和剪切的快捷键无反应,需要为指令设置相应的处理函数才行
以下是 COPY_COMMAND、PASTE_COMMAND 和 CUT_COMMAND(内置)指令的处理函数
// 复制
editor.registerCommand(
COPY_COMMAND,
// 入参是剪切板事件 ClipboardEvent
(event) => {
const selection = $getSelection();
// 通过 $isRangeSelection() 方法判断该选区是否为范围选区
if (!$isRangeSelection(selection)) {
return false;
}
// 调用相应的方法将范围选取中的内容复制到剪切板上
onCopyForPlainText(event, editor);
return true; // 返回 true 表示该指令已经处理完毕
},
COMMAND_PRIORITY_EDITOR, // 相当于 0
)
// 剪切
editor.registerCommand(
CUT_COMMAND,
(event) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
onCutForPlainText(event, editor);
return true;
},
COMMAND_PRIORITY_EDITOR,
)
// 粘贴
editor.registerCommand(
PASTE_COMMAND,
(event) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
onPasteForPlainText(event, editor);
return true;
},
COMMAND_PRIORITY_EDITOR,
)
以上指令处理函数中的核心逻辑分别抽离到相应的函数中
// 将范围选区的内容复制到剪切板中
function onCopyForPlainText(
event: CommandPayloadType<typeof COPY_COMMAND>,
editor: LexicalEditor,
): void {
// 不能直接在指令处理的函数中使用 $ 为前缀的函数
// 需要在 editor.update(callback) 回调函数中执行
editor.update(() => {
const clipboardData = event instanceof KeyboardEvent ? null : event.clipboardData;
const selection = $getSelection();
if (selection !== null && clipboardData != null) {
event.preventDefault();
// ❓❓❓ 将范围选区的节点转换为 HTML 格式
const htmlString = $getHtmlContent(editor);
if (htmlString !== null) {
// 设置为剪切板的内容(
// 一种 fallback 「回退」保底的处理方式 ❓ 以防选区内容无法直接转变成纯文本 ❓
clipboardData.setData('text/html', htmlString);
}
// 再将范围选区的文本内容设置为剪切板的内容
clipboardData.setData('text/plain', selection.getTextContent());
}
});
}
// 将剪切板的内容粘贴到编辑器中
function onPasteForPlainText(
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)) {
$insertDataTransferForPlainText(clipboardData, selection);
}
},
{
tag: 'paste',
},
);
}
// 将范围选区的内容剪切到剪切板中
function onCutForPlainText(
event: CommandPayloadType<typeof CUT_COMMAND>,
editor: LexicalEditor,
): void {
// 先进行复制
onCopyForPlainText(event, editor);
editor.update(() => {
const selection = $getSelection();
// 再进行删除
if ($isRangeSelection(selection)) {
selection.removeText();
}
});
}
拖拽
当用户执行拖拽操作时,Lexical 会在相应的阶段自动分发一些相应的指令:
- 在拖拽开始时,会分发
DRAGSTART_COMMAND指令 - 在拖拽过程中,不断分发
DRAGOVER_COMMAND指令 - 在释放时,会分发
DROP_COMMAND指令
提示
Lexical 默认支持以拖拽的方式向编辑器内插入内容,并支持将选区的内容拖拽到其他地方,但是以复制粘贴,而不是以剪切方式(这和 contenteditable 的 DOM 元素的默认方式不同,可编辑元素的默认方式是剪切移动)
该模块为 DRAGSTART_COMMAND 和 DROP_COMMAND(内置)指令设置了处理函数
// 拖拽开始
editor.registerCommand<DragEvent>(
DRAGSTART_COMMAND,
(event) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
// 拖拽开始时,如果编辑器当前选区的类型不是范围选区
// 例如拖拽的是一个图片节点,则对应选区类型是节点选区
// 则返回 false,即不执行该处理方法的余下部分,但是允许其他处理函数执行
// 此时不阻止默认行为,即可以对该节点所对应的 DOM 元素实现拖拽移动操作
return false;
}
// 如果拖拽开始时,选区类型是范围选区,则阻止该事件的默认行为
// 所以并不支持以拖拽的方式(复制或剪切)移动文本内容
// TODO: Make drag and drop work at some point.
event.preventDefault();
return true;
},
COMMAND_PRIORITY_EDITOR,
)
// 释放时
editor.registerCommand<DragEvent>(
DROP_COMMAND,
(event) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
// 释放时,如果编辑器原来的选区类型不是范围选区
// 例如编辑器所对应的 DOM 元素本来没有获取到焦点,则选区就是 null
// 则返回 false,即不执行该处理方法的余下部分,但是允许其他处理函数执行
// 此时不阻止默认行为,即可以将拖拽的内容插入到编辑器中
return false;
}
// 释放时,如果编辑器原来的选区类型就是范围选区,则阻止该事件的默认行为
// 所以不能将拖拽内容插入到编辑器中
// TODO: Make drag and drop work at some point.
event.preventDefault();
return true;
},
COMMAND_PRIORITY_EDITOR,
)
// 😮😬😨 最后的处理函数的逻辑将会导致一个奇怪的现象,即有时候可以将文本拖拽插入到编辑器中,有时候又不可行。
// 这是因为当编辑器在该页面是获得焦点的,则此时选区依然是 `RangeSelection`,但是如果此时切到其他页面(即编辑器所在的页面失去聚焦时),那么编辑器的选区并没有变化,所以此时将其他来源的文本直接拖放到编辑器时,编辑器并不会接受
// ⚠️ 只有先取消编辑器的焦点(即先让编辑器的选区为 `null`)才可以通过拖拽将内容插入到编辑器中
这两个处理函数主要是阻止了拖拽的默认行为,不过它们的优先级都是最低的,所以在使用该模块包时,如果需要可以自定义优先级更高的指令处理函数,实现拖拽相关的功能