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:
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 codoxconst 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 listenerslet nodesToRegister = registerNodesWithCodox(nodes)
// init lexical editor configconst 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 parametersconst 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}>)