How to set up Lexical editor in React Native

on Dragoș Străinu's blog

So you have a web app that uses Lexical editor and now you want to build the same editor on React Native, or maybe you just want to build a rich text editor in React Native using Lexical. But unfortunately lexical does not support React Native at the moment.

So the idea that comes to mind is to try Lexical editor in a react-native-webview. There are similar solutions like react-native-cn-quill and react-native-rich-editor.

But as expo docs say:

If you need more configurability, you can build a similar library with an existing web-only editor.

So let's implement a lexical editor in an Expo app.
(This is a demo project but you can follow the steps to add the editor to an existing React Native app)

Create the expo app

npx create-expo-app -t expo-template-blank-typescript

Now let's install react-native-webview.

npm i react-native-webview

And make some changes in App.tsx, to display the WebView with https://playground.lexical.dev/ (for now).

// ./App.tsx
import { StyleSheet, Text, View } from "react-native";
import WebView from "react-native-webview";

export default function App() {
  return (
    <View style={styles.container}>
      <View style={{ width: "100%", height: "80%" }}>
        <Text>Lexical Webview</Text>
        <WebView
          source={{ uri: "https://playground.lexical.dev" }}
          style={{ marginTop: 20 }}
        />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#e0e0e0",
    alignItems: "center",
    justifyContent: "center",
  },
});

We should have this result:

expo-webview-init

Add lexical editor

Other implementations for a rich text editor in react-native give us a predefined editor. However, we want to create our custom lexical editor with custom nodes and logic. So the only option is to create the editor from scratch.
How? By creating a new app inside the project using npm workspaces.

We add a workspace to package.json:

   "name": "react-native-lexical",
   "version": "1.0.0",
   "main": "node_modules/expo/AppEntry.js",
