Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug: Custom HTML serialization config for createEditor doesn't work #5262

Closed
GRA0007 opened this issue Nov 21, 2023 · 6 comments
Closed

Bug: Custom HTML serialization config for createEditor doesn't work #5262

GRA0007 opened this issue Nov 21, 2023 · 6 comments

Comments

@GRA0007
Copy link

GRA0007 commented Nov 21, 2023

I'm trying to implement a simple image DecoratorNode that has a caption property which supports rich text (just bold, italics and links), and I'm using the LexicalNestedComposer component from @lexical/react inside my DecoratorNode to create this caption editor.

My editor needs to export HTML for this image in the following format:

<figure>
  <img src="#" />
  <figcaption>Rich <em>caption</em> here</figcaption>
</figure>

Importantly, I'm also using the new HTML serialization overrides in the editor config introduced in version 0.12.3/0.12.4, in order to remove span elements from text nodes and <i> elements from italic text etc.

My first issue, is that there doesn't seem to be a nice way to serialize nested editors to HTML. My current implementation looks something like this:

export class ImageNode extends DecoratorNode<ReactElement> {
  __src: string
  __caption: LexicalEditor

  static getType() {
    return 'image'
  }

  static clone(node: ImageNode): ImageNode {
    return new ImageNode(node.__src, node.__caption, node.__key)
  }

  constructor(src: string, caption?: LexicalEditor, key?: NodeKey) {
    super(key)
    this.__src = src
    this.__caption =
      caption ||
      createEditor({
        html: htmlSerializationConfig,
      })
  }

  createDOM(config: EditorConfig): HTMLElement {
    const element = document.createElement('figure')
    element.className = config.theme.image
    return element
  }

  updateDOM(): false {
    return false
  }

  decorate(): ReactElement {
    return (
      <Suspense fallback={null}>
        <img src={this.__src} />
        <LexicalNestedComposer initialEditor={this.__caption}>
          <div className="relative">
            <PlainTextPlugin
              contentEditable={<ContentEditable />}
              ErrorBoundary={LexicalErrorBoundary}
            />
          </div>
        </LexicalNestedComposer>
      </Suspense>
    )
  }

  static importDOM(): DOMConversionMap {
    return {
      figure: () => ({
        conversion: element => {
          const src = element.querySelector('img')?.getAttribute('src')
          const caption = element.querySelector('figcaption')?.innerHTML
          if (!src) return null
        
          const node = $createImageNode(src)
        
          // Take the caption from the HTML figcaption element and insert into
          // the nested editor, wrapping in a paragraph because TextNodes cannot
          // be inserted into the root node
          if (caption) {
            const nestedEditor = node.__caption
            nestedEditor.update(() => {
              const parser = new DOMParser()
              const dom = parser.parseFromString(`<p>${caption}</p>`, 'text/html')
              const nodes = $generateNodesFromDOM(nestedEditor, dom)
              const root = $getRoot()
              root.append(...nodes)
            })
          }
        
          return { node }
        },
        priority: 3,
      })
    }
  }
  
  exportDOM(): DOMExportOutput {
    const img = document.createElement('img')
    img.src = this.__src
  
    const caption = document.createElement('figcaption')
    const captionState = this.__caption.getEditorState()
    captionState.read(() => {
      const parser = new DOMParser()
      const dom = parser.parseFromString(
        $generateHtmlFromNodes(this.__caption),
        'text/html',
      )
      caption.append(...(dom.body.firstChild?.childNodes ?? []))
    })
  
    const element = document.createElement('figure')
    element.append(img, caption)
  
    return { element }
  }

  isInline() {
    return false
  }
}

Note that in the exportDOM function above, I have to re-parse the output of $generateHtmlFromNodes because this function doesn't provide a way of getting the output as an element/fragment. This is my first issue, but workaround-able.

The second issue, is that the createEditor function I'm using in my constructor, doesn't seem to use the config I pass to it at all. For simplicity, this is essentially the config I'm using:

const htmlSerializationConfig: HTMLConfig = {
  export: new Map([
    [TextNode, (_, node) => {
      if (!$isTextNode(node) || !node.__text) return { element: null }
  
      let element: Text | HTMLElement = document.createTextNode(node.__text)
  
      if (node.hasFormat('bold')) {
        element = wrapElementWith(element, 'strong')
      }
      if (node.hasFormat('italic')) {
        element = wrapElementWith(element, 'em')
      }
  
      return { element }
    }]
  ])
}

From this, the output I would expect would be something like Rich <em>caption</em> here, but what I actually end up with is <span style="white-space: pre-wrap;">Rich </span><i><em class="italic" style="white-space: pre-wrap;">caption</em></i><span style="white-space: pre-wrap;"> here</span>, and that's after manually removing the surrounding p tag by specifically selecting the children like so in my exportDOM function above: dom.body.firstChild?.childNodes.

It feels like there should be a better way of doing this. I've noticed that DecoratorNodes cannot have children, should I be using a different type of node to make my ImageNode? Or has the use case of actually exporting the nested editors not yet been explored? It certainly doesn't feel very ergonomic in its current form.

Lexical version: 0.12.4

Sandbox

https://codesandbox.io/s/lexical-nested-editor-export-repro-zmk4gz

Please try pressing the Export DOM button in the sandbox above and note that the caption is not properly transformed like the text outside the nested editor.

@acywatson
Copy link
Contributor

Or has the use case of actually exporting the nested editors not yet been explored?

This is more the case - nested editors weren't top of mind when this was written, iirc. Agree that this could and should be more ergonomic.

The second issue, is that the createEditor function I'm using in my constructor, doesn't seem to use the config I pass to it at all. For simplicity, this is essentially the config I'm using:

This is definitely a bug - let me see if I can repro locally.

@acywatson
Copy link
Contributor

acywatson commented Nov 22, 2023

#5267

You'll need this, plus pass in initialNodes like this:

<LexicalNestedComposer initialEditor={caption} initialNodes={[TextNode, EmojiNode, KeywordNode, LinkNode, MentionNode, HashtagNode, ParagraphNode]}>

@JorgeAtPaladin
Copy link

@acywatson , is it correct that @GRA0007's component is also not compatible with the yjs collab plugin?

We've so far failed to sync nested composer's state with the collab plugin :-(

@acywatson
Copy link
Contributor

We've so far failed to sync nested composer's state with the collab plugin :-(

Yea the current CollabPlugin frankly does not have great support for nested editors.

@JorgeAtPaladin
Copy link

Unfortunate for sure! But I understand your priorities lie with what's crucial to facebook 👍 We'll wait for this to hopefully be resolved one day and look for an alternative in the time being.

@etrepum
Copy link
Collaborator

etrepum commented Dec 1, 2024

I think generally this problem is fixed, or never really existed in the first place, at least as stated in the title (Custom HTML serialization for createEditor doesn't work). There's more test coverage for this functionality now anyway, and you can pass the editor argument to EditorState.read now which may be useful for scenarios like this.

Regarding other requests (e.g. a version of $generateHTMLFromNodes that returns a DOM node rather than an HTML string), I think those would be best handled in separate feature request issue(s).

@etrepum etrepum closed this as completed Dec 1, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants