Building a custom markdown rich text editor with Lexical headless editor

In modern web applications, a headless editor like Lexical offers significant advantages, especially when replacing legacy editors.

Its flexibility allows seamless integration with your existing storage formats, ensuring backward compatibility with previously created data. Moreover, Lexical decouples the editor’s core functionality from the UI, enabling you to fully leverage your UI toolkit and CSS framework without overriding custom styles.

This article delves into the technical implementation of a Lexical-based editor with a custom Markdown transformer and practical tips for development.

Why choose a headless editor?

Seamless integration with existing systems

Unlike opinionated editors, Lexical lets you adapt its features to your existing data model. This makes it ideal for applications where replacing a legacy editor means ensuring compatibility with existing content.

Complete UI and styling freedom

Lexical doesn’t enforce a specific look and feel. You can use your existing CSS framework, and UI toolkit (such as Tailwind CSS and Radix UI in the examples), for a consistent design without dealing with complex overrides.

Extensibility

Whether you need custom formatting options or bespoke behaviors, Lexical provides the flexibility to extend its functionality while maintaining full control over the editor’s behavior.

The Editor code overview

Let’s dive into the code for our editor, with a focus on understanding its components and plugins.

Core Editor implementation

The core editor (editor.tsx) integrates multiple Lexical plugins and configurations for our specific use case.

import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
import { TRANSFORMERS, $convertFromMarkdownString } from "@lexical/markdown";

import ToolbarPlugin from "./plugins/toolbar-plugin";
import SimpleTheme from "./basic-theme";
import { UNDERLINE } from "./transformers/underline-transformer";

const editorConfig = (initialMarkdown) => ({
  namespace: "RichEditor",
  nodes: [],
  editorState: () =>
    $convertFromMarkdownString(initialMarkdown, [...TRANSFORMERS, UNDERLINE]),
  theme: SimpleTheme,
  onError: (error) => {
    console.error("Editor error:", error);
  },
});

export default function Editor({ value = "", onChange }) {
  return (
    <LexicalComposer initialConfig={editorConfig(value)}>
      <div className="overflow-hidden rounded-lg border bg-gray-50 focus-within:border-blue-500 focus-within:shadow-md">
        <ToolbarPlugin />
        
        <RichTextPlugin
          contentEditable={
            <ContentEditable className="px-4 py-2 focus:outline-none" />
          }
          placeholder={<div className="text-gray-400">Start typing...</div>}
          ErrorBoundary={LexicalErrorBoundary}
        />

        <HistoryPlugin />

        <OnChangePlugin onChange={onChange} />

        <LinkPlugin validateUrl={validateUrl} />

        <ListPlugin />

        <MarkdownShortcutPlugin transformers={[...TRANSFORMERS, UNDERLINE]} />

        <TabIndentationPlugin />
      </div>
    </LexicalComposer>
  );
}

Key plugins and components in Lexical headless editor

Each plugin in Lexical serves a specific purpose. Here’s a detailed look:

1. LexicalComposer

This is the root component of the Lexical editor, responsible for initializing and managing the editor’s configuration.

Key Features:

  • Accepts an initialConfig object defining the editor’s namespace, theme, and nodes.
  • Provides context for other plugins and components to interact with the editor.

2. ContentEditable

This component defines the editable area of the editor where users can type.

3. RichTextPlugin

Handles rendering and managing rich text content, including bold, italic, headings, and more.

Setup:

  • Integrates with ContentEditable to define the editable region.
  • Accepts a placeholder for empty editor states.

4. OnChangePlugin

The OnChangePlugin is responsible for monitoring and responding to changes in the editor state. It allows developers to capture the latest editor content and trigger external actions, such as saving content or updating a preview.

The onChange prop is a callback function invoked whenever the editor state changes. This ensures that the external component using the editor is always synchronized with its current state.

Here’s a simple onChange implementation:

function handleEditorChange(editorState) {
  const markdown = editorState.toMarkdown();
  console.log("Updated Markdown:", markdown);
}

By converting the editor’s content to markdown or other formats, you can integrate with various backend systems or APIs.

