构建富文本编辑器

lexical
Created 2/11/2023
Updated 7/5/2023

构建富文本编辑器

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

参考

初始化

和《构建纯文本编辑器》的初始化步骤一致


与纯文本编辑器相比,富文本编辑器需要创建一系列的自定义节点,例如 HeadingNodeParagraphNodeQuoteNode

所以在使用 createEditor(config) 创建编辑器实例时,需要在配置对象 confignodes 属性中列出所需要使用的节点类型

ts
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 一次性添加上多种功能

提示

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

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

引文节点

该模块基于 ElementNode 元素节点进行扩展,创建一个 QuoteNode 引文节点

ts
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() 如下

ts
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 代码,用于设置一些变量或参数的类型

ts
// 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 引文节点实例

ts
export function $isQuoteNode(
  node: LexicalNode | null | undefined,
): node is QuoteNode {
  return node instanceof QuoteNode;
}

标题节点

该模块基于 ElementNode 元素节点进行扩展,创建一个 HeadingNode 引文节点

ts
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() 如下

ts
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 代码,用于设置一些变量或参数的类型

ts
// 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 标题节点实例

ts
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 节点选区的这个默认行为

ts
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_COMMANDKEY_DELETE_COMMAND 这两个(内置)指令,以下分别是它们的处理函数

ts
/**
 * 注册 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,
)
提示

以上代码中使用了一些方法的源码如下

ts
// 用于判断该 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(内置)指令的处理函数则是

ts
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_COMMANDDELETE_LINE_COMMAND(内置)指令设置了处理函数,以实现删除一个单词或一行内容

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

ts
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 指令设置了处理函数,从编辑器中移除相应的内容。

ts
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_COMMANDINSERT_PARAGRAPH_COMMAND 指令

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

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

ts
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_COMMANDFORMAT_ELEMENT_COMMAND 这两种(内置)指令设置处理函数,以实现上述的两种类型的格式化

ts
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_COMMANDFORMAT_ELEMENT_COMMAND 指令(并传递相应的参数)

ts
// 例如通过点击按钮可以加粗文本内容
btn.addEventListener('click', () => {
  editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
})

缩进

缩进(或减少缩进)一般是按 Tab(减少缩进一般需要同时按下 Shift)来实现的。

当用户按下 Tab 时,编辑器会自动分发 KEY_TAB_COMMAND 指令,所以可以为该指令设置处理函数并在其中实现缩进相关的逻辑。

Lexical 内置提供了两个与缩进相关的指令 INDENT_CONTENT_COMMANDOUTDENT_CONTENT_COMMAND,该模块都分别为这两个(内置)指令设置了处理函数,实现缩进和减少缩进的相关逻辑

ts
// 缩进
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,该函数也是在该模块中定义的

ts
// 处理缩进和减少缩进的核心函数
// 它所接受的两个参数都是函数
// 分别对应于处理缩进的两种方法,根据节点的属性采用其中一种方式:
// * 直接插入制表符
// * 或对节点的属性 __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 作为缩进(或减少缩进)的快捷键

ts
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_COMMANDOUTDENT_CONTENT_COMMAND 指令处理函数,专门实现 Tab在表格中的行为逻辑。

具体实现可以参考这个插件(虽然它是为 lexical-react 设计的)。

方向键

对于方向键的响应,Lexical 编辑器默认使用 contenteditable DOM 元素对于方向键的(原生)处理方式,同时会分发相应的指令(KEY_ARROW_LEFT_COMMANDKEY_ARROW_RIGHT_COMMANDKEY_ARROW_UP_COMMANDKEY_ARROW_DOWN_COMMAND 这四种指令之一)

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

而在该模块中为这 4 种(内置)指令都设置了处理函数,以便处理一些特定的场景

ts
// 向上
// 预期行为是光标跳转到上一个 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() 也是在该模块中定义的,其源码如下

ts
// 判断范围选区/光标是否位于编辑器的末尾
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_COMMANDDRAGOVER_COMMANDDROP_COMMAND(内置)指令都设置了处理函数

ts
// 拖拽开始
// 主要作用是阻止以拖拽的方式将文本内容移除
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() 用于判断拖拽的内容是否为文件,其源码如下

ts
// 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 是在该模块中所定义的。

分发该指令表示往编辑器里拖放/粘贴了文件对象

ts
export const DRAG_DROP_PASTE: LexicalCommand<Array<File>> = createCommand(
  'DRAG_DROP_PASTE_FILE',
);

复制粘贴与剪切

当用户执行复制、粘贴、剪切操作时,Lexical 会自动分发对应的指令 COPY_COMMANDPASTE_COMMANDCUT_COMMAND

注意

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

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

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

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

ts
// 剪切
// 这是一个异步的函数
// 与复制类似
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(内置)指令设置了处理函数,以让编辑器失去焦点

ts
editor.registerCommand(
  KEY_ESCAPE_COMMAND,
  () => {
    const selection = $getSelection();
    if (!$isRangeSelection(selection)) {
      return false;
    }
    // 调用方法 editor.blur() 让编辑器失去焦点,不在响应用户的输入
    editor.blur();
    return true;
  },
  COMMAND_PRIORITY_EDITOR,
)

Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes