lexical: Bug: $generateNodesFromDOM does not apply styles properly

When using $generateNodesFromDOM to convert HTML to Lexical, styles such as underline or align are not being applied.

In the case of HTML with styles that changes tag name such as bold(<strong>) or Italic(<em>), the style looks well when converted to lexical using $generateNodesFromDOM.

However, for HTML with styles that only add className(e.g.editor-text-*), such as underline or strikethrough, the styles don’t seem to be applied when converted to lexical.

I referenced the convert code(HTML -> Lexical - lexical doc).

Lexical version: 0.3.5

Steps To Reproduce

(* example HTML string - No style applied to text(STYLE TEST)) <div class="editor-input" contenteditable="true" spellcheck="true" data-lexical-editor="true" style="user-select: text; white-space: pre-wrap; word-break: break-word;" role="textbox"><p class="editor-paragraph ltr" dir="ltr"><span data-lexical-text="true">STYLE TEST</span></p></div>

case 1. bold

  1. Select bold - it is applied to <strong> tag. 스크린샷 2022-06-17 오전 11 05 53
  2. When convert it to lexical based on the html string obtained in step 1 and insert it, the bold style is well as shown below. 스크린샷 2022-06-17 오전 11 14 31

case 2. underline

  1. Select underline - The tagname is not changed(still <span>) and only editor-text-underline is added to the classname. 스크린샷 2022-06-17 오전 11 18 01 The underline is also visible in the editor. 스크린샷 2022-06-17 오전 11 20 30

  2. However, when convert it to lexical based on html obtained in step 1 and insert, converted without underline applied. 스크린샷 2022-06-17 오전 11 21 34

case 3. bold + italic

  1. Select bold and italic. The tagname is <strong>, and italics only apply to editor-text-italic classname. 스크린샷 2022-06-17 오전 11 25 33

  2. However, when convert and insert it, only applied bold(<strong>). (where is the editor-text-italic? 😢 ) 스크린샷 2022-06-17 오전 11 31 41

Considering the above cases, when converting html string to lexical, style or classname is ignored and it seems to be applied only based on tagname.

Link to code example: HTML -> Lexical

const LoadHtmlPlugin = ({htmlString}: Props) => {
  const [editor] = useLexicalComposerContext();
  useEffect(() => {
    editor.update(() => {
      const parser = new DOMParser();
      const dom = parser.parseFromString(htmlString, 'text/html');
      const nodes = $generateNodesFromDOM(editor, dom);

      // Select the root
      $getRoot().select();

      // Insert them at a selection.
      const selection = $getSelection() as RangeSelection;
      selection.insertNodes(nodes);
    });
  }, [editor]);
  return null;
};

The current behavior

$generateNodesFromDOM does not apply styles properly

The expected behavior

If $generateNodesFromDOM is used, the style and className of the dom must be maintained.

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 34
  • Comments: 16 (3 by maintainers)

Most upvoted comments

I ran into similar problems as described here but found a few different fixes and improved a bit on what @byTimo did. First it wasnt clear to me that I had to add styling for the specific theme classes, a lot of patches weren’t necessary for me after that. (Note: I only had issues with underlining) Coloring and background-color weren’t working but I think that is intentional, so instead of patching I extended and override the TextNode like this:

const initialConfig: InitialConfigType = {
    namespace: 'editor',
    theme: editorThemeClasses,
    onError: (error: any) => console.log(error),
    nodes: [
      ExtentedTextNode,
      { replace: TextNode, with: (node: TextNode) => new ExtentedTextNode(node.__text, node.__key) },
      ListNode,
      ListItemNode,   
    ]
  };

the extende node:

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

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

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

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

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

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

  exportJSON(): SerializedTextNode {
    return super.exportJSON();
  }
}

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;

    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
          ]
            .filter((value) => value != null)
            .join('; ');
          if (style.length) {
            return result.setStyle(style);
          }
        }
        return result;
      }
    };
  };
}

This is the canonical way to do this.

I’ve implemented a patch-function that adds missed tag conversion (for Lexical@0.6.3: sub, sup, s) and background-color and color styles transfer from the DOM node to the Lexical node

