Skip to content

Lexical

This guide explains how to add multiplayer support to applications using React.js and the Lexical editor.

Basic Integration

Begin by incorporating lexical-provider into your Lexical project:

Terminal window
npm install @codoxhq/lexical-provider

The following code snippet demonstrates how to achieve a basic integration between a Lexical editor component and Codox:

import React, { useState, useEffect, useRef, useMemo } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { CodoxCollabPlugin, registerNodesWithCodox, validateStateStructure } from '@codoxhq/lexical-provider';
const nodes = [
/*Put all nodes classes here to register in lexical*/
];
//Important: first register all nodes classes with codox
const LEXICAL_NODES_TO_REGISTER = registerNodesWithCodox([...nodes]);
export default function App() {
const [codoxStarted, setCodoxStarted] = useState(false);
const [currentDocId, setCurrentDocId] = useState('demo_docId_1');
const [initLexicalState, setInitLexicalState] = useState(null);
// codox provider ref for start/stop api
const codoxAPI = useRef();
// codox configuration parameters
const codoxConfig = {
docId: currentDocId,
apiKey: process.env.REACT_APP_CODOX_API_KEY, // api key provided by codox
username: 'demo_user', // client user name
hooks: {
contentChanged: ({ source, content }) => {
console.log('[Lexical Demo][contentChanged hook] hook invoked by Codox: ', { source, content });
// implement action to take on content changed
// source is one of "local", "remote"
if (source === 'local') {
// e.g. update server state
//(async () => await updateServerState(currentDocId, content))();
}
},
usersUpdate: (data) => {
console.log('[Lexical Demo][usersUpdate hook] hook invoked by Codox: ', data);
// implement action to take when users in the session changes,
// e.g., updating session avatars
},
},
};
const startCodox = () => {
if (codoxAPI.current) {
codoxAPI.current
.start(codoxConfig)
.then(() => {
console.log('[Lexical Demo][codox.start] success');
setCodoxStarted(true);
})
.catch((err) => console.log('[Lexical Demo][codox.start] error', err));
}
};
const stopCodox = () => {
if (codoxAPI.current) {
codoxAPI.current.stop();
}
setCodoxStarted(false);
};
let mockState = {
root: {
children: [
{
children: [],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
};
// Sample code to grab document content from app's backend
useEffect(() => {
(async () => {
if (!currentDocId) return;
if (codoxStarted) {
stopCodox();
}
// Make a real api call for document state
// let initState = await fetchDocInitStateByDocId(currentDocId);
let initState = mockState;
if (initState) {
try {
// if invalid - will throw
validateStateStructure(initState, LEXICAL_NODES_TO_REGISTER);
// save it
setInitLexicalState(initState);
} catch (err) {
console.error('[APP] error: ', err);
}
}
})();
}, [currentDocId]);
// once document state is available start codox
useEffect(() => {
if (initLexicalState && currentDocId && !codoxStarted) {
startCodox();
}
}, [initLexicalState, currentDocId, codoxStarted]);
/**
* Standard Lexical Lexical Composer initialization code
* Provide LEXICAL_NODES_TO_REGISTER to `nodes`
*
*/
const initLexicalConfig = useMemo(
() => ({
editorState: initLexicalState ? JSON.stringify({ root: initLexicalState.root }) : null,
namespace: `Playground`,
nodes: LEXICAL_NODES_TO_REGISTER, //register nodes wrapped with codox meta-data
onError: (error) => {
throw error;
},
theme: {}, // css theme
}),
[initLexicalState]
);
return (
initLexicalState && (
<LexicalComposer initialConfig={initLexicalConfig}>
<RichTextPlugin
contentEditable={<ContentEditable />}
placeholder={<div>Enter some rich text...</div>}
ErrorBoundary={LexicalErrorBoundary}
/>
{/*any other plugins here*/}
{/*init Codox colalboration plugin*/}
<CodoxCollabPlugin
// CodoxCollabPlugin can be inited here or inside
// EditorPlugins with other plugins
ref={codoxAPI}
/>
</LexicalComposer>
)
);
}

codoxAPI

Codox exposes its multiplayer capabilities to Lexical through a standard Lexical React plugin component. The CodoxCollabPlugin manages the merging and synchronization of content as local and remote changes occur. Its primary role within the application is to control the start and stop of collaboration sessions.

const codoxAPI = useRef();
...
return (
<LexicalComposer initialConfig={initLexicalConfig}>
{/*init Codox colalboration plugin*/}
<CodoxCollabPlugin
// CodoxCollabPlugin can be inited here or inside
// EditorPlugins with other plugins
ref={codoxAPI}
/>
</LexicalComposer>
)

The plugin provides an API layer accessible through a forwardRef. This ref should be declared on the container component and passed as a prop. To interact with the API, use ref.current to access the instance.

Initializing Document

Before initiating or joining a Codox session, the latest version of the document content must be available. This content, typically formatted as a JSON object adhering to Lexical’s document structure (including a root attribute that encapsulates the document), can be provided directly as a prop or fetched from a remote repository.

In the sample code above, we showed how you might accomplish this if the document was fetched dynamically from a remote repository. We passed the fetched content a utility function validateStateStructure from the provider module which will perform basic validation of the document state against the known nodes. It will throw on any invalid structure or unrecognized nodes are found in the input document state.

const [currentDocId, setCurrentDocId] = useState('demo_docId_1');
const [initLexicalState, setInitLexicalState] = useState(null);
// Sample code to grab document content from app's backend
useEffect(() => {
(async () => {
...
// Make a real api call for document state
let initState = await fetchDocInitStateByDocId(currentDocId);
if (initState) {
try {
// will throw
validateStateStructure(initState, LEXICAL_NODES_TO_REGISTER);
// save it
setInitLexicalState(initState);
} catch (err) {
}
}
})();
}, [currentDocId]);

Registering Nodes

The Codox plugin enhances node capabilities by adding metadata essential for data merging and reconciliation tasks. It is important that the plugin is informed of all nodes you intend to use with Lexical, including both official implementations under the @lexical namespace and any custom nodes you may develop for your application.

To facilitate this, utilize the registerNodesWithCodox() function provided by the Codox provider. This function accepts an array of node classes and returns them wrapped with the necessary listeners and metadata. These enhanced nodes can then be incorporated into the Lexical initialization configuration as shown below:

import {registerNodesWithCodox} from “@codoxhq/lexical-provider”
// all nodes to register in lexical (lexical nodes classes)
let nodes = [HeadingNode, ListNode, ListItemNode, ….]
// nodes wrapped with codox listeners
let nodesToRegister = registerNodesWithCodox(nodes)
// init lexical editor config
const initEditorConfig = {
//pass wrapped nodes into lexical initialization
nodes: nodesToRegister,
...rest of config
}

Start and Stop Session

In line with React’s lifecycle conventions, initiate and terminate Codox sessions using side-effects managed through a forward ref prop. This allows for precise control over session management based on the application state:

// codox configuration parameters
const codoxConfig = {
docId: currentDocId,
apiKey: process.env.REACT_APP_CODOX_API_KEY, // api key provided by codox
username: demoUserName, // client user name
hooks : {
...
}
},
};
const startCodox = () => {
if (codoxAPI.current) {
codoxAPI.current
.start(codoxConfig)
.then(() => {
setCodoxStarted(true);
})
}
};
const stopCodox = () => {
if (codoxAPI.current) {
codoxAPI.current.stop();
}
setCodoxStarted(false);
};
// once document state is available start codox
useEffect(() => {
if (initLexicalState && currentDocId && !codoxStarted) {
startCodox();
}
}, [initLexicalState, currentDocId, codoxStarted]);

Updating Lexical State

In a React-based implementation of a single-player Lexical instance, a custom plugin component is typically used to subscribe to updates from the Lexical editor state. This component would typically handle an onChange event from the parent component to update the local state using setEditorState. Here’s an example implementation:

function MyOnChangePlugin({ onChange }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerUpdateListener(({editorState}) => {
onChange(editorState);
});
}, [editor, onChange]);
return null;
}
function Editor() {
// ...
const [editorState, setEditorState] = useState();
function onChange(editorState) {
// update state
setEditorState(editorState);
}
return (
<LexicalComposer initialConfig={initialConfig}>
<MyOnChangePlugin onChange={onChange}/>
</LexicalComposer>
);
}

