兼容 markdown

lexical
Created 7/6/2023
Updated 8/14/2023

兼容 markdown

Lexical 推出了一个模块包 @lexical/markdown 让富文本编辑器兼容 Markdown,可以支持导入、导出 markdown 文件,并让 markdown 常见的快捷键可以在富文本编辑器中得以通用

Lexical 在其官方 Playground 交互演示中就使用了该模块,可以尝试这个在线的富文本编辑器看看具体效果

提示

该模块需要使用了一些其他的 Lexical 模块包,它们实现了一些自定义的节点(这些节点对应于标准 Markdown 语法所支持的富文本类型)

  • @lexical/code 提供 CodeNode 代码节点
  • @lexical/rich-text 提供 HeadingNode 标题节点和 QuoteNode 引文节点
  • @lexical/list 提供 ListNode 列表节点和 ListItemNode 列表项节点
  • @lexical/link 提供 LinkNode 链接节点

节点转换器

该模块可以让富文本编辑器支持使用 Markdown 语法(但是实现方式和原生的 Markdown 编辑器并不同),具体而言是设置了一系列的节点转换器 transformer,这样当导入、导出、用户输入 Markdown 语法时编辑器就会自动转换为相应的节点

例如当用户输入 # 并按空格键时就会触发节点转换器,将这个 TextNode 文本节点转换为 HeadingNode 标题节点

提示

在进行节点转换时会同时删除相应的 Markdown 标记,例如上述例子中的 # 符号

这个行为和原生的 Markdown 编辑器不同,因为 Markdown 文件是一堆「扁平线性化」的纯文本字符串,需要保留这些标记,以便让 Markdown parser 解释器正确识别和渲染出相应的样式

而富文本编辑器就不需要保留这个标记,因为 Lexical 的数据格式是层级结构化的,每一个节点都是一个对象,已经有相应的属性来记录其样式

该模块在 MarkdownTransformers.ts 文件中提供的一些实现节点转换的相关规则

ts
/**
 * 3 种类型的节点转换器
 */
// 针对元素节点的节点转换器
UNORDERED_LIST
CODE
HEADING
ORDERED_LIST
QUOTE

// 针对文本节点的节点转换器
// 文本格式
BOLD_ITALIC_STAR
BOLD_ITALIC_UNDERSCORE
BOLD_STAR
BOLD_UNDERSCORE
INLINE_CODE
ITALIC_STAR
ITALIC_UNDERSCORE
STRIKETHROUGH

// 文本内容
LINK
注意

在编辑器的实例上注册节点转换器的顺序很重要,特别是依赖正则表达式匹配内容而实现的节点转换器

因为当节点发生变化后,所有节点转换规则 transforms 都会根据规则的注册次序有先后次序,依次检查这个 dirty node 是否符合转换规则

首先应该注册与代码 code 相关的节点转换规则,因为 <code> 里面的内容不需要再进行转换;然后注册处理较长标记的节点转换器,例如针对 ** 标记的节点转换规则应该先注册,然后再注册针对 * 标记的节点转换器

以下是针对文本格式的节点转换规则的注册顺序

lexical-markdown/index.ts
ts
const TEXT_FORMAT_TRANSFORMERS: Array<TextFormatTransformer> = [
  INLINE_CODE,
  BOLD_ITALIC_STAR,
  BOLD_ITALIC_UNDERSCORE,
  BOLD_STAR,
  BOLD_UNDERSCORE,
  HIGHLIGHT,
  ITALIC_STAR,
  ITALIC_UNDERSCORE,
  STRIKETHROUGH,
];

这些转换规则被统一封装为一种对象,它们可能会包含以下一些属性:

ts
// 各种转换规则(对象)的类型定义
export type ElementTransformer = {
  dependencies: Array<Klass<LexicalNode>>;
  export: (
    node: LexicalNode,
    // eslint-disable-next-line no-shadow
    traverseChildren: (node: ElementNode) => string,
  ) => string | null;
  regExp: RegExp;
  replace: (
    parentNode: ElementNode,
    children: Array<LexicalNode>,
    match: Array<string>,
    isImport: boolean,
  ) => void;
  type: 'element';
};

export type TextFormatTransformer = Readonly<{
  format: ReadonlyArray<TextFormatType>;
  tag: string;
  intraword?: boolean;
  type: 'text-format';
}>;