+  "workspaces": [
+    "lexical-editor"
+  ],
   "scripts": {
     "start": "expo start",
     "android": "expo start --android",

We can name it how we want post-editor, page-editor. Also we can have multiple editors if needed.

Create web app as npm workspace

For this, we will use vite.

npm create vite@latest lexical-editor -- --template react-ts

Let's do a cleanup and some changes in lexical-editor app.

lexical-editor
├── README.md
├── index.html
├── package.json
├── src
│   ├── App.tsx
│   ├── index.css
│   ├── main.tsx
│   └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts

Important to not enable zoom in the WebView, add this to index.html:

<meta
  name="viewport"
  content="width=device-width, initial-scale=1, maximum-scale=0.99, user-scalable=0"
/>

Now we can run the web app:

# in the root of expo app, we run script from lexical-editor workspace
npm --workspace=lexical-editor run dev

And we can see this in the browser:

lexical-vite-init

Cool, now how can we put this into react-native-webview?!

Build a web app for react-native-webview

For this, we need some vite plugins that will build the app into a single file that we will import into react-native-webview.

npm -w=lexical-editor i -D vite-plugin-singlefile

and let's change the vite.config.ts:

// .lexical-editor/vite.config.ts
import fs from "fs";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
import react from "@vitejs/plugin-react";

// https://vitejs.dev/config/
export default defineConfig({
  build: {
    watch: {
      include: ["src/**"],
      buildDelay: 500,
    },
  },
  plugins: [
    react(),
    viteSingleFile(),
    {
      name: "vite-plugin-html-string",
      closeBundle() {
        const bundle = fs.readFileSync("dist/index.html", "utf8");

        const escaped = JSON.stringify(bundle);
        const js = `export default ${escaped}`;

        fs.writeFileSync("dist/htmlString.ts", js);
      },
    },
  ],
});

This config watches for changes and builds the app into a single index.html file that we next write into a file named htmlString.ts so we can import it into react-native.

Now we can run:

npm -w=lexical-editor run build

And vite will build the web editor on every change.

Also, we need to build the app on install:

   "scripts": {
     "dev": "vite",
     "build": "tsc && vite build",
+    "prepare": "npm run build -- --no-watch",

Only now we can import the output of the web app into react-native-webview:

 import { StyleSheet, Text, View } from "react-native";
 import WebView from "react-native-webview";
+import htmlString from "./lexical-editor/dist/htmlString";

 export default function App() {
   return (
         <Text>Lexical Webview</Text>
         <WebView
           originWhitelist={["*"]}
-          source={{ uri: "https://playground.lexical.dev" }}
+          source={{ html: htmlString }}
           style={{ marginTop: 20 }}
         />
       </View>

lexical-vite-webview

Create a basic lexical editor

Following https://lexical.dev/docs/getting-started/react

npm -w=lexical-editor install --save lexical @lexical/react

We can create a basic lexical editor:

// lexical-editor/src/Editor.tsx
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import "./Editor.css";

function onError(error: unknown) {
  console.error(error);
}

export function Editor() {
  const initialConfig = {
    namespace: "MyEditor",
    onError,
  };

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <div className="editor-container">
        <PlainTextPlugin
          contentEditable={<ContentEditable className="editor-input" />}
          placeholder={
            <div className="editor-placeholder">Enter some text...</div>
          }
          ErrorBoundary={LexicalErrorBoundary}
        />
        <HistoryPlugin />
      </div>
    </LexicalComposer>
  );
}

basic-lexical-editor

Message from WebView to React Native

Let's add the OnChangePlugin and post a message to react-native:

+import { $getRoot, EditorState } from "lexical";
 import { LexicalComposer } from "@lexical/react/LexicalComposer";
 import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
 import { ContentEditable } from "@lexical/react/LexicalContentEditable";
 import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
+import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
 import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
 import "./Editor.css";

@@ -15,6 +17,22 @@ export function Editor() {
     onError,
   };

+  function onChange(editorState: EditorState) {
+    editorState.read(() => {
+      const plainText = $getRoot().getTextContent();
+
+      const message = {
+        type: "LEXICAL_EDITOR_STATE_CHANGE",
+        payload: {
+          plainText,
+          serializedEditorState: editorState.toJSON(),
+        },
+      };
+
+      window.ReactNativeWebView?.postMessage(JSON.stringify(message));
+    });
+  }
+
   return (
     <LexicalComposer initialConfig={initialConfig}>
       <div className="editor-container">
 @@ -26,6 +44,7 @@ export function Editor() {
           ErrorBoundary={LexicalErrorBoundary}
         />
         <HistoryPlugin />
+        <OnChangePlugin onChange={onChange} />
       </div>
     </LexicalComposer>
   );

Ok, now we can receive this message on the react-native side:

 import { StyleSheet, Text, View } from "react-native";
-import WebView from "react-native-webview";
+import WebView, { WebViewMessageEvent } from "react-native-webview";
 import htmlString from "./lexical-editor/dist/htmlString";

 export default function App() {
+  function onMessage(event: WebViewMessageEvent) {
+    const message = JSON.parse(event.nativeEvent.data);
+    console.log(JSON.stringify(message, null, 2));
+  }
+
   return (
     <View style={styles.container}>
       <View style={{ width: "100%", height: "80%" }}>
         <Text>Lexical Webview</Text>
         <WebView
           originWhitelist={["*"]}
 +          onMessage={onMessage}
           source={{ html: htmlString }}
           style={{ marginTop: 20 }}
         />

When we type something in the WebView editor we receive:

{
  "type": "LEXICAL_EDITOR_STATE_CHANGE",
  "payload": {
    "plainText": "Hi",
    "serializedEditorState": {
      "root": {
        "children": [
          {
            "children": [
              {
                "detail": 0,
                "format": 0,
                "mode": "normal",
                "style": "",
                "text": "Hi",
                "type": "text",
                "version": 1
              }
            ],
            "direction": "ltr",
            "format": "",
            "indent": 0,
            "type": "paragraph",
            "version": 1
          }
        ],
        "direction": "ltr",
        "format": "",
        "indent": 0,
        "type": "root",
        "version": 1
      }
    }
  }
}

We can do a switch on message.type and handle any messages:

function onMessage(event: WebViewMessageEvent) {
  const message = JSON.parse(event.nativeEvent.data);
  console.log(JSON.stringify(message, null, 2));

  switch (message.type) {
    case "LEXICAL_EDITOR_STATE_CHANGE":
      // Do something with the editor state
      // like saving it to a database
      break;
    default:
      console.error("Unknown message type", message);
  }
}

Command from React Native to Webview

Let's say, we want to initialize the editor state when it is mounted.

We listen for LEXICAL_EDITOR_READY message and post a message to webview using WebView ref.

...
+import { useRef } from "react";

 export default function App() {
+  const webviewRef = useRef<WebView>(null);
+
   function onMessage(event: WebViewMessageEvent) {
     const message = JSON.parse(event.nativeEvent.data);
     console.log(JSON.stringify(message, null, 2));
     ...
         // Do something with the editor state
         // like saving it to a database
         break;
+      case "LEXICAL_EDITOR_READY":
+        const commandMessage = {
+          command: "INIT_SERIALIZED_EDITOR_STATE",
+          payload: {
+            root: {
+              children: [
+                {
+                  children: [
+                    {
+                      detail: 0,
+                      format: 0,
+                      mode: "normal",
+                      style: "",
+                      text: "Initial text",
+                      type: "text",
+                      version: 1,
+                    },
+                  ],
+                  direction: "ltr",
+                  format: "",
+                  indent: 0,
+                  type: "paragraph",
+                  version: 1,
+                },
+              ],
+              direction: "ltr",
+              format: "",
+              indent: 0,
+              type: "root",
+              version: 1,
+            },
+          },
+        };
+
+        webviewRef.current?.postMessage(JSON.stringify(commandMessage));
+        break;
       default:
         console.error("Unknown message type", message);
     }
       <View style={{ width: "100%", height: "80%" }}>
         <Text>Lexical Webview</Text>
         <WebView
 +          ref={webviewRef}
           originWhitelist={["*"]}
           onMessage={onMessage}
           source={{ html: htmlString }}

Now we need to handle this in Lexical editor. We can create a custom plugin.

import { useEffect } from "react";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";

export const EditorStateInitPlugin = () => {
  const [editor] = useLexicalComposerContext();

  useEffect(() => {
    const message = {
      type: "LEXICAL_EDITOR_READY",
    };

    window.ReactNativeWebView?.postMessage(JSON.stringify(message));
  }, []);

  useEffect(() => {
    const listener = (e: MessageEvent<string>) => {
      const message = JSON.parse(e.data);

      if (message.command === "INIT_SERIALIZED_EDITOR_STATE") {
        editor.setEditorState(editor.parseEditorState(message.payload), {
          tag: "FromReactNativeToLexical",
        });
      }
    };
    window.addEventListener("message", listener);

    return () => {
      window.removeEventListener("message", listener);
    };
  }, [editor]);

  return null;
};

And use it in our Editor.tsx:

-import { $getRoot, EditorState } from "lexical";
+import { $getRoot, EditorState, LexicalEditor } from "lexical";
 import { LexicalComposer } from "@lexical/react/LexicalComposer";
 import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
 import { ContentEditable } from "@lexical/react/LexicalContentEditable";
 import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
 import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
 import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
+import { EditorStateInitPlugin } from "./plugins/EditorStateInitPlugin";
 import "./Editor.css";

...

-  function onChange(editorState: EditorState) {
+  function onChange(
+    editorState: EditorState,
+    _latestEditor: LexicalEditor,
+    tags: Set<string>,
+  ) {
+    if (tags.has("FromReactNativeToLexical")) {
+      return;
+    }
     editorState.read(() => {
       const plainText = $getRoot().getTextContent();

...

           ErrorBoundary={LexicalErrorBoundary}
         />
         <HistoryPlugin />
+        <EditorStateInitPlugin />
         <OnChangePlugin onChange={onChange} />
       </div>
     </LexicalComposer>

Let's see it in action:

lexical-initial-state

What's next?

Now you can add the desired nodes and plugins into editor. Also, add new messages and commands for communication between React Native and Lexical editor in WebView.

For the full code example, you can check the playground project on GitHub, react-native-lexical.