Finnian's blog

Software Engineer based in New Zealand

Integrating Facebook's new WYSIWYG editor 'Lexical' with ActionText

Lexical works out of the box with ActionText, but it requires a little setup.

3-Minute Read

I spent today getting Facebook’s new WYSIWYG editor “Lexical” working with Rails & ActionText. The process was fairly straightforward, but I ran into some issues along the way which I wanted to document.

First off, there’s a live demo here: https://lexical-actiontext-demo.onrender.com/

The demo uses bog-standard Rails with esbuild and simply swaps out the de-facto Trix editor for a Stimulus controller that sets up Lexical. There’s no toolbar, so you’ll need to use keyboard shortcuts (CMD+B, CMD+I etc) to get the formatting to work.

The source is here: https://github.com/developius/lexical-actiontext/

There’s a lot left to do, including toolbars with nice buttons, custom formatting for the different styles, ActiveStorage attachment support, etc. This is a “spike” or a proof of concept really. I just wanted to share my progress in case anyone else was trying to get Lexical working, as the docs for vanilla JS instead of React are extremely limited.

I wanted to publish package with a Stimulus controller that could be extended, allowing people to get Lexical up-and-running with Rails more easily, but I ran into some problems with how the library is architected which doesn’t lend itself to the outcome I was aiming for. I’ve documented these in the GitHub repo. I may get involved in contributing to Lexical, but I don’t have the energy just yet.

Whilst Lexical supports vanilla JS (and therefore any framework), the documentation is currently geared towards React. This makes it quite difficult to understand how it works and most of the API is undocumented. It’s a brand new project though, so that’s all good! It’ll improve with time.

Just show me the code!

https://github.com/developius/lexical-actiontext/blob/main/app/javascript/controllers/lexical_rich_text_controller.ts

import { Controller } from "@hotwired/stimulus"
import { $getRoot, $insertNodes, createEditor, CreateEditorArgs, LexicalEditor } from 'lexical';
import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html';
import { registerRichText } from "@lexical/rich-text";

export default class LexicalController extends Controller {
  static targets = ["editor", "input"]

  /**
   * The element to render the editor within.
   */
  declare readonly editorTarget: HTMLElement;
  /**
   * The input field to output the rendered from the editor HTML to.
   */
  declare readonly inputTarget: HTMLInputElement;

  editor: LexicalEditor;
  removeRichTextListener: () => void;
  removeUpdateListener: () => void;

  connect() {
    this.initEditor()
    this.loadInitialState()
    this.removeRichTextListener = registerRichText(this.editor)
    this.registerUpdateListener()
  }

  initEditor() {
    this.editor = createEditor(this.config());
    this.editor.setRootElement(this.editorTarget);
  }

  /**
   * Load the HTML markup into the editor from the input field value.
   */
  loadInitialState() {
    this.editor.update(() => {
      // In the browser you can use the native DOMParser API to parse the HTML string.
      const parser = new DOMParser();
      const dom = parser.parseFromString(this.inputTarget.value, "text/html");
    
      // Once you have the DOM instance it's easy to generate LexicalNodes.
      const nodes = $generateNodesFromDOM(this.editor, dom);
    
      // Select the root
      $getRoot().select();
    
      // Insert them at a selection.
      $insertNodes(nodes);
    });
  }

  registerUpdateListener() {
    this.removeUpdateListener = this.editor.registerUpdateListener(({editorState}) => {
      editorState.read(() => {
        const htmlString = $generateHtmlFromNodes(this.editor, null);
        this.inputTarget.value = htmlString;
      })
    })
  }

  /**
   * The configuration to pass to `createEditor`.
   * @returns The configuration object.
   */
  config(): CreateEditorArgs {
    return {}
  }

  disconnect() {
    this.removeRichTextListener()
    this.removeUpdateListener()
  }
}

Recent Posts