链接节点
Lexical 推出了一个模块包 @lexical/link 提供了一个自定义节点 LinkNode 链接节点
参考
- lexical-link - Github
- @lexical/link - documentation
该模块自定义了两类链接节点 LineNode 和 AutoLinkNode:
LineNode普通的链接节点AutoLinkNode自动转换的链接节点,针对直接在编辑器中输入 URL 字符串,自动转换为链接节点的场景
所支持的链接协议如下,即链接节点所对应的 URL 需要采用以下 protocol
const SUPPORTED_URL_PROTOCOLS = new Set([
'http:',
'https:',
'mailto:',
'sms:',
'tel:',
]);
LinkNode
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() 的具体代码如下
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 的具体代码如下
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() 的具体代码如下
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 是否为链接节点的方法
//
/**
* 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
// 该自定义节点的作用是覆写 `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() 的具体代码如下
/**
* 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 是否为自动链接节点的方法
/**
* 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,则将取消原有的链接节点
/**
* 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() 具体代码如下
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 可以直接使用
export const TOGGLE_LINK_COMMAND: LexicalCommand<
string | ({url: string} & LinkAttributes) | null
> = createCommand('TOGGLE_LINK_COMMAND');