构建纯文本编辑器

lexical
Created 2/11/2023
Updated 6/19/2023

构建纯文本编辑器

Lexical 推出了一个模块包 @lexical/plain-text 可用于快速构建一个纯文本编辑器,通过解读其源码来学习如何使用 Lexical 的核心库和其他模块。

参考

初始化

首先创建一个编辑器实例,并将它绑定到页面上的 contenteditable 的 DOM 元素上

说明

一般项目的使用场景只需要一个编辑器实例,所以在本文中只以创建一个 editor 作为示例

ts
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,可以十分方便地实现一个简单的纯文本编辑器,例如具有键入功能、删除功能、复制粘贴功能、通过方向键改变选区的功能等。

注意

需要留意 @lexical/plain-text 模块的 peerDependencies,即需要在项目中已安装有相应的包,才可以正常使用该模块所提供的功能

json
{
  //...
  "peerDependencies": {
    "lexical": "0.8.0",
    "@lexical/utils": "0.8.0",
    "@lexical/selection": "0.8.0",
    "@lexical/clipboard": "0.8.0"
  },
  // ...
}

该模块主要导出一个方法 registerPlainText(editor) 可以为编辑器 editor 一次性添加上多种功能

提示

该模块调用了一系列以 register 开头的方法,为不同的指令注册处理函数

而且将这些为指令注册处理函数的方法作为参数,传递给 mergeRegister(...func) 方法(该方法来自 @lexical/utils 模块)实现封装,该方法返回值是一个函数,调用它可以一次性取消所有的注册

删除内容

该模块中实现删除功能的主要方式是借助 KEY_BACKSPACE_COMMANDKEY_DELETE_COMMAND 这两个内置指令。

当用户按下删除键 Backspace 时,Lexical 会自动分发 KEY_BACKSPACE_COMMAND 这个内置指令,(已在编辑器上注册的)该指令的处理函数会被调用,在其中实现删除内容的相关逻辑。

类似地,当用户按下删除键 Delete 时,Lexical 会自动分发 KEY_DELETE_COMMAND 这个内置指令

说明

关于 Lexical 会根据哪些特定的按键(或按键组合)分发哪一种内置的指令,具体可以查看 LexicalEvents.ts 这个文件

以下是 KEY_BACKSPACE_COMMAND(内置)指令的处理函数

ts
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(内置)指令的处理函数类似

ts
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(内置)指令的处理函数则是

ts
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(内置)指令的处理函数

ts
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(内置)指令的处理函数

ts
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(内置)指令的处理函数

ts
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_COMMANDINSERT_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(内置)指令的处理函数

ts
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_COMMANDKEY_ARROW_RIGHT_COMMANDKEY_ARROW_UP_COMMANDKEY_ARROW_DOWN_COMMAND 这四种指令之一)

所以即使不为以上四种指令设置处理函数,编辑器也可以正常响应用户按下方向键的操作

而在该模块中,分别为 KEY_ARROW_LEFT_COMMANDKEY_ARROW_RIGHT_COMMAND 设置了处理函数,以便处理一些特定的场景

以下是 KEY_ARROW_LEFT_COMMANDKEY_ARROW_RIGHT_COMMAND(内置)指令的处理函数

ts
// 响应向左的方向键
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_COMMANDPASTE_COMMANDCUT_COMMAND

注意

Lexical 默认只支持复制,对于粘贴和剪切的快捷键无反应,需要为指令设置相应的处理函数才行

以下是 COPY_COMMANDPASTE_COMMANDCUT_COMMAND(内置)指令的处理函数

ts
// 复制
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,
)

以上指令处理函数中的核心逻辑分别抽离到相应的函数中

ts
// 将范围选区的内容复制到剪切板中
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_COMMANDDROP_COMMAND(内置)指令设置了处理函数

ts
// 拖拽开始
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`)才可以通过拖拽将内容插入到编辑器中

这两个处理函数主要是阻止了拖拽的默认行为,不过它们的优先级都是最低的,所以在使用该模块包时,如果需要可以自定义优先级更高的指令处理函数,实现拖拽相关的功能


Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes