I have custom TextNode but AutoLinkPlugin doesn't work with it. After a bit of debugging it looks like Transform doesn't respect nodes replacement. I managed to get it working by copying key of TextNode when creating my custom node. But documentation is a bit lacking on this so I don't know if this is a good idea. So I wonder, should I copy key? Am I missing some setting that lets Transforms work with custom nodes? Or should I write custom AutoLinkPlugin?
Here are code samples:
// Custom Node // from https://lexical.dev/docs/concepts/serialization#handling-extended-html-styling 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> ); }