链接节点

lexical
Created 7/17/2023
Updated 7/25/2023

链接节点

Lexical 推出了一个模块包 @lexical/link 提供了一个自定义节点 LinkNode 链接节点

参考

该模块自定义了两类链接节点 LineNodeAutoLinkNode

  • LineNode 普通的链接节点
  • AutoLinkNode 自动转换的链接节点,针对直接在编辑器中输入 URL 字符串,自动转换为链接节点的场景

所支持的链接协议如下,即链接节点所对应的 URL 需要采用以下 protocol

ts
const SUPPORTED_URL_PROTOCOLS = new Set([
  'http:',
  'https:',
  'mailto:',
  'sms:',
  'tel:',
]);

LinkNode

ts
import {addClassNamesToElement} from '@lexical/utils';

// 基于 `ElementNode` 元素节点进行扩展
export class LinkNode extends ElementNode {
  // 链接节点的一些属性(内部访问)
  /** @internal */
  __url: string; // 对应 <a> 标签的 href 属性值
  /** @internal */
  __target: null | string; // 对应于 <a> 标签的 target 属性值
  /** @internal */
  __rel: null | string; // 对应于 <a> 标签的 rel 属性值
  /** @internal */
  __title: null | string; // 对应于 <a> 标签的 title 属性值

  /**
   * 所有节点都有以下两个静态方法
   * static getType()
   * static clone()
   */
  // 获取该自定义节点的名称
  static getType(): string {
    return 'link';
  }

  // 创建拷贝
  static clone(node: LinkNode): LinkNode {
    return new LinkNode(
      node.__url,
      {rel: node.__rel, target: node.__target, title: node.__title},
      node.__key,
    );
  }

  /**
   * 实例化
   */
  constructor(url: string, attributes: LinkAttributes = {}, key?: NodeKey) {
    super(key); // 除了通过 key 创建一个节点
    // 还需要设置一些其他属性
    const {target = null, rel = null, title = null} = attributes;
    this.__url = url;
    this.__target = target;
    this.__rel = rel;
    this.__title = title;
  }

  // 链接节点具有一些属性(带双下划线 __property 表示属性仅供内部访问)
  // 为了可以提供外部访问和修改的权限
  // 需要分别为这些属性设置 get 和 set 方法
  // ⚠️ 为了确保 Lexical 的 Editor State 编辑器状态满足不可变性 immutable,在读取和修改节点的属性值时,应该调用 Lexical 所提供的方法 getLatest() 和 getWritable()
  getURL(): string {
    return this.getLatest().__url;
  }

  setURL(url: string): void {
    const writable = this.getWritable();
    writable.__url = url;
  }

  getTarget(): null | string {
    return this.getLatest().__target;
  }

  setTarget(target: null | string): void {
    const writable = this.getWritable();
    writable.__target = target;
  }

  getRel(): null | string {
    return this.getLatest().__rel;
  }

  setRel(rel: null | string): void {
    const writable = this.getWritable();
    writable.__rel = rel;
  }

  getTitle(): null | string {
    return this.getLatest().__title;
  }

  setTitle(title: null | string): void {
    const writable = this.getWritable();
    writable.__title = title;
  }

  /**
   * View
   * 与视图相关的方法
   */
  // 该节点在页面上所对应的 DOM 元素结构
  createDOM(config: EditorConfig): HTMLAnchorElement {
    const element = document.createElement('a'); // 创建一个 <a> 元素
    // 并将属性 __url 通过「无害化」处理后设置为 <a> 元素的 href 属性值
    // 💡 用于「无害化」处理的函数 sanitizeUrl() 可以查看后文
    element.href = this.sanitizeUrl(this.__url);
    // 分别判断链接节点一些属性,如果它们不为空,则分别设置为 <a> 元素的相应属性
    if (this.__target !== null) {
      element.target = this.__target;
    }
    if (this.__rel !== null) {
      element.rel = this.__rel;
    }
    if (this.__title !== null) {
      element.title = this.__title;
    }
    // 并为 <a> 元素添加在编辑器主题中 config.theme 所设置的 class 类名
    // 在 config.theme.link 中设置 class 类名
    addClassNamesToElement(element, config.theme.link);
    return element;
  }