The native onChange emitter in Lexical does not differentiate between changes made by the local user and those made remotely during collaboration. To address this, the Codox plugin provides a contentChanged hook that triggers each time there is a change in the editor’s content, with an indication whether the changes are local or remote:

// codox configuration parameters
const codoxConfig = {
hooks : {
contentChanged: ({ source, content }) => {
console.log('[Lexical Demo][contentChanged hook] hook invoked by Codox: ', data);
// implement action to take on content changed
// source is one of "local", "remote"
if (source === 'local') {
(async () => await updateServerState(currentDocId, content))();
}
}
}
}

Undo/Redo support

Codox supports undo and redo operations, leveraging the built-in undo/redo functionality of the Lexical editor. This is implemented through the LexicalHistoryPlugin from the official package @lexical/react/LexicalHistoryPlugin. To ensure stability and compatibility with codox sync’s merging process, initialize the HistoryPlugin alongside the Codox plugin within your editor configuration. It’s recommended to use this built-in plugin rather than custom undo/redo implementations to avoid potential sync conflicts.

import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';

Error handling and rollback

Error from codox

For a comprehensive list of potential errors emitted by Codox, refer to the error events documentation. To handle these errors, subscribe to the relevant events and implement appropriate responses:

useEffect(() => {
if (!codoxStarted && initStateReady) {
// subscribe to codox error events
const events = ["error"];
events.forEach((event) => {
codoxProviderRef.current.on(event, (data) => {
console.log("[Draft Demo][Codox Event Emitted]: ", { event, data });
});
});
...
}
}, [initStateReady]);

Error from Lexical

The Codox plugin is equipped to handle certain errors that may arise from Lexical operations, helping to prevent these errors from corrupting the document. The standard error handling strategy involves rolling back the document to its state prior to the error detection. If an onError callback is provided by the client’s application, Codox will invoke this callback and then perform a rollback to safeguard the document integrity:

const initLexicalConfig = useMemo(() => {
return {
onError: (error) => {
....
},
};
}, [initLexicalState]);
return (
initLexicalState && <LexicalComposer initialConfig={initLexicalConfig}>
)