5. HistoryPlugin

Provides undo and redo functionality, a crucial feature for any text editor.

6. ListPlugin

The ListPlugin enables support for ordered (<ol>) and unordered (<ul>) lists in the editor. It handles indentation, nesting, and rendering of list items, making it easy to manage structured content.

7. TabIndentationPlugin

The TabIndentationPlugin adds keyboard-based indentation functionality using the Tab and Shift+Tab keys. It is especially useful for working with lists or other block structures that require hierarchical nesting.

8. MarkdownShortcutPlugin

Enables Markdown-like shortcuts for formatting text (e.g., **bold**, _italic_).

Extensibility:

  • Uses TRANSFORMERS to define Markdown patterns and transformations.
  • Includes our custom UNDERLINE transformer for underlining text.

9. Toolbar Plugin

The toolbar houses controls for formatting text (e.g., bold, italic, underline) toolbar-plugin.tsx:

import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { FORMAT_TEXT_COMMAND, $getSelection, $isRangeSelection } from "lexical";

export default function ToolbarPlugin() {
  const [editor] = useLexicalComposerContext();

  return (
    <div className="toolbar flex items-center gap-2">
      <button
        onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")}
        className="rounded-md bg-gray-200 p-1 text-xs"
      >
        Bold
      </button>

      <button
        onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")}
        className="rounded-md bg-gray-200 p-1 text-xs"
      >
        Italic
      </button>
      
      {/* Rest of the buttons omitted for brevity. */}
    </div>
  );
}

Custom theme

The BasicTheme object defines Tailwind-based styles for headings, links, text formatting, and lists.

Example:

export default {
  heading: {
    h1: "text-5xl font-extrabold",
    h2: "text-4xl font-bold",
    h3: "text-3xl font-bold",
    h4: "text-2xl font-bold",
    h5: "text-xl font-bold",
    h6: "text-lg font-bold",
  },
  link: "text-violet-600",
  text: {
    bold: "bold",
    italic: "italic",
    underline: "underline",
  },
  list: {
    nested: {
      listitem: "pl-5 mt-2 space-y-1 list-none",
    },
    ol: "space-y-1 list-decimal list-inside",
    ul: "space-y-1 list-disc list-inside",
  },
}

Adding link functionality

Handling links is an important feature in a text editor. This functionality allows users to insert, update, and remove hyperlinks. Here’s a breakdown of how we add link functionality in our editor.

Key components

The link functionality involves the following:

  1. Toolbar button: A UI control to add, edit, or remove links.
  2. Popover UI: A small popup with an input field for entering or updating the link URL.
  3. Lexical commands: To toggle links in the editor.

Adding a link button

In the ToolbarPlugin, we use a button to activate link management. The button checks whether the current selection is inside a link and shows the corresponding UI.

Code for the link button:

<Button title="Link" selected={isLink}>
  <LinkIcon />
</Button>

The selected prop dynamically updates based on whether the current selection contains a link node. This is managed by monitoring the editor state with the SELECTION_CHANGE_COMMAND and reading the selection:

const linkNode = $isLinkNode(parent)
  ? parent
  : $isLinkNode(node)
  ? node
  : null;

if (linkNode) {
  setIsLink(true);
  setLinkUrl(linkNode.getURL());
} else {
  setIsLink(false);
  setLinkUrl("https://");
}

Popover for link management

When the link button is clicked, we open a popover that contains an input field for the URL. Users can:

  • Add a new link.
  • Update an existing link.
  • Remove the link entirely.
<Popover>
  <PopoverTrigger asChild>
    <Button title="Link" selected={isLink}>
      <LinkIcon />
    </Button>
  </PopoverTrigger>
  <PopoverContent className="flex items-center gap-2">
    <input
      type="url"
      className="w-full min-w-0 focus:outline-none"
      value={linkUrl}
      onChange={(e) => setLinkUrl(e.target.value)}
    />
    <Button onClick={() => addLink(linkUrl)} className="text-green-400">
      <PencilSquareIcon />
    </Button>
    {isLink && (
      <Button onClick={() => addLink(null)} className="text-red-400">
        <TrashIcon />
      </Button>
    )}
  </PopoverContent>
