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:
- Toolbar button: A UI control to add, edit, or remove links.
- Popover UI: A small popup with an input field for entering or updating the link URL.
- 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
isnull
, 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.