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

Feature: serialize EditorState to HTML in read-only mode #2587

Closed med8bra closed 1 year ago

med8bra commented 1 year ago

I'm storing EditorState as the value for a RichTextInput (controlled input), and I want on form submission to:

Currently, to generate HTML from Lexical, I need to use Editor. update with $generateHtmlfromNodes, which has some issues:

Pleasure to contribute code

I'm open to contributing to this project if this feature is a valid use case.

trueadm commented 1 year ago

Editor is needed due to the fact HTML needs the EditorConfig to apply theming. See https://github.com/facebook/lexical/issues/2586 for some background and why it had to be closed.

You can always create a detached editor to do the rendering, or a headless editor and do the work on the server. Or simply store the HTML in state along with the editor state when you save the editor state.

The reason you can’t use the helper function in read only mode is exactly the same problem as the first above. In read only mode, Lexical doesn’t know if any active editor. It’s an editor to get the theme config to apply to the HTML elements.

med8bra commented 1 year ago

Yes, I understood that part @trueadm. But as you said we only need EditorConfig, not an Editor or an active Editor.

My use case is to generate HTML at form submission outside the editor component (don't want to pass the editor instance to the outer scope). Generating HTML every time is overkill.

If the $generateHtmlFromNodes requires an Editor just to get theme config, why not accept directly an EditorConfig as a parameter?

A LexicalNode has createDom(EditorConfig) which means, a node needs just EditorConfig to generate its DOM representation.

trueadm commented 1 year ago

@med8bra Well unfortunately, the exportDOM(editor: LexicalEditor) method also requires passing of editor too, as the editor is used to initialize nested editor state – for example image captions. So we making such a big breaking change would still require an editor to be available.

med8bra commented 1 year ago

@trueadm Indeed, exportDOM is taking an EditorInstance, but I just did a search in the repository and I cannot find a case where the editor was useful to generating DOM, except:

Sorry, I didn't understand the use case for image captions, won't an ImageNode have the image caption in its state?


My FIndings


Proposed solution

To benefit from the two strategies, maybe we should define two different use cases:

Let me know what is your take on this

trueadm commented 1 year ago

One workaround could be to expose a new core helper from lexical called $getEditor() which returns null | EditorInstance. Then Excalidraw could use that instead and we wouldn’t need to pass it to the html function? Did you want to take a look at making that change in a PR?

med8bra commented 1 year ago

That would be a good workaround for now, The only issue I see is that if $getEditor returns null, we will be putting more responsibility on LexicalNode.exportDOM, as it should handle both cases.

Actually, I just read the ExcalidrawNode implementation, it's not using the rendered canvas, but using an utility method to convert Excalidraw data to SVG. So it will be the same performance cost without an editor instance.

But I was thinking maybe we could have different modes:

I don't want to rush into writing a PR, but if we come up with a better approach I'll be pleased to implement it. Thanks for your time @trueadm

med8bra commented 1 year ago

So my proposition is

norregaarden-scalgo commented 1 year ago

What's the status on this? Have you started a PR, @med8bra? I'd like to run "$generateHtmlFromNodes(editor, null)" (inside "editor.registerUpdateListener") whenever the user has not typed for N milliseconds, in order to send the content to a server, but the trouble is, I enable read-only mode (to remove red lines from chromes spelling check, etc.) when the user clicks outside the box. Or is there another way to export the content for syncing with a server while in read-only mode? Or should I rather do a hackish solution, listening on read-only state change and from there add/remove the other listener?

med8bra commented 1 year ago

@norregaarden-scalgo I'm waiting for a confirmation from @trueadm to start on my proposition.

For your case, using LexicalOnChangePlugin with a debounced onChange callback will be sufficient.

norregaarden-scalgo commented 1 year ago

@norregaarden-scalgo I'm waiting for a confirmation from @trueadm to start on my proposition.

For your case, using LexicalOnChangePlugin with a debounced onChange callback will be sufficient.

@med8bra thanks you for your suggestion, but the same error pertains using OnChangePlugin from @lexical/react/LexicalOnChangePlugin, i.e. $generateHtmlFromNodes can only be called from editor.read or editor.update, and if I wrap it in that, it says " Cannot use method in read-only mode." even when I'm, not in read-only mode, if the editor has been in read-only mode at some point.

kuus commented 1 year ago

is there any working temporary workaround to get an HTML string on change with $generateHtmlFromNodes? When the JSON serialization is not an option it seems a quite basic behaviour in order to save the user entered HTML and let them update it later on. thanks!

trueadm commented 1 year ago

@kuus Why can't you use $generateHtmlFromNodes. That is the correct way to do what you're asking. You can do it in an update listener or using the OnChangePlugin as mentioned above.

$generateHtmlFromNodes can only be called from editor.read or editor.update, and if I wrap it in that, it says " Cannot use method in read-only mode." even when I'm, not in read-only mode, if the editor has been in read-only mode at some point.

Make sure you call $generateHtmlFromNodes from an editor.update, if you are doing so, please can you share a sandbox with a repro case? It's hard to understand what is going wrong otherwise.

kuus commented 1 year ago

Hi @trueadm , thanks for the quick reply, I was trying to do it from editorState.read() as such:

https://codesandbox.io/s/lexical-on-change-jervjw

import { useCallback, useEffect } from "react";
import { $getRoot, $getSelection, type EditorState } from "lexical";
import { $generateNodesFromDOM, $generateHtmlFromNodes } from "@lexical/html";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";

type HTMLEditorContainerProps = {
  defaultValue?: string;
};

export const HTMLEditorContainer = (props: HTMLEditorContainerProps) => {
  const { defaultValue } = props;
  const [editor] = useLexicalComposerContext();

  const onChange = useCallback(
    (editorState: EditorState) => {
      editorState.read(() => {
        const selection = $getSelection();
        const html = $generateHtmlFromNodes(editor, selection);
        // use `onChange` from props
        console.log("onChange html", html); // gives error Cannot use method in read-only mode.
      });
    },
    [editor]
  );

  // set defaultValue
  useEffect(() => {
    if (!defaultValue || !editor) {
      return;
    }

    editor.update(() => {
      const parser = new DOMParser();
      const dom = parser.parseFromString(defaultValue, "text/html");
      const nodes = $generateNodesFromDOM(editor, dom);
      $getRoot().select();
      const selection = $getSelection();
      // @ts-expect-error not sure...
      selection.insertNodes(nodes);
    });
  }, [editor]);

  return (
    <>
      <RichTextPlugin
        contentEditable={<ContentEditable />}
        placeholder="Placeholder..."
      />
      <OnChangePlugin
        onChange={onChange}
        ignoreHistoryMergeTagChange
        ignoreInitialChange
        ignoreSelectionChange
      />
    </>
  );
};

and that was giving me the error Cannot use method in read-only mode.

If I do it from editor.update it works instead:

https://codesandbox.io/s/lexical-on-change-iqexeo

import { useEffect } from "react";
import { $getRoot, $getSelection } from "lexical";
import { $generateNodesFromDOM, $generateHtmlFromNodes } from "@lexical/html";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";

type HTMLEditorContainerProps = {
  defaultValue?: string;
};

export const HTMLEditorContainer = (props: HTMLEditorContainerProps) => {
  const { defaultValue } = props;
  const [editor] = useLexicalComposerContext();

  // set defaultValue and register onChange
  useEffect(() => {
    if (!defaultValue || !editor) {
      return;
    }

    editor.registerUpdateListener(() => {
      editor.update(() => {
        const html = $generateHtmlFromNodes(editor, null);
        // use `onChange` from props
        console.log("onChange html", html);
      })
    });

    editor.update(() => {
      const parser = new DOMParser();
      const dom = parser.parseFromString(defaultValue, "text/html");
      const nodes = $generateNodesFromDOM(editor, dom);
      $getRoot().select();
      const selection = $getSelection();
      // @ts-expect-error not sure...
      selection.insertNodes(nodes);
    });
  }, [editor]);

  return (
    <>
      <RichTextPlugin
        contentEditable={<ContentEditable />}
        placeholder="Placeholder..."
      />
    </>
  );
};

is this latter the right way to do it?

kuus commented 1 year ago

a bit unrelated (if you like I can open an issue or ask on SO), is it possible to strip the classNames on the resulted HTML string when using $generateNodesFromDOM @trueadm ?

trueadm commented 1 year ago

@kuus There's no built in way to do so, your best bet is to apply a query selector on all the nodes and manually do element.removeAttribute('class'). As for using editor.update, that should be fine :)

kuus commented 1 year ago

@kuus There's no built in way to do so, your best bet is to apply a query selector on all the nodes and manually do element.removeAttribute('class'). As for using editor.update, that should be fine :)

@trueadm For now I was able to accomplish that by using a kind of dummy headless editor whose state is in sync with the "real" one, the dummy editor has no theme applied to it so no html classes and the onChange is bound to that. It works, but do you think is that a good approach?

trueadm commented 1 year ago

That's a neat trick too!

animeshchat commented 1 year ago

@trueadm

I'm storing the json state. I'd like to parse this stored state and send an email (as HTML) on the server?

Right now - my best bet is to use a NodeJS instance on the server to transform JSON to HTML using $generateNodesFromDOM.

There seems to be no way to generate HTML from JSON with zero dependecies. Is this a valid use case for lexical to support?

trueadm commented 1 year ago

Generating HTML is contextual for now, so it needs an editor instance to get the node classes from. There’s no way around that

thegreatercurve commented 1 year ago

Just to add to this, we still require an editor instance, but the fundamental issue of converting editor state to HTML in non-editable mode has been fixed. You can call editor.getEditorState().read(() => $generateHtmlFromNodes(editor, null)) with editable: false and it should work fine - no need to call editor.update any more.

thegreatercurve commented 1 year ago

Closing this as the original bug has been fixed, and we don't have any immediate plans to separate the editor instance from converting JSON state to HTML. That would require rewriting a lot of internal editor logic.

erikmartinessanches commented 4 months ago

@kuus There's no built in way to do so, your best bet is to apply a query selector on all the nodes and manually do element.removeAttribute('class'). As for using editor.update, that should be fine :)

@trueadm For now I was able to accomplish that by using a kind of dummy headless editor whose state is in sync with the "real" one, the dummy editor has no theme applied to it so no html classes and the onChange is bound to that. It works, but do you think is that a good approach?

Are you able to use html classes from the original editor it is bound to, with that setup?

kuus commented 4 months ago

@kuus There's no built in way to do so, your best bet is to apply a query selector on all the nodes and manually do element.removeAttribute('class'). As for using editor.update, that should be fine :)

@trueadm For now I was able to accomplish that by using a kind of dummy headless editor whose state is in sync with the "real" one, the dummy editor has no theme applied to it so no html classes and the onChange is bound to that. It works, but do you think is that a good approach?

Are you able to use html classes from the original editor it is bound to, with that setup?

hi @erikmartinessanches yes html classes are used in the editor shown to the user. I was looking to strip them out from what saved to the database in the "dummy headless editor" because I only needed to save basic html tags like b,i,em,strong, etc., so there was no need to store html classes too in the database.