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>
);
}
Ok, I have figure it out.
this:
{ replace: TextNode, with: (node: TextNode) => new ExtendedTextNode(node.__text, node.__key) },
also needs
, withKlass: ExtendedTextNode