Lexical
Codox Integration with Lexcial Editor
Overview
-
Codox provides a separate plugin for sync, following a lexical approach for designing external features and plugins. Plugin should be initialized as any other lexical plugin.
-
Codox plugin provides api to control sync start/stop via built-in React ref
-
Codox plugin implements a rollback mechanism after lexical core errors. Rollback is restoring editor state to previous. Plugin catches errors thrown from lexical editor. If client’s callback “onError” was passed into editor config, plugin will allow it to trigger and then will perform rollback.
-
Codox sync supports undo/redo. It relies on a built-in lexical undo/redo implemented with lexical HistoryPlugin provided by the official package “@lexical/react/LexicalHistoryPlugin”. For undo/redo support in editor, should init HistoryPlugin along with Codox plugin. Any other custom client’s implementations of undo/redo can be unstable with respect to sync.
-
Codox sync implements the restrictions of inserted content - the blacklisted content combinations, which are not allowed to be inserted into the editor. Codox plugin prevents inserting such combinations. See Content Blacklisting section for blacklist details.
-
Codox sync supports sync of all lexical nodes types from lexical core and all custom nodes from the official playground. Dynamic support of other custom nodes is in progress.
-
Codox plugin accepts init state during sync start and applies it to editor, then on content changes, plugin will trigger client’s callback passed as part of plugin configuration. Client’s app should not do any direct update ops to editor state. Read ops are not limited.
-
Codox provides an additional helper util to validate editor state json. Checks if state is valid for lexical. Throws error if state is invalid
-
Codox plugin has built-in mechanism to validate and fix lexical tables structures, if lexical core produces any incorrect table structure. This logic is encapsulated and prevents output of broken table structures from lexical
Requirements for Codox sync integration
-
Codox plugin must be initialized in the scope Lexical Composer context. If it’s not, plugin with throw error.
-
Initial state, fetched from client’s backend, should be passed to codox plugin when invoking sync start. Client’s app must delegate applying init state to codox plugin and should not apply it directly to sync - this may cause incorrect sync.
-
Sync must be started only after init state has already been fetched from backend
-
All lexical nodes which are to be registered in lexical, must be passed through codox util “registerNodesWithCodox” before passing it to lexical editor initial configuration. This step is critical for further correct sync work.
Installation of codox sync provider
Install codox provider npm package with codox components into project dependencies:
npm install @codoxhq/lexical-provider
Editor intitial config update for sync integration
Import register nodes util from codox provider
import {registerNodesWithCodox} from “@codoxhq/lexical-provider”
Wrap lexical nodes into registerNodesWithCodox() util and pass wrapped nodes into lexical init configuration for lexical editor:
// all nodes to register in lexical (lexical nodes classes)let nodes = [HeadingNode, ListNode, ListItemNode, ….]
// critical for sync to wrap nodeslet nodesToRegister = registerNodesWithCodox(nodes)
// init lexical editor configconst initEditorConfig = { editorState: null, namespace: “[Your namespace name]”, nodes: nodesToRegister,
....rest of config}
….<LexicalComposer initialConfig={initEditorConfig}> ….plugins</LexicalComposer>
Codox plugin config reference
Codox plugin accepts config and ref props. Config contains all attributes to configure codox for sync launch and ref is provided to be able to access codox start/stop api to control when to start and stop codox sync
// create react ref for codox providerconst codoxProviderRef = useRef()
<CodoxCollabPlugin ref={codoxProviderRef} config={{ docId: "[current document id]", username: "[current user name]", apiKey: "[api key provided by codox]",
onEditorStateChange=[callback triggered when editor state changed] // optional onBlackListedInsert=[callback triggered when blacklisted content combination was found] }}
/>
onEditorStateChange
Codox plugin accepts the callback from client’s app, which will be triggered each time, when content state is changed in editor. This callback should be used to listen to state changes and update app’s database.
const onEditorStateChange = ({ docId, // id of current document - passed into CodoxCollabPlugin config state, // editor content state already converted from lexcial to json obj isRemoteChange, // boolean flag to identify if change was made locally or not}) => { // do your custom logic here /* NOTE: state will include "comments" array along with "root" if comments not used, the array will always be empty*/};
It is recommended to update database only on local changes trigger.
onBlacklistedInsert
Optional callback which is triggered each time, when any blacklisted content combination was found and was blocked. Client’s app can listen to such cases and somehow notify users that operation was blacklisted, e.g. show some modal or pop-up.
const onBlackListedInsert = () => { // do your custom logic here - notify user, etc};
Client’s app must not stop codox in such cases or do any other operations with editor - all required logic is done internally by codox plugin.
Codox plugin sync start/stop api reference
Codox plugin provides an api to control start/stop of sync via React refs mechanism. Created ref should be passed to codox plugin and later can be used to invoke start/stop of codox sync.
// create react refconst codoxProviderRef = useRef()
// start codox syncconst startCodox = (initState) => { codoxProviderRef.current.start(initState)}
// stop codox sync - usually invoke it in useEffect return callbackconst stopCodox = () => { codoxProviderRef.current.stop()}
....<CodoxCollabPlugin ref={codoxProviderRef} // pass ref to plugin config={config object}/>
Codox sync blacklisting policy
Codox plugin defines a set of content combinations which are not allowed. Such content create/insert is blocked and the “onBlacklistedInsert” callback is invoked, if provided. Purpose of blacklisting is to prevent any weird content combinations including those which does not make any sense. Current content combinations blacklist:
Target (child lexical node types) | Forbidden to insert into (parent lexical node type) |
---|---|
“table” | “table”, “image”, “inline-image”, “sticky” |
“image” | “image”, “inline-image”, “sticky” |
“horizontalrule” | “table”, “image”, “inline-image”, “sticky” |
“page-break” | “table”, “image”, “inline-image”, “sticky” |
“collapsible-container” | “table”, “image”, “inline-image”, “sticky”, “collapsible-container” |
“layout-container” | “table”, “image”, “inline-image”, “sticky”, “layout-container” |
“inline-image” | “image”, “inline-image”, “sticky” |
“poll” | “image”, “inline-image”, “sticky” |
“excalidraw” | “image”, “inline-image”, “sticky” |
Should read table as following (1st row as example): “Table is forbidden to insert into table, image, inline-image and sticky”
Codox state validation util for editor content state
Codox provides extra helper util to validate json state object and ensure it is valid for lexical. Util can be used at any time and any place of client’s app code.
It is recommended to validate initial state, received from app’s backend.
Validation will throw error if state is not valid - should wrap it into try-catch.
Validation requires the same list of registered nodes that is passed into initial editor config
import {validateStateStructure} from "@codoxhq/lexical-provider"
....// all nodes to register in lexical (custom and lexical nodes classes)let nodes = [HeadingNode, ListNode, ListItemNode, ….]let nodesToRegister = registerNodesWithCodox(nodes) // critical for sync to wrap nodes
....
// fetching initial stateuseEffect(() => { try { const initState = fetch(....) // fetch init state validateStateStructure(initState, nodesToRegister) } catch(err) { // capture error here }}, [])
Codox extra plugins for Lexical Editor
Codox provider lib provides few extra plugins for extra functionality:
CodoxCommentPlugin
Separate plugin for support of comments. Has build-in ui for displaying comments and add/remove comments and threads. Along with plugin, the special editor command is exposed - to invoke comments creation from any place or other plugin
import {CodoxCollabPlugin, CodoxCommentPlugin, INSERT_COMMENT} from "@codoxhq/lexcial-provider"
// init both plugins along with other plugins<LexicalComposer> //....other plugins <CodoxCollabPlugin {codox config}/> <CodoxCommentPlugin/> // init comments plugin</LexicalComposer>
Comments plugin has built-in ui:
- A button in top-right of page, click on it opens sidebar on the right. Sidebar renders all current active comment threads and has functionality to remove threads and add/remove comments.
- when any range of content is selected, extra button is rendered on the right side of the page, click on it opens a modal to create new comment for selected content range
Extra command is exposed from codox provider lib to allow to trigger comment add from any part of editor ui, e.g. from any floating sidebars, etc
import {INSERT_COMMENT} from "@codoxhq/lexical-provider"
....
editor.dispatchCommand(INSERT_COMMENT) // will trigger modal open for comment add
All comments are exposed in editor state json obj along with root, when state is exposed with “onEditorStateChange” callback:
const { root, // content tree comments, // comments array - is empty when no comments or comments not used} = state;
CodoxFillBGColorPlugin
Extra plugin to change text background fill color.
Plugin comes from built-in ui - button click on which opens a color palette for background color pick. Can be utilized in any other lexical plugin, for example in toolbar plugin.
Plugin has set of optional props for css customization and listen to color changes:
import {CodoxFillBGColorPlugin} from "@codoxhq/lexical-provider"
// somewhere in toolbar plugin
<CodoxFillBGColorPlugin onColorChange={(latestColor) => { // invoked when bg color changes, }} buttonLable={btn title, defaults to ""} buttonAriaLabel={btn aria attr name, defaults to ""} buttonClassName={defaults to "toolbar-item color-picker"} buttonLabelClassName={defaults to "text dropdown-button-text"} buttonIconClassName={defaults to "icon bg-color"} dropdownClassName={defaults to "dropdown"} dropdownChevronClassName={defaults to "chevron-down"}/>
CodoxFontColorPlugin
Extra plugin to change text font color.
Plugin comes from built-in ui - button click on which opens a color palette for text color pick. Can be utilized in any other lexical plugin, for example in toolbar plugin.
Plugin has set of optional props for css customization and listen to color changes:
import {CodoxFontColorPlugin} from "@codoxhq/lexical-provider"
// somewhere in toolbar plugin
<CodoxFontColorPlugin onColorChange={(latestColor) => { // invoked when bg color changes }} buttonLable={btn title, defaults to ""} buttonAriaLabel={btn aria attr name, defaults to ""} buttonClassName={defaults to "toolbar-item color-picker"} buttonLabelClassName={defaults to "text dropdown-button-text"} buttonIconClassName={defaults to "icon font-color"} dropdownClassName={defaults to "dropdown"} dropdownChevronClassName={defaults to "chevron-down"}/>
Undo/Redo support for Codox sync
Codox sync supports undo/redo out-of-the-box. To enable undo/redo support, should use built-in lexical HistoryPlugin:
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
Sync for undo/redo relies on history plugin built-in implementation of undo/redo stacks.
Important: It is not guaranteed to have correct and stable sync for undo/redo in case of using any custom undo/redo implementations
Basic Codox sync intengration example code snippet
// main editor component
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*/];/* * Wrap all nodes classes with Codox registerNodesWithCodox util. * This step is critical for correct sync work. */const LEXICAL_NODES_TO_REGISTER = registerNodesWithCodox([...nodes]);
export default function App() { const currentDocId = 'demo_doc_id'; // flag for codox start const [codoxStarted, setCodoxStarted] = useState(false);
// codox provider ref for start/stop api const codoxProviderRef = useRef();
const startCodox = (initState) => { if (codoxProviderRef.current) { /** * Should start codox with initial state for current doc id * Codox will apply initial state to editor */ codoxProviderRef.current.start(initState); setCodoxStarted(true); } }; const stopCodox = () => { if (codoxProviderRef.current) { codoxProviderRef.current.stop(); } setCodoxStarted(false); };
// fetch init state on mount and start codox sync useEffect(() => { (async () => { // Stop sync if already running if (codoxStarted) { stopCodox(); }
let response = await fetch(/*url for fetching init state*/); let initState = response.json();
// wrap in try-catch: validateStateStructure may throw if state is invalid try { // validate state from backend validateStateStructure(initState, LEXICAL_NODES_TO_REGISTER); // if invalid - will throw // start codox sync with init state startCodox(initState); } catch (err) { // do custom logic here, e.g. fetch retry or else } })(); }, []);
/** * Initial config for Lexical Composer - init it only once */ const initLexicalConfig = useMemo(() => { return { editorState: null, // use null as init state, when init state is fetched, it will be applied by codox namespace: `Demo`, // can use own namespace name, "Demo" is for example here nodes: LEXICAL_NODES_TO_REGISTER, // should wrap nodes into codox register fn onError: (error) => { // custom error handler, can do smth custom here },
// css theme for styling editor - see lexcial docs for details // theme: CustomTheme, }; }, []);
// triggered by CodoxCollabPlugin on content state changes const onEditorStateChange = ({ docId, state, isRemoteChange = false } = {}) => { /** * Should save to database ONLY on local changes - when isRemoteChange flag is false */ if (isRemoteChange) return;
/* do custom logic here to save state to database */ };
// triggered by CodoxCollabPlugin when blacklisted content combination rejected let onBlacklistedInsert = () => { /* do custom logic here, e.g. notify user that content change was rejected.
Note: no need to do any other actions, like restart sync or else, CodoxCollabPlugin handles such cases internally. Content state will remain same as before blacklisted content insert */ };
const Placeholder = () => <div className="Placeholder__root">{'Enter some rich text...'}</div>;
return ( <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 // Important to put it inside LexicalComposer component as other plugins
ref={codoxProviderRef} config={{ //NOTE: initState should be passed into codoxProviderRef start() call docId: currentDocId, apiKey: process.env.REACT_APP_CODOX_API_KEY, // api key provided by codox username: 'demoUserName', // client user name - use real unique username here instead of demo name onEditorStateChange: onEditorStateChange, // callback to trigger on state changes onBlacklistedInsert: onBlacklistedInsert, // callback to trigger when attempt to insert/paste blacklisted content combination }} /> </LexicalComposer> );}
Extended Codox sync intengration example code snippet with comments, enabled extra plugins and undo/redo
Main editor component code:
// main editor component
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 { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
// custom toolbar componentimport ToolbarPlugin from './ToolbarPlugin';
import { CodoxCollabPlugin, registerNodesWithCodox, validateStateStructure, CodoxCommentPlugin,} from '@codoxhq/lexical-provider';
const nodes = [ /*Put all nodes classes here to register in lexical*/];/* * Wrap all nodes classes with Codox registerNodesWithCodox util. * This step is critical for correct sync work. */const LEXICAL_NODES_TO_REGISTER = registerNodesWithCodox([...nodes]);
export default function App() { const currentDocId = 'demo_doc_id'; // flag for codox start const [codoxStarted, setCodoxStarted] = useState(false);
// codox provider ref for start/stop api const codoxProviderRef = useRef();
const startCodox = (initState) => { if (codoxProviderRef.current) { /** * Should start codox with initial state for current doc id * Codox will apply initial state to editor */ codoxProviderRef.current.start(initState); setCodoxStarted(true); } }; const stopCodox = () => { if (codoxProviderRef.current) { codoxProviderRef.current.stop(); } setCodoxStarted(false); };
// fetch init state on mount and start codox sync useEffect(() => { (async () => { // Stop sync if already running if (codoxStarted) { stopCodox(); }
let response = await fetch(/*url for fetching init state*/); let initState = response.json();
// wrap in try-catch: validateStateStructure may throw if state is invalid try { // validate state from backend validateStateStructure(initState, LEXICAL_NODES_TO_REGISTER); // if invalid - will throw // start codox sync with init state startCodox(initState); } catch (err) { // do custom logic here, e.g. fetch retry or else } })(); }, []);
/** * Initial config for Lexical Composer - init it only once */ const initLexicalConfig = useMemo(() => { return { editorState: null, // use null as init state, when init state is fetched, it will be applied by codox namespace: `Demo`, // can use own namespace name, "Demo" is for example here nodes: LEXICAL_NODES_TO_REGISTER, // should wrap nodes into codox register fn onError: (error) => { // custom error handler, can do smth custom here },
// css theme for styling editor - see lexcial docs for details // theme: CustomTheme, }; }, []);
// triggered by CodoxCollabPlugin on content state changes const onEditorStateChange = ({ docId, state, isRemoteChange = false } = {}) => { /** * Should save to database ONLY on local changes - when isRemoteChange flag is false */ if (isRemoteChange) return;
/* do custom logic here to save state to database */ };
// triggered by CodoxCollabPlugin when blacklisted content combination rejected let onBlacklistedInsert = () => { /* do custom logic here, e.g. notify user that content change was rejected.
Note: no need to do any other actions, like restart sync or else, CodoxCollabPlugin handles such cases internally. Content state will remain same as before blacklisted content insert */ };
const Placeholder = () => <div className="Placeholder__root">{'Enter some rich text...'}</div>;
return ( <LexicalComposer initialConfig={initLexicalConfig}> {/*NOTE: plugins can be wrapped into any markup according to app's style*/}
{/*custom toolbar for editor*/} <ToolbarPlugin />
<RichTextPlugin contentEditable={<ContentEditable />} placeholder={<div>Enter some rich text...</div>} ErrorBoundary={LexicalErrorBoundary} />
{/*init history plugin for undo/redo support - see lexical docs for details*/} <HistoryPlugin />
{/*any other plugins here*/}
{/*init Codox colalboration plugin*/} <CodoxCollabPlugin // CodoxCollabPlugin can be inited here or inside EditorPlugins with other plugins // Important to put it inside LexicalComposer component as other plugins
ref={codoxProviderRef} config={{ //NOTE: initState should be passed into codoxProviderRef start() call docId: currentDocId, apiKey: process.env.REACT_APP_CODOX_API_KEY, // api key provided by codox username: 'demoUserName', // client user name - use real unique username here instead of demo name onEditorStateChange: onEditorStateChange, // callback to trigger on state changes onBlacklistedInsert: onBlacklistedInsert, // callback to trigger when attempt to insert/paste blacklisted content combination }} />
{/*init Codox comments plugin*/} <CodoxCommentPlugin /> </LexicalComposer> );}
Custom Toolbar component code:
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';import { CodoxFillBGColorPlugin, CodoxFontColorPlugin, INSERT_COMMENT, // special command to invoke new comment insert} from '@codoxhq/lexical-provider';
// your custom toolbar implementationexport default function ToolbarPlugin() { // get ref to current editor instance const [editor] = useLexicalComposerContext();
/* any custom logic for implementation of ui here */
/** * Example of how to trigger new comment insert from any plugin outside codox comments plugin. * Note: comment insert can be invoked from any other plugin. * * Dispatching the command will open modal for new comment insert on current selection. * Modal ui is a part of CodoxCommentPlugin implementation. */ const insertComment = () => { /* simply dispatch command, provided by codox provider, to editor. See the lexical docs for details of commands concepts. */ editor.dispatchCommand(INSERT_COMMENT, undefined); };
return ( <div> {/*all other ui elements here*/}
{/*will render btn for text color change - click will open color pallette*/} <CodoxFontColorPlugin onColorChange={(latestColor) => { //Callback is invoked when font color is changed - outputs latest applied color // console.log('[ToolbarPlugin]: latest font color applied: ', { latestColor }); }} buttonLabel="" // can pass custom btn name buttonAriaLabel="Formatting text color" // can use any own name // Set of css props - can customize elements with own classes buttonClassName="toolbar-item color-picker" buttonLabelClassName="text dropdown-button-text" buttonIconClassName="icon font-color" dropdownClassName="dropdown" dropdownChevronClassName="chevron-down" />
{/*will render btn for text bg color change - click will open color pallette*/} <CodoxFillBGColorPlugin onColorChange={(latestColor) => { // Callback is invoked when font color is changed - outputs latest applied color // console.log('[ToolbarPlugin]: latest bg fill color applied: ', { latestColor }); }} buttonLabel="" // can pass custom btn name buttonAriaLabel="Formatting background color" // can use any own name //Set of css props - can customize elements with own classes buttonClassName="toolbar-item color-picker" buttonLabelClassName="text dropdown-button-text" buttonIconClassName="icon bg-color" dropdownClassName="dropdown" dropdownChevronClassName="chevron-down" />
{/*simple example of button to invoke comment insert*/} <button onClick={insertComment}>Insert Comment</button> </div> );}