  // 更新节点
  updateDOM(
    prevNode: LinkNode,
    anchor: HTMLAnchorElement,
    config: EditorConfig,
  ): boolean {
    const url = this.__url;
    const target = this.__target;
    const rel = this.__rel;
    const title = this.__title;

    // 判断当前的链接节点的 url 和前一个 state 状态下的 url 是否一致
    // 不一致就进行更新
    if (url !== prevNode.__url) {
      anchor.href = url;
    }

    // 对于其他属性也是进行类似的判断
    if (target !== prevNode.__target) {
      if (target) {
        anchor.target = target;
      } else {
        anchor.removeAttribute('target');
      }
    }

    if (rel !== prevNode.__rel) {
      if (rel) {
        anchor.rel = rel;
      } else {
        anchor.removeAttribute('rel');
      }
    }

    if (title !== prevNode.__title) {
      if (title) {
        anchor.title = title;
      } else {
        anchor.removeAttribute('title');
      }
    }

    // 并不需要重新调用 createDOM() 创建一个新的 DOM 元素来取代页面上原有的旧元素
    // 因为更新前后该节点都是对应于 <a> 元素
    return false;
  }

  // 将 DOM 元素反序列化为节点时调用的方法(静态方法)
  static importDOM(): DOMConversionMap | null {
    return {
      a: (node: Node) => ({
        // 💡 所使用的转换函数 convertAnchorElement 可以查看后文
        conversion: convertAnchorElement,
        priority: 1,
      }),
    };
  }

  // 将 JSON 数据反序列化为节点时调用的方法(静态方法)
  // 不管导入的节点是否为 SerializedLinkNode 序列化的链接节点,还是 SerializedAutoLinkNode 序列化的自动链接节点,最后都是反序列化为 LinkNode 列表节点
  static importJSON(
    serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
  ): LinkNode {
    // 💡 使用方法 $createLinkNode() 创建链接节点,具体可以查看后文
    const node = $createLinkNode(serializedNode.url, {
      rel: serializedNode.rel,
      target: serializedNode.target,
      title: serializedNode.title,
    });
    // 并调用(父类)ElementNode 元素节点的相应方法设置节点的相应属性
    node.setFormat(serializedNode.format);
    node.setIndent(serializedNode.indent);
    node.setDirection(serializedNode.direction);
    return node;
  }

  // 🚫 缺少 exportDOM 方法,将链接节点序列化为 DOM 元素(字符串)

  // 节点序列化为 JSON 格式时调用的方法
  exportJSON(): SerializedLinkNode | SerializedAutoLinkNode {
    return {
      // 这里先使用了父类 ElementNode 元素节点的 exportJSON() 方法
      // 并进行扩展,覆盖了属性 type 和 version
      // 添加了一些其他属性,以便储存链接节点的相关信息
      ...super.exportJSON(),
      rel: this.getRel(),
      target: this.getTarget(),
      title: this.getTitle(),
      type: 'link',
      url: this.getURL(),
      version: 1,
    };
  }

  /**
   * Mutation
   * 与节点变换相关的方法
   */
  // 设置在该节点上按回车键时的行为
  // 这里根据光标的锚点位置而采取不同的处理逻辑
  insertNewAfter(
    selection: RangeSelection,
    restoreSelection = true,
  ): null | ElementNode {
    // 基于父节点 this.getParentOrThrow() 的 insertNewAfter 行为,创建一个节点
    // 由于 LinkNode 属于 inline 类型的 ElementNode 元素节点,所以换行后需要用父节点来包裹 ❓
    const element = this.getParentOrThrow().insertNewAfter(
      selection,
      restoreSelection,
    );
    if ($isElementNode(element)) {
      // 判断新建的节点是否为 ElementNode 元素节点
      // 如果 element 是元素节点就创建一个具有同样属性的 LinkNode 链接节点
      const linkNode = $createLinkNode(this.__url, {
        rel: this.__rel,
        target: this.__target,
        title: this.__title,
      });
      // 并将其插入到 element 最后
      element.append(linkNode);
      return linkNode;
    }
    return null;
  }

