兼容 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 文件中提供的一些实现节点转换的相关规则
/**
* 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> 里面的内容不需要再进行转换;然后注册处理较长标记的节点转换器,例如针对 ** 标记的节点转换规则应该先注册,然后再注册针对 * 标记的节点转换器
以下是针对文本格式的节点转换规则的注册顺序
const TEXT_FORMAT_TRANSFORMERS: Array<TextFormatTransformer> = [
INLINE_CODE,
BOLD_ITALIC_STAR,
BOLD_ITALIC_UNDERSCORE,
BOLD_STAR,
BOLD_UNDERSCORE,
HIGHLIGHT,
ITALIC_STAR,
ITALIC_UNDERSCORE,
STRIKETHROUGH,
];
这些转换规则被统一封装为一种对象,它们可能会包含以下一些属性:
// 各种转换规则(对象)的类型定义
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备注转换而得的节点类型,有三种类型element、text-format、text-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 监听编辑器的更新,并在回调函数种执行前面所提到的节点转换),其核心代码如下
// ...
// 一系列的节点转换操作
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 来提供给开发者整合到项目中,例如通过以下代码来应用
import {
registerMarkdownShortcuts,
TRANSFORMERS,
} from '@lexical/markdown';
const editor = createEditor(config);
registerMarkdownShortcuts(editor, TRANSFORMERS);