export type TextMatchTransformer = Readonly<{
  dependencies: Array<Klass<LexicalNode>>;
  export: (
    node: LexicalNode,
    // eslint-disable-next-line no-shadow
    exportChildren: (node: ElementNode) => string,
    // eslint-disable-next-line no-shadow
    exportFormat: (node: TextNode, textContent: string) => string,
  ) => string | null;
  importRegExp: RegExp;
  regExp: RegExp;
  replace: (node: TextNode, match: RegExpMatchArray) => void;
  trigger: string;
  type: 'text-match';
}>;
  • 属性 type 备注转换而得的节点类型,有三种类型 elementtext-formattext-match
  • 属性 dependencies:针对转换为 element 类型的转换规则,是一个数组,包含该转换规则所涉及的 Lexical 节点(即从 Markdown 文本转换而成的对应节点,一般一种转换规则是对应一种类型的节点,但是有些节点需要依赖其他节点存在,例如列表项节点 ListItemNode 需要有 ListNode 列表节点作为父节点同时存在)
  • 属性 format:针对转换为 text-format 类型的转换规则,是一个数组,包含了该转换规则所涉及的文本格式
  • 属性 regExp:用于匹配相应的 Markdown 标记的正则表达式(用户输入时)
  • 属性 replace:用于实现内容替换/节点转换的方法
  • 属性 importRegExp 用于匹配相应的 Markdown 标记的正则表达式(导入 Markdown 文本时)
  • 属性 trigger:针对转换为 text-match 类型的转换规则,触发转换规则指定的特定字符
  • 属性 export 是将相应的节点导出为 markdown 文本时的方法(相当于「逆转换」)

快捷键

该模块在 MarkdownShortcuts.ts 文件 中提供的一些支持 Markdown 快捷键命令的方法

实际上是通过注册一个侦听器 editor.registerUpdateListener 监听编辑器的更新,并在回调函数种执行前面所提到的节点转换),其核心代码如下

ts
// ...
// 一系列的节点转换操作
const transform = (
  parentNode: ElementNode,
  anchorNode: TextNode,
  anchorOffset: number,
) => {
  if (
    runElementTransformers(
      parentNode,
      anchorNode,
      anchorOffset,
      byType.element,
    )
  ) {
    return;
  }

  if (
    runTextMatchTransformers(
      anchorNode,
      anchorOffset,
      textMatchTransformersIndex,
    )
  ) {
    return;
  }

  runTextFormatTransformers(
    anchorNode,
    anchorOffset,
    textFormatTransformersIndex,
  );
};

// 监听编辑器的更新
return editor.registerUpdateListener(
  ({tags, dirtyLeaves, editorState, prevEditorState}) => {
    // Ignore updates from undo/redo (as changes already calculated)
    if (tags.has('historic')) {
      return;
    }

    // If editor is still composing (i.e. backticks) we must wait before the user confirms the key
    if (editor.isComposing()) {
      return;
    }

    const selection = editorState.read($getSelection);
    const prevSelection = prevEditorState.read($getSelection);

    if (
      !$isRangeSelection(prevSelection) ||
      !$isRangeSelection(selection) ||
      !selection.isCollapsed()
    ) {
      return;
    }

    const anchorKey = selection.anchor.key;
    const anchorOffset = selection.anchor.offset;

    const anchorNode = editorState._nodeMap.get(anchorKey);

    if (
      !$isTextNode(anchorNode) ||
      !dirtyLeaves.has(anchorKey) ||
      (anchorOffset !== 1 && anchorOffset > prevSelection.anchor.offset + 1)
    ) {
      return;
    }

    editor.update(() => {
      // Markdown is not available inside code
      if (anchorNode.hasFormat('code')) {
        return;
      }

      const parentNode = anchorNode.getParent();

      if (parentNode === null || $isCodeNode(parentNode)) {
        return;
      }
      // 执行节点转换
      transform(parentNode, anchorNode, selection.anchor.offset);
    });
  },
);

该模块最终通过导出一个方法 registerMarkdownShortcuts 来提供给开发者整合到项目中,例如通过以下代码来应用

ts
import {
  registerMarkdownShortcuts,
  TRANSFORMERS,
} from '@lexical/markdown';

const editor = createEditor(config);
registerMarkdownShortcuts(editor, TRANSFORMERS);

导入与导出

该模块在 MarkdownExport.ts 文件MarkdownImport.ts 文件中提供的一些 markdown 文件导入导出的相关方法


Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes