@infonomic/payload-alternative-lexical-editor
v1.1.7
Published
An alternative lexical editor for Payload CMS.
Readme
Payload CMS Alternative Lexical Rich Text Editor
An alternative adapter-based Lexical rich text editor for Payload CMS.
[!IMPORTANT] Unless you have very specific needs, or a lot of experience with Lexical and custom Payload fields, you should almost certainly be using the official Payload Lexical Rich Text editor.
Our Lexical Editor
Background
We started working with Lexical in 2022 while searching for a replacement CMS for our agency. We then discovered Payload CMS - which was a pretty good fit for what we were looking for at the time, with one notable exception - the use of Slate as its rich text editor. We'd worked with Slate and other editors previously and really wanted to use Lexical.
We were also painfully aware of how difficult it is to work with Contenteditable-based editors - in particular for mobile support and especially on Android. Read this somewhat famous post from Jesse Jorgenson at Prose Mirror - Contenteditable on Android is the Absolute Worst. As an aside, CKEditor has an excellent editing surface with very good mobile support (with solutions for most of Android's quirks) - it's just a shame that their model doesn't support structured content as its native serialization format (it expects HTML in and out - despite having quite a good internal model). Lexical's model and native serialization format is excellent - 'data in and data out', and mobile support is 'good enough'.
And so we started work on a Lexical-based rich text field for Payload.
Early in 2023 we discovered Alessio Gravili's Payload Lexical Plugin which helped enormously in getting started with Payload and custom fields. We also attempted to 'give back' to the work Alessio was doing with contributions to his public repo.
Thanks largely to Alessio's efforts, Lexical has now been adopted by the Payload team and is the default editor for Payload, which is fantastic.
In our case, there are still a few issues (and a few opinions) that meant continuing with our own editor is our preferred approach for the moment. We're also very interested in the new Lexical Extensions framework and will likely migrate to this as the Lexical Extensions API becomes stable.
Rationale
Here are the main drivers for us wanting to maintain our own editor:
We'd already created a custom Lexical rich text field (before Lexical was included in Payload) and felt that at the time it would be easier to convert this to an adapter than convert our plugins and nodes to features.
As a candidate editor for existing projects - in particular for our Drupal users - we needed an 'across the top' editor toolbar including support for
LexicalNestedComposer. The good news is that a fixed toolbar is on its way to the official Payload Lexical editor.We needed a way to call
setValuefor the RichText field fromLexicalNestedComposerwithin our image plugin captions and admonition plugin text, and so createdSharedOnChangeContext. When versions are enabled, this means that the 'Save Draft, and 'Publish Changes' buttons become 'enabled' whenLexicalNestedComposertext is changed. Overall, our structure for context providers for the editor is a little different as well.We wanted control over the serialization of internal links. See the special section below on Editor Links Strategy.
In Payload 3.0 - we wanted to experiment with client-only forms using the new field api and
RenderFields. You can see an example here in our Admonition plugin. This is totally experimental. It works (as far as we can tell) and we're using this for all of our custom components that require modals or drawers with Payload fields.We wanted to share our plugins - in particular our Inline Image plugin which was accepted into the Lexical playground and our Admonition plugin. In fact, our Inline Image plugin was one of the main reasons we chose Lexical as our preferred editor. Try creating a floated inline element that appears correctly in both the admin UI and the front end application - with any of the 'other editors', and you'll see why ;-). Most of the other plugins in this repo track Lexical Playground plugins and are updated from there.
And lastly, we wanted to keep our editor lightweight and fast — in particular for longer documents.
Editor Links Strategy
As mentioned in the rationale section above, we wanted control over the serialization of internal links. Instead of retrieving and populating an entire document for each internal link in the editor via an afterRead field hook, we wanted to augment the relationship with just the slug and title.
Here's our version of the Lexical link node:
{
"direction": "ltr",
"format": "",
"indent": 0,
"type": "link",
"version": 2,
"attributes": {
"newTab": false,
"linkType": "internal",
"doc": {
"value": "6635e07947922a2b9194d9a2",
"relationTo": "minimal",
"data": {
"id": "6635e07947922a2b9194d9a2",
"title": "This is a Test Minimal Page",
"slug": "this-is-a-test-minimal-page"
}
},
"text": "Click Me!"
}We've added an additional property called data, to which we've added the id, title and slug for the target document. When combined with the relationTo property, this is everything the front end application needs to create a complete link or router link to the target document.
[!IMPORTANT] We have two strategies for populating the data property above. The first, via an
afterReadhook, and the second via abeforeChangehook. You can choose which to implement based on your requirements.
afterRead
When using an afterRead hook — we add the data property and populated the title and slug for the related document dynamically during document read. Here's our afterRead field hook. Note however, that for documents that contain more than one or two links, this can add a significant number of document requests for a single source document since the related document for each internal link will need to be retrieved in order to populate our data property (O(n) linear time complexity). In our experience, this can have a major impact on overall performance and user experience.
[!IMPORTANT] 2026-01-03: There is currently a problem with the current implementation of our
afterReadhook. IfdocumentAhas a link todocumentBanddocumentBlinks back todocumentA, ourafterReadhook will end up in an infinite call loop and will eventually overflow. A first attempt to correct this using Payload hooks Context failed.
beforeChange
When using a beforeChange hook — we add the data property to the document itself when the document is being saved. Here's our beforeChange hook. Obviously this has implications for stale links (source documents who's title or slug may have changed). However, there is no impact on overall performance and user experience, since the source document already contains the data it needs for internal links (O(1) constant time complexity).
The configuration in this repo is using the beforeChange strategy, although this can be changed here in the hooks property for the richtext adapter.
Editor Architecture
The architecture of our editor is designed to solve the "two-way binding problem" common in rich text editors.
Rich text editors maintain their own complex internal state (the DOM/Virtual DOM). When you try to sync this with React state (props), you often get infinite loops (Editor changes $\rightarrow$ React updates $\rightarrow$ Prop changes $\rightarrow$ Editor updates $\rightarrow$ Editor changes...) or cursor jumping (re-rendering the editor while typing).
In Payload - the adapter loads our editor via the rsc-entry.tsx stub and a component map entry in the adapter. This follows the current adapter / component map strategy in Payload CMS itself. Note: Although the return shape of our editor is slightly different, the root of our editor document and SerializedEditorState is identical to the official Payload Lexical editor, and so migrating to or from the Payload version will depend primarily on which features or plugins have been used.
Component Hierarchy
Once the editor has been 'bootstrapped' from the adapter, the editor field is rendered and managed via the following component hierarchy:
+-----------------------------------------------------------------------+
| EditorField (src/field/editor-field.tsx) |
| - Handles Lazy Loading & Suspense |
+-----------------------------------+-----------------------------------+
|
v
+-----------------------------------+-----------------------------------+
| EditorComponent (src/field/editor-component.tsx) |
| - Connects to Payload Forms (useField) |
| - Manages Hash Refs (lastEmitted, normalizedIncoming) |
| - Handles onChange (Debouncing & Hash Checks) |
+-----------------------------------+-----------------------------------+
|
| (renders)
v
+-----------------------------------+----------------------------------+
| EditorContext (src/field/editor-context.tsx) |
| - Wraps everything in <LexicalComposer> |
| - Provides SharedHistory & SharedOnChange Contexts |
+------------------+---------------------------------+-----------------+
| |
| (passed as children) | (renders)
v v
+------------------+------------------+ +-----------+------------------+
| ApplyValuePlugin | | Editor (src/field/editor.tsx)|
| (src/field/apply-value-plugin.tsx) | | - ToolbarPlugin |
| | | - ContentEditable |
| - Watches: incoming value & hash | | - Floating Toolbars |
| - Action: editor.update() | | - Auto-resize logic |
| (Syncs external props -> Editor) | +------------------------------+
+-------------------------------------+Key Relationships
EditorComponentis the "Brain". It holds the connection to the Payload form state (useField) and decides when to update the form value based on hashes.EditorContextis the "Bridge". It initializes the Lexical instance (LexicalComposer) but doesn't contain the specific logic for syncing values or rendering the UI itself.ApplyValuePluginis the "Synchronizer". It sits inside the Lexical context. WhenEditorComponentreceives a new value from the database (or parent), it passes it down here. This plugin forces the Lexical instance to update its state to match.Editoris the "View". It handles the visual presentation, toolbars, and the actualcontentEditableDOM element.
Stability inside Editor Component
Here's how the useRef hooks inside editor-component.tsx solve editor-specific problems:
1. The "Debounce" Ref
dispatchFieldUpdateTask
- Purpose: Performance.
- How it works: When a user types, Lexical fires
onChangeon every keystroke. We don't want to update the main React state (and trigger re-renders up the tree) 60 times a second. - Mechanism: This ref stores the ID of the current
requestIdleCallback. If a user types again before the callback runs, we cancel the old one and start a new one. It ensures we only process the latest state when the browser is idle.
2. The "Fresh Props" Refs
valueRef and initialValueRef
- Purpose: Accessing state inside
useCallbackwithout dependencies. - How it works: The
handleChangefunction is memoized viauseCallback. If we addedvalueto its dependency array,handleChangewould be re-created every time the user typed, breaking our debounce logic. - Mechanism: We copy the props into these refs on every render. Inside
handleChange, we readvalueRef.current. This lets the function stay stable (same memory address) while still seeing the latest data.
3. The "Outbound Loop Breaker"
lastEmittedHashRef
- Purpose: Preventing the "Echo" loop.
- The Problem: You type "A". The editor emits "A". The parent saves "A" and passes "A" back down as a prop. The editor sees "A" and thinks, "Oh, a new value! I should process this."
- The Fix: Before calling
onChange, we calculate a hash of the content and store it here. - Logic: "If the hash I'm about to send is the same as the last one I sent, do nothing."
4. The "Inbound Normalization" Refs
normalizedIncomingHashRef and hasNormalizedBaselineRef
- Purpose: Handling Lexical's strictness.
- The Problem: Lexical is opinionated. If you load a value like
{"text": "hello"}, Lexical might instantly transform it into a more complex structure (adding IDs, versions, etc.). This immediate transformation looks like a "change" event, which might trigger a save before the user has even touched the keyboard. normalizedIncomingHashRef:- The
ApplyValuePluginloads data, waits for Lexical to "settle" (normalize it), and then saves the hash of that settled state here. - The
handleChangefunction checks this: "Is this 'new' change just the result of the data I just loaded?" If yes, it ignores it.
- The
hasNormalizedBaselineRef:- This acts as a gatekeeper. It starts as
false. - It prevents the editor from emitting any changes until the initial value has been fully loaded and normalized. This stops the editor from accidentally overwriting the database with an empty state during the split-second it takes to mount.
- This acts as a gatekeeper. It starts as
Summary Flow
- User types:
handleChangetriggers. - Debounce:
dispatchFieldUpdateTaskensures we wait for a pause. - Check Baseline:
hasNormalizedBaselineRefensures we aren't just booting up. - Check Inbound:
normalizedIncomingHashRefensures we aren't just reporting Lexical's own auto-formatting of the data we just gave it. - Check Outbound:
lastEmittedHashRefensures we aren't reporting the same thing twice. - Success: Only then do we call
onChange.
Getting Started
To run the editor examples from within this repo:
- Clone this repo
- If you don't already have an instance of MongoDB running locally we've provided a docker composer file and a shell start script. To start
cd mongodbfrom the project root.mkdir dataand then./mongo.sh upto start a local instance of MongoDB with a fresh database. - In the
apps/nextdirectory - copy.env.exampleto.env(Note: Don't deploy this to production or a public service without changing your PAYLOAD_SECRET). - From the root - run
pnpm installfollowed bypnpm dev. - To run a production build - from the root run
pnpm buildfollowed bypnpm start.
To install and run the editor in your own project.
pnpm add @infonomic/payload-alternative-lexical-editorornpm install @infonomic/payload-alternative-lexical-editor- Configure the editor in
payload.config.ts
import { lexicalEditor } from '@infonomic/payload-alternative-lexical-editor'
...
// @ts-expect-error: our return type for editorConfig is slightly different
editor: lexicalEditor(),
...Follow the examples in this repo under apps/next for configuration options and settings for the editor (turning editor features on or off).
Thoughts, suggestions or contributions more than welcome. We hope that some of this helps.
