代码节点
Lexical 推出了一个模块包 @lexical/code 提供了两个自定义节点 CodeNode 代码节点和 CodeHighlightNode 高亮代码节点,用于渲染出代码块
参考
- lexical-code - Github
- @lexical/code - documentation
该模块自定义了两类节点 CodeNode 和 CodeHighlightNode:
CodeNode代码(块)节点CodeHighlightNode代码高亮节点
其中 CodeNode 作为代码(块)的容器,而代码中每一个不同高亮颜色的内容都对应一个 CodeHighlightNode 节点
提示
对于 inline 行内代码,则通过 TextNode 节点来渲染,并将文本节点的属性 format 设置为 code
另外该模块需要 prismjs 依赖包以实现代码高亮
CodeNode 代码节点
以下是 CodeNode 类的定义,它继承自 ElementNode 元素节点
import {addClassNamesToElement, isHTMLElement} from '@lexical/utils';
const LANGUAGE_DATA_ATTRIBUTE = 'data-highlight-language';
// 基于 `ElementNode` 元素节点进行扩展
export class CodeNode extends ElementNode {
// 代码节点的一些属性(内部访问)
/** @internal */
__language: string | null | undefined; // 代码块所属的编程语言
/**
* 所有节点都有以下两个静态方法
* static getType()
* static clone()
*/
// 获取该代码节点的名称
static getType(): string {
return 'code';
}
// 创建拷贝
static clone(node: CodeNode): CodeNode {
return new CodeNode(node.__language, node.__key);
}
/**
* 实例化
*/
constructor(language?: string | null | undefined, key?: NodeKey) {
super(key); // 除了通过 key 创建一个节点
// 还需要设置一些其他属性
this.__language = mapToPrismLanguage(language); // 通过映射处理来检查给定的编程语言是否支持高亮
}
/**
* View
* 与视图相关的方法
*/
// 该节点在页面上所对应的 DOM 元素结构
createDOM(config: EditorConfig): HTMLElement {
const element = document.createElement('code'); // 创建一个 <code> 元素
// 并为 <code> 元素添加在编辑器主题中 config.theme 所设置的 class 类名
// 在 config.theme.code 中设置 class 类名
addClassNamesToElement(element, config.theme.code);
// 将 DOM 元素的属性 spellcheck 设置为 false,不需要对该元素的内容进行拼写检查
element.setAttribute('spellcheck', 'false');
// 获取代码节点的属性 __language
const language = this.getLanguage();
if (language) {
// 并设置为 DOM 元素的属性 data-highlight-language 的值
element.setAttribute(LANGUAGE_DATA_ATTRIBUTE, language);
}
return element;
}
// 更新节点
updateDOM(
prevNode: CodeNode,
dom: HTMLElement,
config: EditorConfig,
): boolean {
const language = this.__language;
const prevLanguage = prevNode.__language;
if (language) {
if (language !== prevLanguage) {
// 更新编程语言,设置 DOM 元素的属性 data-highlight-language 的值
dom.setAttribute(LANGUAGE_DATA_ATTRIBUTE, language);
}
} else if (prevLanguage) {
// 如果当前和更新前的代码节点的属性 language 的值都是 undefined
// 则删除 DOM 元素的属性 data-highlight-language
dom.removeAttribute(LANGUAGE_DATA_ATTRIBUTE);
}
// 返回 false 表示并不需要重新调用 createDOM() 创建一个新的 DOM 元素来取代页面上原有的旧元素
return false;
}
// 节点序列化为 DOM 元素(字符串)时调用的方法
exportDOM(): DOMExportOutput {
// 代码节点导出为 DOM 时以 <pre> 元素的形式存在
const element = document.createElement('pre');
element.setAttribute('spellcheck', 'false');
const language = this.getLanguage();
if (language) {
// 设置 <pre> 元素的属性 data-highlight-language
element.setAttribute(LANGUAGE_DATA_ATTRIBUTE, language);
}
return {element};
}
// 将 DOM 元素反序列化为节点时调用的方法(静态方法)
static importDOM(): DOMConversionMap | null {
return {
// Typically <pre> is used for code blocks, and <code> for inline code styles
// but if it's a multi line <code> we'll create a block. Pass through to
// inline format handled by TextNode otherwise.
// 一般 <code> 元素会解析为 inline style TextNode 具有 code 样式的文本节点
// 只有当 <code> 元素包含多行内容时,才将其转换代码节点
// 这里入参的 node 是指需要反序列化的 DOM 元素
code: (node: Node) => {
// 判断 DOM 节点是否包含多行
// 通过正则表达式 `/\r?\n/` 对内容进行匹配,看看是否包含(可以有回车符相邻的)换行符
// 或通过查找里面的子元素是否包含 <br> 元素
const isMultiLine =
node.textContent != null &&
(/\r?\n/.test(node.textContent) || hasChildDOMNodeTag(node, 'BR'));
return isMultiLine
? {
// 如果 <code> 元素包含多行内容,则对其进行转换
conversion: convertPreElement,
priority: 1, // 优先级较高
}
: null; // 如果没有包含多行内容,则不进行处理
},
// 将字体名称包含 monospace 的 <div> 元素转换为代码节点
div: (node: Node) => ({
conversion: convertDivElement,
priority: 1,
}),
// 一般将 <pre> 元素解析为代码节点
pre: (node: Node) => ({
conversion: convertPreElement,
priority: 0,
}),
// 以下 DOM 元素的转换规则都是为了兼容从 Github 复制而来的内容 ❓
table: (node: Node) => {
const table = node;
// domNode is a <table> since we matched it by nodeName
if (isGitHubCodeTable(table as HTMLTableElement)) {
return {
conversion: convertTableElement,
priority: 3,
};
}
return null;
},
td: (node: Node) => {
// element is a <td> since we matched it by nodeName
const td = node as HTMLTableCellElement;
const table: HTMLTableElement | null = td.closest('table');
if (isGitHubCodeCell(td)) {
return {
conversion: convertTableCellElement,
priority: 3,
};
}
if (table && isGitHubCodeTable(table)) {
// Return a no-op if it's a table cell in a code table, but not a code line.
// Otherwise it'll fall back to the T
return {
conversion: convertCodeNoop,
priority: 3,
};
}
return null;
},
tr: (node: Node) => {
// element is a <tr> since we matched it by nodeName
const tr = node as HTMLTableCellElement;
const table: HTMLTableElement | null = tr.closest('table');
if (table && isGitHubCodeTable(table)) {
return {
conversion: convertCodeNoop,
priority: 3,
};
}
return null;
},
};
}
// 将 JSON 数据反序列化为节点时调用的方法(静态方法)
static importJSON(serializedNode: SerializedCodeNode): CodeNode {
// 💡 使用方法 $createCodeNode() 创建代码节点,具体可以查看后文
const node = $createCodeNode(serializedNode.language);
// 并调用(父类)ElementNode 元素节点的相应方法设置节点的相应属性
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}
// 节点序列化为 JSON 格式时调用的方法
exportJSON(): SerializedCodeNode {
return {
// 这里先使用了父类 ElementNode 元素节点的 exportJSON() 方法
// 并进行扩展,覆盖了属性 type 和 version
// 添加了一些其他属性 language,以便储存链接节点的相关信息
...super.exportJSON(),
language: this.getLanguage(),
type: 'code',
version: 1,
};
}
/**
* Mutation
* 与节点变换相关的方法
*/
// 设置在该节点上按回车键时的行为
// 这里根据光标的锚点位置而采取不同的处理逻辑
insertNewAfter(
selection: RangeSelection,
restoreSelection = true,
): null | ParagraphNode | CodeHighlightNode | TabNode {
const children = this.getChildren(); // 获取所有子节点
const childrenLength = children.length; // 获取子节点的数量
// 如果子节点数量大于 2
// 而且最后两个节点的内容是换行符
// 而且选区处于 collapsed 光标状态
// 而且光标位于该节点的内(位于最后)
// 即代码块的最后已经有两行是空行了,而光标位于代码块的最后,在该情况下按下回车键
if (
childrenLength >= 2 &&
children[childrenLength - 1].getTextContent() === '\n' &&
children[childrenLength - 2].getTextContent() === '\n' &&
selection.isCollapsed() &&
selection.anchor.key === this.__key &&
selection.anchor.offset === childrenLength
) {
// 删除该节点的最后两个节点(空行)
children[childrenLength - 1].remove();
children[childrenLength - 2].remove();
// 新建一个段落节点
const newElement = $createParagraphNode();
// 调用父类 ElementNode 的方法 insertAfter 将新建的段落节点插入该代码节点的后面(作为兄弟节点)
this.insertAfter(newElement, restoreSelection);
return newElement;
}
// 当选区位于代码块的中间时,在该情况下按下按下回车,
// 在代码块中新建的下一行需要**保留与前一行相同的缩进值**
// If the selection is within the codeblock, find all leading tabs and
// spaces of the current line. Create a new line that has all those
// tabs and spaces, such that leading indentation is preserved.
const anchor = selection.anchor;
const focus = selection.focus;
// 根据相对位置,厘清哪个是选区的开头(锚点还是焦点)
const firstPoint = anchor.isBefore(focus) ? anchor : focus;
// 获取选区开头所在的节点
const firstSelectionNode = firstPoint.getNode();
// 如果选区的开头节点是 CodeHighlightNode 代码高亮节点或 TabNode 缩进节点
if (
$isCodeHighlightNode(firstSelectionNode) ||
$isTabNode(firstSelectionNode)
) {
// 获取选区的开头所在的那一行的最开头的节点(CodeHighlightNode 代码高亮节点或 TabNode 缩进节点)
// getFirstCodeNodeOfLine 方法是在 CodeHighlightNode 代码高亮节点的模块中导出
let node = getFirstCodeNodeOfLine(firstSelectionNode);
const insertNodes = [];
// eslint-disable-next-line no-constant-condition
while (true) {
if ($isTabNode(node)) {
// 如果选区的开头所在的那一行的最开头的节点是 TabNode 节点
// 则使用 $createTabNode() 方法创建一个新的 TabNode 节点并将它加入到 insertNodes 数组中
insertNodes.push($createTabNode());
node = node.getNextSibling(); // 然后继续考察下一个兄弟节点的情况
} else if ($isCodeHighlightNode(node)) {
// 如果选区的开头所在的那一行的最开头的节点是 CodeHighlightNode 代码高亮节点
// 统计该节点内容的开始部分有多少个空格
let spaces = 0;
const text = node.getTextContent();
const textSize = node.getTextContentSize();
for (; spaces < textSize && text[spaces] === ' '; spaces++);
if (spaces !== 0) {
// 如果节点内容的开始部分具有空格
// 则使用 $createCodeHighlightNode() 方法创建一个新的 CodeHighlightNode 节点
// 并将内容设置为相同数量的空格
// 再将节点加入到 insertNodes 数组中
insertNodes.push($createCodeHighlightNode(' '.repeat(spaces)));
}
// 如果空格数量和文本数量不一致,表示该 CodeHighlightNode 代码高亮节点前面带空格后面就是文字
// 那么缩进的考察就结束了,可以跳出循环
if (spaces !== textSize) {
break;
}
node = node.getNextSibling(); // 然后继续考察下一个兄弟节点的情况
} else {
break;
}
}
if (insertNodes.length > 0) {
// 使用方法 $createLineBreakNode() 创建一个换行节点
// 连同前面 insertNodes 数组所收集的节点
// 一并插入到选区后
selection.insertNodes([$createLineBreakNode(), ...insertNodes]);
return insertNodes[insertNodes.length - 1];
}
}
return null;
}
// 该节点不可以缩进
canIndent(): false {
return false;
}
// 设置光标移动到节点开头的行为
// 当该节点位于编辑器的**开头**按下 Backspace 键的行为 ❓
// 将代码节点变成段落节点
collapseAtStart(): boolean {
// 创建一个段落节点
const paragraph = $createParagraphNode();
// 将当前代码节点的所有子节点都添加到新建的段落节点中
const children = this.getChildren();
children.forEach((child) => paragraph.append(child));
// 将新建的段落节点替换为代码节点
this.replace(paragraph);
return true;
}
// 链接节点具有一些属性(带双下划线 __property 表示属性仅供内部访问)
// 为了可以提供外部访问和修改的权限
// 需要分别为这些属性设置 get 和 set 方法
// ⚠️ 为了确保 Lexical 的 Editor State 编辑器状态满足不可变性 immutable,在读取和修改节点的属性值时,应该调用 Lexical 所提供的方法 getLatest() 和 getWritable()
setLanguage(language: string): void {
const writable = this.getWritable();
// 在设置 __language 属性时,需要通过映射来判断 Prism 是否支持该编程语言
writable.__language = mapToPrismLanguage(language);
}
getLanguage(): string | null | undefined {
return this.getLatest().__language;
}
}
其中用于查看/检查给定的编程语言是否为 Prism 所支持的方法 mapToPrismLanguage(进行映射处理)具体代码如下
import * as Prism from 'prismjs';
const mapToPrismLanguage = (
language: string | null | undefined,
): string | null | undefined => {
// 判断给定的编程语言 language 是否为 Prism 所支持的
// 如果支持则直接返回它,否则返回 undefined
// eslint-disable-next-line no-prototype-builtins
return language != null && Prism.languages.hasOwnProperty(language)
? language
: undefined;
};
其中用于判断 DOM 元素类型的方法 hasChildDOMNodeTag 具体代码如下
function hasChildDOMNodeTag(node: Node, tagName: string) {
for (const child of node.childNodes) {
if (isHTMLElement(child) && child.tagName === tagName) {
return true;
}
// 以递归调用的方式查找给定的 DOM 元素的**后代元素**中是否还有指定的 tagName 类型
hasChildDOMNodeTag(child, tagName);
}
return false;
}
其中将 DOM 元素反序列化所使用的一系列转换函数的具体代码如下
// 将 <pre> 元素或多行 <code> 元素转换为代码节点
function convertPreElement(domNode: Node): DOMConversionOutput {
let language;
if (isHTMLElement(domNode)) {
language = domNode.getAttribute(LANGUAGE_DATA_ATTRIBUTE);
}
return {node: $createCodeNode(language)};
}
// 将字体名称为 monospace 的 <div> 元素转换为代码节点
function convertDivElement(domNode: Node): DOMConversionOutput {
// domNode is a <div> since we matched it by nodeName
const div = domNode as HTMLDivElement;
const isCode = isCodeElement(div);
if (!isCode && !isCodeChildElement(div)) {
return {
node: null,
};
}
return {
after: (childLexicalNodes) => {
const domParent = domNode.parentNode;
if (domParent != null && domNode !== domParent.lastChild) {
childLexicalNodes.push($createLineBreakNode());
}
return childLexicalNodes;
},
node: isCode ? $createCodeNode() : null,
};
}
// 判断给定的 DOM 元素的字体是否为 monospace
function isCodeElement(div: HTMLElement): boolean {
return div.style.fontFamily.match('monospace') !== null;
}
// 判断给定的 DOM 元素的祖先元素的字体是否为 monospace
function isCodeChildElement(node: HTMLElement): boolean {
let parent = node.parentElement;
// 通过循环回溯查看祖先元素的字体设置
while (parent !== null) {
if (isCodeElement(parent)) {
return true;
}
parent = parent.parentElement;
}
return false;
}
前面所使用的用于创建代码节点的方法 $createCodeNode() 的具体代码如下
import { $applyNodeReplacement } from 'lexical';
export function $createCodeNode(
language?: string | null | undefined,
): CodeNode {
// 在创建代码节点时,不直接返回 CodeNode 节点实例
// 而是先经过 $applyNodeReplacement() 方法的处理
// 因为在 Lexical 系统中设计了一个覆盖节点的功能
// 可以在全局的层面将特定类型的节点替换为指定的节点
// 具体可以查看官方文档 https://lexical.dev/docs/concepts/node-replacement
// 或笔记的相关部分 https://frontend-note.benbinbin.com/article/lexical/lexical-concept-node#覆写节点
return $applyNodeReplacement(new CodeNode(language));
}
CodeHighlightNode
该模块定义了另一种节点 CodeHighlightNode 它作为 CodeNode 的子节点,用于为代码(文本内容)添加不同的高亮颜色,其中核心主要是基于 prismjs 依赖包分析代码并设置高亮
以下是 CodeHighlightNode 类的定义,它继承自 TextNode 文本节点
// 基于 `TextNode` 文本节点进行扩展
/** @noInheritDoc */
export class CodeHighlightNode extends TextNode {
// 代码节点的一些属性(内部访问)
/** @internal */
__highlightType: string | null | undefined; // 高亮的类型
// 这里的「类型」是指针对哪一个种内容进行高亮,例如标点符号,动词、变量还是数字等
/**
* 所有节点都有以下两个静态方法
* static getType()
* static clone()
*/
// 获取该代码节点的名称
static getType(): string {
return 'code-highlight';
}
// 创建拷贝
static clone(node: CodeHighlightNode): CodeHighlightNode {
return new CodeHighlightNode(
node.__text, // 该属性是继承自父类型 TextNode 的属性,该节点所包含的文本内容
node.__highlightType || undefined,
node.__key,
);
}
/**
* 实例化
*/
constructor(
text: string, // 所包含的文本内容
highlightType?: string | null | undefined, // 高亮类型
key?: NodeKey, // 节点的唯一标识符
) {
super(text, key);
this.__highlightType = highlightType;
}
// 链接节点具有一些属性(带双下划线 __property 表示属性仅供内部访问)
// 为了可以提供外部访问和修改的权限
// 需要分别为这些属性设置 get 和 set 方法
// ⚠️ 为了确保 Lexical 的 Editor State 编辑器状态满足不可变性 immutable,在读取和修改节点的属性值时,应该调用 Lexical 所提供的方法 getLatest() 和 getWritable()
getHighlightType(): string | null | undefined {
const self = this.getLatest();
return self.__highlightType;
}
/**
* View
* 与视图相关的方法
*/
// 该节点在页面上所对应的 DOM 元素结构
createDOM(config: EditorConfig): HTMLElement {
// 根据编辑器配置 config 使用父类 TextNode 文本节点的方法 createDOM 创建相应的 HTML 元素
const element = super.createDOM(config);
const className = getHighlightThemeClass(
config.theme,
this.__highlightType,
);
// 并为 DOM 元素添加在编辑器主题中 config.theme 所设置的 class 类名
// 在 config.theme.codeHighlight 中设置 class 类名
addClassNamesToElement(element, className);
return element;
}
// 更新节点
updateDOM(
prevNode: CodeHighlightNode,
dom: HTMLElement,
config: EditorConfig,
): boolean {
// 使用父类 TextNode 文本节点的方法 updateDOM 更新相应的 HTML 元素(默认方法)
const update = super.updateDOM(prevNode, dom, config);
// 再根据节点的高亮类型 highlightType 是否发生变化
// 对 DOM 元素的 class 类名进行增删
const prevClassName = getHighlightThemeClass(
config.theme,
prevNode.__highlightType,
);
const nextClassName = getHighlightThemeClass(
config.theme,
this.__highlightType,
);
if (prevClassName !== nextClassName) {
if (prevClassName) {
// 删除之前的类名
removeClassNamesFromElement(dom, prevClassName);
}
if (nextClassName) {
// 增加新的类名
addClassNamesToElement(dom, nextClassName);
}
}
// 最后返回 update(一个布尔值)
// 表示是否需要重新调用 createDOM() 创建一个新的 DOM 元素
// 这里取决于父元素 TextNode 的默认行为
return update;
}
// 将 JSON 数据反序列化为节点时调用的方法(静态方法)
static importJSON(
serializedNode: SerializedCodeHighlightNode,
): CodeHighlightNode {
// 💡 使用方法 $createCodeHighlightNode() 创建高亮代码节点,具体可以查看后文
const node = $createCodeHighlightNode(
serializedNode.text,
serializedNode.highlightType,
);
// 并调用(父类)TextNode 文本节点的相应方法设置节点的相应属性
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
}
// 节点序列化为 JSON 格式时调用的方法
exportJSON(): SerializedCodeHighlightNode {
return {
// 这里先使用了父类 TextNode 文本节点的 exportJSON() 方法
// 并进行扩展,覆盖了属性 type 和 version
// 添加了一些其他属性 highlightType,以便储存链接节点的相关信息
...super.exportJSON(),
highlightType: this.getHighlightType(),
type: 'code-highlight',
version: 1,
};
}
// 覆写父类的方法 setFormat,因为该节点的行为和父类不相同
// 代码是无法设置文本内容的样式格式的,例如加粗、下划线等
// Prevent formatting (bold, underline, etc)
setFormat(format: number): this {
return this;
}
// 需要存在父节点(父节点是 CodeNode 代码节点)
// 用于规范复制粘贴的行为
isParentRequired(): true {
return true;
}
// 用于创建父节点
// 当之前的方法 isParentRequired() 返回 true 时,在复制粘贴时会配合调用该方法
createParentElementNode(): ElementNode {
return $createCodeNode();
}
}
其中用于获取(CodeHighlightNode 节点所对应的)DOM 元素的类名的方法 getHighlightThemeClass 具体代码如下
function getHighlightThemeClass(
theme: EditorThemeClasses,
highlightType: string | null | undefined,
): string | null | undefined {
// 这里的代码可以使用 **JS 的可选链** 进行简化
return (
highlightType &&
theme &&
theme.codeHighlight &&
theme.codeHighlight[highlightType] // 根据高亮内容的类型 highlightType 获取相应的 class 类名
);
}
其中用于创建高亮代码节点的方法 $createCodeHighlightNode() 的具体代码如下
export function $createCodeHighlightNode(
text: string,
highlightType?: string | null | undefined,
): CodeHighlightNode {
// 在创建链接节点时,不直接返回 CodeHighlightNode 节点实例
// 而是先经过 $applyNodeReplacement() 方法的处理
// 因为在 Lexical 系统中设计了一个覆盖节点的功能
// 可以在全局的层面将特定类型的节点替换为指定的节点
// 具体可以查看官方文档 https://lexical.dev/docs/concepts/node-replacement
// 或笔记的相关部分 https://frontend-note.benbinbin.com/article/lexical/lexical-concept-node#覆写节点
return $applyNodeReplacement(new CodeHighlightNode(text, highlightType));
}