</Popover>

Commands for managing links

Lexical provides a built-in command, TOGGLE_LINK_COMMAND, to add or remove link nodes. Here’s how we integrate it:

Adding or updating links:

const addLink = (link: string | null) => {
  editor.dispatchCommand(TOGGLE_LINK_COMMAND, link);
  editor.focus();
};
  • If link is a valid URL, it creates or updates the link.
  • If link is null, the command removes the link from the selected text.

Link state monitoring

The editor continuously monitors whether the current selection is inside a link. This is achieved using the $getSelection() function and $isLinkNode():

Code:

const $updateToolbar = useCallback(() => {
  const selection = $getSelection();

  if ($isRangeSelection(selection)) {
    const node = getSelectedNode(selection);
    const parent = node.getParent();

    const linkNode = $isLinkNode(parent)
      ? parent
      : $isLinkNode(node)
      ? node
      : null;

    if (linkNode) {
      setIsLink(true);
      setLinkUrl(linkNode.getURL());
    } else {
      setIsLink(false);
      setLinkUrl("https://");
    }
  }
}, []);


Code for getSelectedNode:

import { $isAtNodeEnd } from "@lexical/selection"
import { ElementNode, RangeSelection, TextNode } from "lexical"

export function getSelectedNode(
  selection: RangeSelection
): TextNode | ElementNode {
  const anchor = selection.anchor
  const focus = selection.focus
  const anchorNode = selection.anchor.getNode()
  const focusNode = selection.focus.getNode()

  if (anchorNode === focusNode) {
    return anchorNode
  }

  const isBackward = selection.isBackward()
  if (isBackward) {
    return $isAtNodeEnd(focus) ? anchorNode : focusNode
  } else {
    return $isAtNodeEnd(anchor) ? anchorNode : focusNode
  }
}

Adding custom markdown underline support

Markdown doesn’t natively support underlining. Here’s how we can enable it with a custom transformer.

Implementation (underline-transformer.tsx):

import type { TextMatchTransformer } from "@lexical/markdown"
import { LexicalNode, TextNode } from "lexical"

const UNDERLINE_REG_EXP = /<u>(.*?)<\\/u>/

export const UNDERLINE: TextMatchTransformer = {
  dependencies: [TextNode],
  export: (node: LexicalNode) => {
    if (!(node instanceof TextNode) || !node.hasFormat("underline")) {
      return null
    }

    const textContent = node.getTextContent()

    return `<u>${textContent}</u>`
  },
  regExp: UNDERLINE_REG_EXP,
  importRegExp: UNDERLINE_REG_EXP,
  replace: (node: TextNode, match: RegExpMatchArray) => {
    const textContent = match[1]

    const newNode = new TextNode(textContent)
    newNode.setFormat(node.getFormat())
    newNode.toggleFormat("underline")

    node.replace(newNode)
  },
  type: "text-match",
}

Drawback of Markdown underline

Storage limitation

Lexical Markdown’s text-based format doesn’t allow combining underline with other styles (e.g., bold text inside an underlined block). For advanced styling combinations, consider a structured storage format like JSON.

Tips for developing with Lexical

1. Explore the Lexical Playground

The Lexical Playground demonstrates advanced use cases, including custom nodes, formatting, and more.

2. Debugging with Update Listeners

Use editor.registerUpdateListener to observe state changes for debugging or implementing custom features.

3. Encapsulate plugins

Modularize features like toolbars and custom transformations into reusable plugins for clean and maintainable code.

Conclusion

Lexical offers a headless, flexible editor framework perfect for replacing legacy systems while preserving backward compatibility and ensuring UI consistency. By combining it with your own choice of CSS and UI toolkit solutions, you can build a modern, extensible editor tailored to your specific needs. With the addition of custom Markdown transformers and thoughtful plugin design, Lexical is a solid go-to for all your content editing needs.

Table of Contents