const createMissedFormatConverter = (format: TextFormatType) => {
    return (): DOMConversionOutput => {
        return {
            forChild: lexicalNode => {
                if ($isTextNode(lexicalNode)) {
                    lexicalNode.toggleFormat(format);
                }

                return lexicalNode;
            },
            node: null,
        };
    };
};

const 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;

        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,
                    ]
                        .filter(isNotNull)
                        .join('; ');
                    if (style.length) {
                        return result.setStyle(style);
                    }
                }
                return result;
            },
        };
    };
};

export function applyHtmlToRichContentPatches() {
    const importers = TextNode.importDOM();
    TextNode.importDOM = function _() {
        return {
            ...importers,
            span: () => ({
                conversion: patchStyleConversion(importers?.span),
                priority: 0,
            }),
            sub: () => ({
                conversion: createMissedFormatConverter('subscript'),
                priority: 0,
            }),
            sup: () => ({
                conversion: createMissedFormatConverter('superscript'),
                priority: 0,
            }),
            s: () => ({
                conversion: createMissedFormatConverter('strikethrough'),
                priority: 0,
            }),
        };
    };

    const missedFormatTag: Array<[TextFormatType, string]> = [
        ['underline', 'u'],
        ['strikethrough', 's'],
    ];

    const exportDOM = TextNode.prototype.exportDOM;
    TextNode.prototype.exportDOM = function _(this: TextNode, editor: LexicalEditor) {
        const { element } = exportDOM.apply(this, [editor]);
        if (!element) {
            return { element };
        }

        let wrapped = element;

        for (const [format, tag] of missedFormatTag) {
            if ($hasFormat(this, format)) {
                const wrapper = document.createElement(tag);
                wrapper.appendChild(element);
                wrapped = wrapper;
            }
        }

        return { element: wrapped };
    };
}

UPD After some investigation I figured out that the way of determining of applied formats for the node wasn’t correct for each cases, so I found the another way and updated the code snippet above using this

export function $hasFormat(node: TextNode, format: TextFormatType): boolean {
    const currentFormat = node.getFormat();
    return node.getFormatFlags(format, null) < currentFormat;
}

I ran into similar problems as described here but found a few different fixes and improved a bit on what @byTimo did. First it wasnt clear to me that I had to add styling for the specific theme classes, a lot of patches weren’t necessary for me after that. (Note: I only had issues with underlining) Coloring and background-color weren’t working but I think that is intentional, so instead of patching I extended and override the TextNode like this:

const initialConfig: InitialConfigType = {
    namespace: 'editor',
    theme: editorThemeClasses,
    onError: (error: any) => console.log(error),
    nodes: [
      ExtentedTextNode,
      { replace: TextNode, with: (node: TextNode) => new ExtentedTextNode(node.__text, node.__key) },
      ListNode,
      ListItemNode,   
    ]
  };

the extende node:

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

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

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

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

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

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

  exportJSON(): SerializedTextNode {
    return super.exportJSON();
  }
}

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;

    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
          ]
            .filter((value) => value != null)
            .join('; ');
          if (style.length) {
            return result.setStyle(style);
          }
        }
        return result;
      }
    };
  };
}

I believe I have the same issue, and I do not believe it is just a styling concern.

It seems that $generateHtmlFromNodes correctly applies style classes to the HTML, for me it is something like this (snippet):

<span class="textUnderline">aaaa</span>

However as far as i can tell $generateNodesFromDOM does not have any logic to apply this in reverse (take the class and generate an underline node). So in my case where I am saving the output of $generateHtmlFromNodes and then reloading it via $generateNodesFromDOM I lose some subset of my styling on reload

So I think what you are looking for is exportDOM/importDOM, see here: https://lexical.dev/docs/concepts/serialization#lexicalnodeexportdom

For built in nodes I don’t think think there is a great way to do this besides monkey patching, maybe I’m wrong, see this issue here: https://github.com/facebook/lexical/issues/1262

This is what I did for exportDOM for the build in TextNode:

