You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

自定义ExtendedTextNode与AutoLinkPlugin兼容问题咨询

问题描述

我自定义了ExtendedTextNode,但AutoLinkPlugin无法与其正常协作。调试后发现Transform不识别节点替换操作,我通过复制TextNode的key让功能恢复了,但不确定这种做法是否合理。想咨询三个问题:

  • 是否应该复制节点key?
  • 是否存在可让Transform支持自定义节点的配置?
  • 是否需要编写自定义AutoLinkPlugin?

代码示例

自定义ExtendedTextNode

// Custom Node 
// 参考Lexical官方文档扩展HTML样式处理

import {
  $isTextNode,
  DOMConversion,
  DOMConversionMap,
  DOMConversionOutput,
  NodeKey,
  TextNode,
  SerializedTextNode,
  LexicalNode
} from 'lexical';

export class ExtendedTextNode extends TextNode {
  constructor(text: string, key?: NodeKey) {
    super(text, key);
  }

  static getType(): string {
    return 'extended-text';
  }

  static clone(node: ExtendedTextNode): ExtendedTextNode {
    return new ExtendedTextNode(node.__text, node.__key);
  }

  static importDOM(): DOMConversionMap | null {
    const importers = TextNode.importDOM();
    return {
      ...importers,
      code: () => ({
        conversion: patchStyleConversion(importers?.code),
        priority: 1
      }),
      em: () => ({
        conversion: patchStyleConversion(importers?.em),
        priority: 1
      }),
      span: () => ({
        conversion: patchStyleConversion(importers?.span),
        priority: 1
      }),
      strong: () => ({
        conversion: patchStyleConversion(importers?.strong),
        priority: 1
      }),
      sub: () => ({
        conversion: patchStyleConversion(importers?.sub),
        priority: 1
      }),
      sup: () => ({
        conversion: patchStyleConversion(importers?.sup),
        priority: 1
      }),
    };
  }

  static importJSON(serializedNode: SerializedTextNode): TextNode {
    return TextNode.importJSON(serializedNode);
  }

  isSimpleText() {
    return (
      (this.__type === 'text' || this.__type === 'extended-text') &&
      this.__mode === 0
    );
  }

  exportJSON(): SerializedTextNode {
    return {
      ...super.exportJSON(),
      type: 'extended-text',
      version: 1,
    }
  }
}

export function $createExtendedTextNode(text: string): ExtendedTextNode {
    return new ExtendedTextNode(text);
}

export function $isExtendedTextNode(node: LexicalNode | null | undefined): node is ExtendedTextNode {
    return node instanceof ExtendedTextNode;
}

function patchStyleConversion(
  originalDOMConverter?: (node: HTMLElement) => DOMConversion | null
): (node: HTMLElement) => DOMConversionOutput | null {
  return (node) => {
    const original = originalDOMConverter?.(node);
    if (!original) {
      return null;
    }
    const originalOutput = original.conversion(node);

    if (!originalOutput) {
      return originalOutput;
    }

    const backgroundColor = node.style.backgroundColor;
    const color = node.style.color;
    const fontFamily = node.style.fontFamily;
    const fontSize = node.style.fontSize;

    return {
      ...originalOutput,
      forChild: (lexicalNode, parent) => {
        const originalForChild = originalOutput?.forChild ?? ((x) => x);
        const result = originalForChild(lexicalNode, parent);
        if ($isTextNode(result)) {
          const style = [
            backgroundColor ? `background-color: ${backgroundColor}` : null,
            color ? `color: ${color}` : null,
            fontFamily ? `font-family: ${fontFamily}` : null,
            fontSize ? `font-size: ${fontSize}` : null,
          ]
            .filter((value) => value != null)
            .join('; ');
          if (style.length) {
            return result.setStyle(style);
          }
        }
        return result;
      }
    };
  };
}

编辑器初始化代码

// Editor init
export function EditorLexicalView({selectedFile} : Props) {
  const { ref: toolbarRef, height: toolbarHeight = 1 } = useResizeObserver<HTMLDivElement>({box:'border-box'}); 

  const initialConfig = {
    namespace: 'MyEditor',
    theme: EditorTheme,
    onError,
    nodes: [
      ExtendedTextNode,
      { replace: TextNode, with: (node: TextNode) => new ExtendedTextNode(node.__text, node.__key) },
      ListNode,
      ListItemNode,
      LinkNode,
      AutoLinkNode
    ]
  };
  const urlRegExp = new RegExp(
    /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;;%@.\w_]*)#?(?:[\w]*))?)/,
 );
 function validateUrl(url: string): boolean {
    return url === 'https://' || urlRegExp.test(url);
 }

 const URL_REGEX =
 /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/;