  // 如果光标在节点最前面时继续输入文字
  // 则文字内容并不会插入 prepend 到链接节点内
  // 而是紧挨着链接节点创建一个段落节点(作为兄弟节点)来容纳新输入的文本
  canInsertTextBefore(): false {
    return false;
  }

  // 如果光标在节点最后面时继续输入文字
  // 则文字内容并不会插入 append 到链接节点内
  // 而是紧挨着链接节点创建一个段落节点(作为兄弟节点)来容纳新输入的文本
  canInsertTextAfter(): false {
    return false;
  }

  // 该节点不能为空(要有子节点,一般为文本节点)
  canBeEmpty(): false {
    return false;
  }

  // 该节点虽然是继承自 ElementNode 元素节点,但是它在外观上是 inline 位于行内(而不是独占整行的)
  isInline(): true {
    return true;
  }

  // 该方法用于 @lexical/clipboard 模块中
  // 以实现复制粘贴的相关功能
  // 参考 https://github.com/facebook/lexical/blob/main/packages/lexical-clipboard/src/clipboard.ts#L489
  // 返回的是一个**布尔值**用于设置复制的行为,当复制该节点的子节点时(由于 ElementNode 元素节点一般不易选中),是否同时用该节点进行包裹
  // 即如果选中该链接节点的文本内容(可能只是部分文本)进行复制时,是否该节点进行包裹
  // 如果返回 true 则复制时,会用该链接节点对选中的文本进行包裹,即相当于复制了一个链接节点;如果返回 false 则复制时,只是复制了选中的文本
  extractWithChild(
    child: LexicalNode,
    selection: RangeSelection | NodeSelection | GridSelection,
    destination: 'clone' | 'html',
  ): boolean {
    if (!$isRangeSelection(selection)) {
      return false;
    }

    const anchorNode = selection.anchor.getNode();
    const focusNode = selection.focus.getNode();

    // 根据范围选区的端点(anchor 锚定一侧和 focus 焦点一侧)是否都在该链接节点中
    // 以及有选中的文本(文本长度大于 0)
    // 则返回 true 即使用该链接节点包裹选中的文本,所以实际复制的是一个链接(而不是纯文本)
    return (
      this.isParentOf(anchorNode) &&
      this.isParentOf(focusNode) &&
      selection.getTextContent().length > 0
    );
  }
}

其中用于 URL 「无害化」处理的函数 sanitizeUrl() 的具体代码如下

ts
sanitizeUrl(url: string): string {
  try {
    // 尝试基于入参的字符串创建一个 URL 对象
    const parsedUrl = new URL(url);
    // 并判断这个 URL 对象所采用的协议 protocol 是否为链接节点所支持的
    // eslint-disable-next-line no-script-url
    if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
      // 如果链接节点并不支持这种类型的协议,直接返回 'about:blank' 作为链接的 url
      // 那么链接会指向一个空页面
      return 'about:blank';
    }
  } catch {
    return url;
  }
  // 如果通过「无害化」处理,就返回入参的字符串 url
  return url;
}

其中将 DOM 元素反序列化所使用的转换函数 convertAnchorElement 的具体代码如下

ts
import {isHTMLAnchorElement} from '@lexical/utils';

function convertAnchorElement(domNode: Node): DOMConversionOutput {
  let node = null;
  // 转换的 DOM 是 <a> 元素
  if (isHTMLAnchorElement(domNode)) {
    const content = domNode.textContent;
    // 先判断该元素是否有文本内容
    if (content !== null && content !== '') {
      // 使用 dom.getAttribute() 方法从 <a> 元素里读取出相应的属性,用于创建链接节点
      node = $createLinkNode(domNode.getAttribute('href') || '', {
        rel: domNode.getAttribute('rel'),
        target: domNode.getAttribute('target'),
        title: domNode.getAttribute('title'),
      });
    }
  }
  return {node};
}

前面所使用的用于创建链接节点的方法 $createLinkNode() 的具体代码如下

ts
import { $applyNodeReplacement } from 'lexical';

/**
 * Takes a URL and creates a LinkNode.
 * @param url - The URL the LinkNode should direct to.
 * @param attributes - Optional HTML a tag attributes { target, rel, title }
 * @returns The LinkNode.
 */