const exportDOM = TextNode.prototype.exportDOM
TextNode.prototype.exportDOM = function (editor: LexicalEditor) {
  if (this.__format & IS_UNDERLINE) {
    const dom = document.createElement("u")
    dom.textContent = this.__text
    const maybeUnderline: string | string[] | undefined = editor._config.theme.text?.["underline"]
    if (maybeUnderline) {
      dom.className = Array.isArray(maybeUnderline) ? maybeUnderline.join(" ") : maybeUnderline
    }
    return { element: dom }
  } else {
    return exportDOM.apply(this, [editor])
  }
}

For my use case importDOM is already set up to handle u elements, it was just not exporting that way, but not sure of your use case.

Has anybody figured out any work around for this? I’m running into the same issue where most of my styles are getting dropped from the conversion from HTML back into lexical nodes.

@byTimo I’ve a query regarding these patches, where do we have to include them? Because currently when I’m implementing directly these patches into my code I’m getting attached error

Actually these patches are just some kind of global monkey-patches, so you can call applyHtmlToRichContentPatches somewhere in the module where your rich editor component is declared.

In my case, the patch is being called in the file with the Lexical editor configuration, but you can call it something like this

src/path/to/your/component/filename.ts

import { LexicalComposer } from '@lexical/react/LexicalComposer';
import {applyHtmlToRichContentPatches) from 'path/to/patch';
....

applyHtmlToRichContentPatches();

const config = {
    namespace: 'richEditor',
    editable: true,
};

...

export const RichEditor = () => {
    ...
    return (
        <LexicalComposer initialConfig={config}>
            ...
        </LexicalComposer>
    )
}

BTW, I’ve updated the code snippet above a bit. Please, take a look if you’re using this.

Is it just me or is IS_UNDERLINE only exported from the dev build and not the prod build? Lexical ships with a LexicalConstants.d.ts file but no matching module. Storybook ends up complaining that it can’t find lexical/LexicalConstants, and running rg IS_UNDERLINE node_modules/lexical returns the following:

Lexical.dev.js:const IS_UNDERLINE = 1 << 3;
Lexical.dev.js:const IS_ALL_FORMATTING = IS_BOLD | IS_ITALIC | IS_STRIKETHROUGH | IS_UNDERLINE | IS_CODE | IS_SUBSCRIPT | IS_SUPERSCRIPT; // Text node details
Lexical.dev.js:  underline: IS_UNDERLINE
Lexical.dev.js:  const prevUnderlineStrikethrough = prevFormat & IS_UNDERLINE && prevFormat & IS_STRIKETHROUGH;
Lexical.dev.js:  const nextUnderlineStrikethrough = nextFormat & IS_UNDERLINE && nextFormat & IS_STRIKETHROUGH;
LexicalConstants.d.ts:export declare const IS_UNDERLINE: number;

It looks like the solution from @pbrown-iris-construction works as long as I define const IS_UNDERLINE = 1 << 3. It would be great to see Lexical support exporting underlines in HTML as a core feature.

I think we are supposed to write styles for those classes.(I wrote styles for when I used this) It helps to apply multiple formatting to same text? instead of wrapping them inside different tags. In case you wanted properties being used inside the lexical playground here it is

I am facing styling issues as well.

I have styles applied to all the elements except img element.

When I resize the image in the editor and try to render the HTML generated by $generateHtmlFromNodes, it overflows in x direction and none of the width, height are applied. I can see the values in editorState json but I don’t see that coveted in the HTML.

I tried upgrading to the latest version. It didn’t fix. I have version 0.10.0 right now.

Please help!

Edit: NVM.

I just had to make the following update in ImageNode.tsx

  exportDOM(): DOMExportOutput {
    const element = document.createElement('img');
    element.setAttribute('src', this.__src);
    element.setAttribute('alt', this.__altText);
+    element.setAttribute('width', this.__width.toString());
+    element.setAttribute('height', this.__height.toString());

    return { element };
  }

I resolved this issue by saving the Editor State instead of $generateNodesFromDOM and then using setEditorState to load the editor.

The editorState contains all the styles such as the formatting and alignments, so there is no issue that the style is missing as before!

  • Save editorState as getEditorState.
  • Create a new custom plugin. Load the editor from within useEffect hook, and set editorState by setEditorState.
// How to load current editor
const [editor] = useLexicalComposerContext();
useEffect(() => {
   // ...
  editor.setEditorState(YOUR_SAVED_EDITOR_STATE);
}, [editor]);