const EMAIL_REGEX =
 /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/;

const MATCHERS = [
 createLinkMatcherWithRegExp(URL_REGEX, (text) => {
    console.trace()
    return text;
 }),
 createLinkMatcherWithRegExp(EMAIL_REGEX, (text) => {
    return `mailto:${text}`;
 }),
];

  return (
    <LexicalComposer initialConfig={initialConfig}>
        <div className='editor-container'>
            <ToolbarPlugin ref={toolbarRef}/>
                    <div className='editor-inner' style={{height: `calc(100% - ${toolbarHeight}px)`}}>
                <RichTextPlugin
                contentEditable={<ContentEditable className="editor-input section-to-print" spellCheck={false}/>}
                placeholder={null}
                ErrorBoundary={LexicalErrorBoundary}
                />
                </div>
        </div>
        <TestPlugin selectedFile={selectedFile}/>
        <HistoryPlugin />
        <AutoFocusPlugin />
        <RegisterCustomCommands />
        <LinkPlugin validateUrl={validateUrl}/>
        <AutoLinkPlugin matchers={MATCHERS}/>
    </LexicalComposer>
  );
}

解答

1. 是否应该复制节点key?

可以临时用,但不是长期最优方案:

  • 节点key是Lexical中节点的唯一标识,复制原TextNode的key能让Transform操作(比如AutoLink的文本替换)识别新节点是原节点的替换体,避免因key不一致导致的操作失效。
  • 仅在一对一替换原节点的场景下安全(比如你配置的replace: TextNode逻辑),如果是创建全新节点,不要手动指定key,让Lexical自动生成,避免key冲突。

2. 是否存在让Transform支持自定义节点的配置?

有两个更合理的方案,无需依赖复制key:

方案一:让自定义节点兼容文本节点判断

AutoLinkPlugin的Transform逻辑依赖$isTextNode识别文本节点,默认只匹配type为text的节点。你可以:

  • 确保$isExtendedTextNode和$isTextNode的判断兼容,比如在全局扩展$isTextNode的逻辑,或者让自定义节点的isSimpleText方法返回符合TextNode的特征(你当前的代码已经做了这一步)。
  • 修复importJSON方法,当前返回的是TextNode,应该返回ExtendedTextNode,否则序列化后再导入会变回TextNode:
    static importJSON(serializedNode: SerializedTextNode): ExtendedTextNode {
      const node = $createExtendedTextNode(serializedNode.text);
      node.setStyle(serializedNode.style || '');
      node.setMode(serializedNode.mode || 0);
      node.setFormat(serializedNode.format || 0);
      return node;
    }
    

方案二:自定义Transform的节点匹配逻辑

AutoLinkPlugin的核心是$createAutoLinkTransform,默认只处理TextNode。你可以修改这个Transform,让它同时支持ExtendedTextNode:

import { $createAutoLinkNode, $isTextNode, TRANSFORM_PRIORITY_EDITOR, $createNodeTransform } from 'lexical';
import { $isExtendedTextNode } from './ExtendedTextNode';

function $createCustomAutoLinkTransform(matchers) {
  return () => {
    return $createNodeTransform(
      (node) => {
        // 同时匹配TextNode和ExtendedTextNode
        if (!$isTextNode(node) && !$isExtendedTextNode(node)) return node;
        
        // 复制原AutoLinkTransform的核心逻辑
        const text = node.getTextContent();
        for (const matcher of matchers) {
          const matches = text.match(matcher.regex);
          if (matches) {
            // 替换为AutoLinkNode的逻辑...
          }
        }
        return node;
      },
      TRANSFORM_PRIORITY_EDITOR,
    );
  };
}

然后在插件中使用这个自定义Transform,替代默认逻辑。

3. 是否需要编写自定义AutoLinkPlugin?

不需要完全重写,更高效的方式是扩展现有插件逻辑:

  • 用上述方案二的自定义Transform替换AutoLinkPlugin内部的Transform即可,无需重写整个插件。
  • 或者通过包装原AutoLinkPlugin的方式,注入自定义的节点匹配逻辑。

内容的提问来源于stack exchange,提问作者Derag

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.24 10:24:54