自定义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
相关产品推荐
相关产品推荐