export function $createLinkNode(
  url: string,
  attributes?: LinkAttributes,
): LinkNode {
  // 在创建链接节点时,不直接返回 LinkNode 节点实例
  // 而是先经过 $applyNodeReplacement() 方法的处理
  // 因为在 Lexical 系统中设计了一个覆盖节点的功能
  // 可以在全局的层面将特定类型的节点替换为指定的节点
  // 具体可以查看官方文档 https://lexical.dev/docs/concepts/node-replacement
  // 或笔记的相关部分 https://frontend-note.benbinbin.com/article/lexical/lexical-concept-node#覆写节点
  return $applyNodeReplacement(new LinkNode(url, attributes));
}

该模块还导出了一个判断给定节点 node 是否为链接节点的方法

ts
//
/**
 * Determines if node is a LinkNode.
 * @param node - The node to be checked.
 * @returns true if node is a LinkNode, false otherwise.
 */
export function $isLinkNode(
  node: LexicalNode | null | undefined,
): node is LinkNode {
  return node instanceof LinkNode;
}

AutoLinkNode

该模块还定义了另一种节点 AutoLinkNode,它继承自 LineNode 普通的链接节点,针对直接在编辑器中输入 URL 字符串,自动转换为链接节点的场景

疑问

但是在当前的源码中,AutoLinkNode 除了节点的名称以外,它的属性和行为与 LineNode 相比并没有特别的不同,应该还需要配合正则表达式 Regular Expression 匹配用户输入的内容,以触发节点转换器,将普通的文本节点自动转换为 AutoLinkNode 自动链接节点

具体实现可以查看另一个模块 @lexical/markdown

ts
// 该自定义节点的作用是覆写 `canInsertTextAfter` 属性,以允许在 link 后面直接输入文本 ❓
// Custom node type to override `canInsertTextAfter` that will
// allow typing within the link
export class AutoLinkNode extends LinkNode {
  static getType(): string {
    return 'autolink';
  }

  static clone(node: AutoLinkNode): AutoLinkNode {
    return new AutoLinkNode(
      node.__url,
      {rel: node.__rel, target: node.__target, title: node.__title},
      node.__key,
    );
  }

  /**
   * View
   * 与视图相关的方法
   */
  static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
    const node = $createAutoLinkNode(serializedNode.url, {
      rel: serializedNode.rel,
      target: serializedNode.target,
      title: serializedNode.title,
    });
    node.setFormat(serializedNode.format);
    node.setIndent(serializedNode.indent);
    node.setDirection(serializedNode.direction);
    return node;
  }

  // 对于导入 DOM 对象中的所有 <a> 元素都转换为 LinkNode 链接节点
  // 所以并不需要 AutoLinkNode 进行处理 ❓
  static importDOM(): null {
    // TODO: Should link node should handle the import over autolink?
    return null;
  }

  exportJSON(): SerializedAutoLinkNode {
    return {
      ...super.exportJSON(),
      type: 'autolink',
      version: 1,
    };
  }

  /**
   * Mutation
   * 与节点变换相关的方法
   */
  // 设置在该节点上按回车键时的行为
  // 这里根据光标的锚点位置而采取不同的处理逻辑
  insertNewAfter(
    selection: RangeSelection,
    restoreSelection = true,
  ): null | ElementNode {
    const element = this.getParentOrThrow().insertNewAfter(
      selection,
      restoreSelection,
    );
    if ($isElementNode(element)) {
      // 判断新建的节点是否为 ElementNode 元素节点
      // 如果 element 是元素节点就创建一个具有同样属性的 AutoLinkNode 链接节点
      const linkNode = $createAutoLinkNode(this.__url, {
        rel: this._rel,
        target: this.__target,
        title: this.__title,
      });
      element.append(linkNode);
      return linkNode;
    }
    return null;
  }
}

前面所使用的用于创建自动链接节点的方法 $createAutoLinkNode() 的具体代码如下

ts
/**
 * Takes a URL and creates an AutoLinkNode. AutoLinkNodes are generally automatically generated
 * during typing, which is especially useful when a button to generate a LinkNode is not practical.
 * @param url - The URL the LinkNode should direct to.
 * @param attributes - Optional HTML a tag attributes. { target, rel, title }
 * @returns The LinkNode.
 */
