代码节点

lexical
Created 7/17/2023
Updated 8/21/2023

代码节点

Lexical 推出了一个模块包 @lexical/code 提供了两个自定义节点 CodeNode 代码节点和 CodeHighlightNode 高亮代码节点,用于渲染出代码块

参考

该模块自定义了两类节点 CodeNodeCodeHighlightNode

  • CodeNode 代码(块)节点
  • CodeHighlightNode 代码高亮节点

其中 CodeNode 作为代码(块)的容器,而代码中每一个不同高亮颜色的内容都对应一个 CodeHighlightNode 节点

提示

对于 inline 行内代码,则通过 TextNode 节点来渲染,并将文本节点的属性 format 设置为 code

另外该模块需要 prismjs 依赖包以实现代码高亮

CodeNode 代码节点

以下是 CodeNode 类的定义,它继承自 ElementNode 元素节点

ts
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(进行映射处理)具体代码如下

ts
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 具体代码如下

ts
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 元素反序列化所使用的一系列转换函数的具体代码如下

ts
// 将 <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() 的具体代码如下

ts
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 文本节点

ts
// 基于 `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 具体代码如下

ts
function getHighlightThemeClass(
  theme: EditorThemeClasses,
  highlightType: string | null | undefined,
): string | null | undefined {
  // 这里的代码可以使用 **JS 的可选链** 进行简化
  return (
    highlightType &&
    theme &&
    theme.codeHighlight &&
    theme.codeHighlight[highlightType] // 根据高亮内容的类型 highlightType 获取相应的 class 类名
  );
}

其中用于创建高亮代码节点的方法 $createCodeHighlightNode() 的具体代码如下

ts
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));
}

Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes