facebook / lexical

Lexical is an extensible text editor framework that provides excellent reliability, accessibility and performance.
https://lexical.dev
MIT License
17.5k stars 1.45k forks source link

Bug: $generateNodesFromDOM does not apply styles properly #2452

Closed ChanhuiSeok closed 6 months ago

ChanhuiSeok commented 1 year ago

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.

harish-sethuraman commented 1 year ago

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

patches11 commented 1 year ago

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

kevin-jenson commented 1 year ago

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.

pbrown-iris-construction commented 1 year ago

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.

ChanhuiSeok commented 1 year ago

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!

// How to load current editor
const [editor] = useLexicalComposerContext();
useEffect(() => {
   // ...
  editor.setEditorState(YOUR_SAVED_EDITOR_STATE);
}, [editor]);
knpwrs commented 1 year ago

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.

BanannaLopal commented 1 year ago

@pbrown-iris-construction do u have a solution for 'editor-text-strikethrough' and text alignment?

byTimo commented 1 year ago

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;
}
rahulgoyal8312 commented 1 year ago

@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

Screenshot 2022-12-14 at 10 35 29 PM
byTimo commented 1 year ago

@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.

GabrielOrdonesDuarte commented 1 year ago
const [editor] = useLexicalComposerContext();
useEffect(() => {
   // ...
  editor.setEditorState(YOUR_SAVED_EDITOR_STATE);
}, [editor]);

Hello @ChanhuiSeok! Can you give me a detailed example? I'm having the same problem but didn't found a solution yet.

Thanks!

matthiasmeerhof commented 1 year ago

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;
      }
    };
  };
}
tejas-hosamani commented 1 year ago

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 };
  }
acywatson commented 1 year ago

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.

wangkekekexili commented 6 months ago

https://github.com/facebook/lexical/issues/2452#issuecomment-1435638298 This works well. A small note that we may need to handle em and strong in addition to span because of https://github.com/facebook/lexical/blob/74471ab9cfca443d3ba15fefbe487cd1d4a24049/packages/lexical/src/nodes/LexicalTextNode.ts#L118-L126

ivailop7 commented 6 months ago

https://github.com/facebook/lexical/issues/2452#issuecomment-1435638298 This works well. A small note that we may need to handle em and strong in addition to span because of https://github.com/facebook/lexical/blob/74471ab9cfca443d3ba15fefbe487cd1d4a24049/packages/lexical/src/nodes/LexicalTextNode.ts#L118-L126

strong and em are handled in the docs example: https://lexical.dev/docs/concepts/serialization#handling-extended-html-styling