export function $createAutoLinkNode(
  url: string,
  attributes?: LinkAttributes,
): AutoLinkNode {
  return $applyNodeReplacement(new AutoLinkNode(url, attributes));
}

该模块还导出了一个判断给定节点 node 是否为自动链接节点的方法

ts
/**
 * Determines if node is an AutoLinkNode.
 * @param node - The node to be checked.
 * @returns true if node is an AutoLinkNode, false otherwise.
 */
export function $isAutoLinkNode(
  node: LexicalNode | null | undefined,
): node is AutoLinkNode {
  return node instanceof AutoLinkNode;
}

链接节点的切换

该模块提供了一个 toggleLink 方法,用于实现链接节点的切换,基于调用该方法时第一个参数 url 的值:

  • 如果传递的值是字符串,则将选区(的父节点)设置为链接节点
  • 如果传递的值是 null,则将取消原有的链接节点
ts
/**
 * Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
 * but saves any children and brings them up to the parent node.
 * @param url - The URL the link directs to.
 * @param attributes - Optional HTML a tag attributes. { target, rel, title }
 */
export function toggleLink(
  url: null | string,
  attributes: LinkAttributes = {},
): void {
  const {target, title} = attributes;
  const rel = attributes.rel === undefined ? 'noreferrer' : attributes.rel;
  const selection = $getSelection();

  if (!$isRangeSelection(selection)) {
    return;
  }

  // 这里提取了选区中的节点
  // 而且该方法会执行必要的节点分割 split node 以适应选择文本节点的部分内容的场景
  // 如果切换操作是添加链接节点,则新建的链接节点**只会对选中的文本内容**进行包裹
  // 具体参考官方文档 https://lexical.dev/docs/api/classes/lexical.RangeSelection#extract 或源码 https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalSelection.ts#L1973-L2026
  const nodes = selection.extract();

  if (url === null) {
    // 如果传入的参数 url 的值是 null,就移除链接节点
    nodes.forEach((node) => {
      // 遍历选区中的节点(一般是文本节点),获取它们的父节点
      const parent = node.getParent();

      if ($isLinkNode(parent)) {
        // 判断父节点是否为链接节点
        // 抽取该父节点的子节点
        const children = parent.getChildren();

        // 将这些子节点都移动到该父节点之前
        for (let i = 0; i < children.length; i++) {
          parent.insertBefore(children[i]);
        }

        // 最后删除该父节点(链接节点)
        parent.remove();
      }
    });
  } else {
    // 如果传入的参数 url 的值是字符串,就创建/更新链接节点
    if (nodes.length === 1) {
      // 如果选区中只有一个节点
      const firstNode = nodes[0];
      // 从该节点(开始)及其祖先节点中寻找 LinkNode 链接节点
      const linkNode = $isLinkNode(firstNode)
        ? firstNode
        : $getLinkAncestor(firstNode);
      if (linkNode !== null) {
        // 如果可以找到已存在的 LinkNode 就对其进行更新(包括 URL 和其他属性)
        linkNode.setURL(url);
        if (target !== undefined) {
          linkNode.setTarget(target);
        }
        if (rel !== null) {
          linkNode.setRel(rel);
        }
        if (title !== undefined) {
          linkNode.setTitle(title);
        }
        return; // 更新成功后就结束了
      }
    }

    // 用于记录前一个遍历节点的父节点
    let prevParent: ElementNode | LinkNode | null = null;
    // 用于记录前一个遍历节点所属的链接节点
    let linkNode: LinkNode | null = null;

    // 如果选区有多个节点,对其进行遍历,分各种(边缘)情况进行处理
    // ⚠️ 避免形成嵌套的链接节点,相应地避免生成嵌套的 <a> 标签
    nodes.forEach((node) => {
      // 获取当前所遍历节点的父节点
      const parent = node.getParent();

      // 1️⃣ 情况一:不进行处理
      // 如果父节点和前面记录的链接节点相同,或父节点为空,或父节点是独占一行的 ElementNode(即不是位于 inline 行内的节点)
      if (
        parent === linkNode ||
        parent === null ||
        ($isElementNode(node) && !node.isInline())
      ) {
        return;
      }

      if ($isLinkNode(parent)) {
        // 2️⃣ 情况二:父节点已经是 LinkNode
        // 如果父节点已经是 LinkNode(但是和之前记录的 LinkNode 不相同)
        // 则将记录的 LinkNode 改为该父节点
        linkNode = parent;
        // 并更新这个 LinkNode(即父节点)的 URL 和其他属性
        parent.setURL(url);
        if (target !== undefined) {
          parent.setTarget(target);
        }
        if (rel !== null) {
          linkNode.setRel(rel);
        }
        if (title !== undefined) {
          linkNode.setTitle(title);
        }
        return; // 更新成功后就结束了
      }

      if (!parent.is(prevParent)) {
        // 3️⃣ 情况三:父节点不是 LinkNode,而且与之前的父节点不同
        // 如果当前所遍历节点的父节点和前面记录的父节点不相同,而且这个父节点不是链接节点
        // 则更新记录的父节点为当前所遍历节点的父节点
        prevParent = parent;
        // 同时基于参数创建一个新的链接节点
        linkNode = $createLinkNode(url, {rel, target});

        if ($isLinkNode(parent)) {
          // 前面已经处理了 $isLinkNode(parent) 成立的情况,所以以下的代码并不会执行 ❓
          // 根据该节点前面是否有兄弟节点,来判断新建的 LinkNode 应该放置在父节点的前面还是后面
          if (node.getPreviousSibling() === null) {
            // 如果当前所遍历的节点的前面没有兄弟节点(即它是其父节点的第一个子节点)
            // 则将新建的 LinkNode 链接节点插入到父节点的前面(作为父节点的兄弟节点)
            parent.insertBefore(linkNode);
          } else {
            // 否则将新建的 LinkNode 插入到父节点的后面
            parent.insertAfter(linkNode);
          }
        } else {
          // 如果父节点不是链接节点
          // 那么将新建的 LinkNode 插入到当前所遍历的节点 node 之前(作为兄弟节点)
          node.insertBefore(linkNode);
        }
      }

      if ($isLinkNode(node)) {
        // 4️⃣ 情况四:如果当前所遍历的节点已经是 LinkNode

        if (node.is(linkNode)) {
          // 如果当前所遍历的节点就是目前的链接节点
          // 不进行任何处理
          return;
        }
        if (linkNode !== null) {
          // 如果当前所遍历的节点就是目前的链接节点不同
          // 则将该节点里的所有子节点都「移动到」目前的链接节点 linkNode 里(相当于用链接节点将其包裹)
          const children = node.getChildren();

          for (let i = 0; i < children.length; i++) {
            linkNode.append(children[i]);
          }
        }

        // 最后删除当前所遍历的节点(避免形成嵌套的链接节点)
        node.remove();
        return;
      }

      if (linkNode !== null) {
        // 5️⃣ 情况五:如果当前所遍历的节点不是 LinkNode
        // 将当前所遍历的节点 node 移到目前的链接节点 linkNode 内
        linkNode.append(node);
      }
    });
  }
}

前面所使用的在祖先节点中寻找链接节点的方法 $getLinkAncestor() 具体代码如下

ts
function $getLinkAncestor(node: LexicalNode): null | LexicalNode {
  return $getAncestor(node, $isLinkNode);
}

function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
  node: LexicalNode,
  predicate: (ancestor: LexicalNode) => ancestor is NodeType,
): null | LexicalNode {
  let parent: null | LexicalNode = node;
  // 不断循环往上一级节点寻找目标类型的节点
  // 直至找到目标类型的节点,或父节点 parent 为 null 即达到根节点为止
  while (
    parent !== null &&
    (parent = parent.getParent()) !== null &&
    !predicate(parent)
  );
  return parent;
}
使用场景

以上的方法一般作为自定义指令 command 的处理函数,这样用户就可以通过点击按钮等 UI 控件分发指令 command dispatch 来触发链接节点的切换

该模块已经导出了一个自定义指令 TOGGLE_LINK_COMMAND 可以直接使用

ts
export const TOGGLE_LINK_COMMAND: LexicalCommand<
  string | ({url: string} & LinkAttributes) | null
> = createCommand('TOGGLE_LINK_COMMAND');

Copyright © 2025 Ben

Theme BlogiNote

Icons from Icônes