iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
✍️

Trying out Lexical + Yjs

に公開

Introduction

This is an article about when I tried lexical (Text Editor) developed by Facebook and yjs.

lexical

https://lexical.dev

An extensible text editor framework

It has the following features:

  • Reliable
    • Composed of editor instances attached to each editable element.
    • Each editor state represents the current state and pending state at any given point in time.
  • Accessible
    • Follows best practices established by WCAG (Web Content Accessibility Guidelines) and is compatible with screen readers and other assistive technologies.
  • Fast
    • UI components, toolbars, rich text features, markdown, etc., can be added as plugins.
  • Cross Platform
    • Available as a JavaScript framework / Swift framework for native iOS development.

Lexical's Design

image1.png

As shown in the diagram above, Lexical's core is designed to be independent of the contenteditable HTML element and the state.

  • Editor instances
    • The core part that links the contenteditable HTML element and allows updating the EditorState.
  • Editor States
    • The underlying data model representing what should be displayed on the DOM.
    • An immutable object.
    • Mainly has two parts:
      • Lexical Node Tree
      • Lexical Selection object

Collaboration

Lexical provides LexicalCollaborationPlugin and useCollaborationContext hooks in @lexical/react. This is implemented based on the yjs bindings provided by @lexical/yjs.

image2.png

Environment Setup and Preparation

1. devcontainer Environment Setup

1-1. Base Environment

I will use this again to set up the environment.

https://github.com/Slowhand0309/nodejs-devcontainer-boilerplate

I will create a new project called lexical-yjs-example using this as a template and proceed.

1-2. React Environment Setup

$ yarn create vite . --template react-swc-ts
? Current directory is not empty. Please choose how to proceed: › - Use arrow-keys. Return to submit.
> Ignore files and continue
  • Modify scripts > dev in package.json as follows:
    vite --host=0.0.0.0

  • Add the following port to .devcontainer/docker-compose.yml:

        ports:
          - "5173:5173"
    
  • Add the following to .devcontainer/postAttach.sh:

    yarn
    yarn dev
    

Now, start the devcontainer and access localhost:5173. If the usual screen is displayed, you're good to go.

2. Package Installation

yarn add lexical @lexical/react

Implementation

1. Simple Editor Implementation

1-1. Creating Editor.tsx

Create src/Editor.tsx with the following content:

import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";

const theme = {};

const onError = (error: Error) => {
  console.error(error);
};

const Editor = () => {
  const initialConfig = {
    namespace: "MyEditor",
    theme,
    onError,
  };

  return (
    <LexicalComposer initialConfig={initialConfig}>
      <RichTextPlugin
        contentEditable={<ContentEditable />}
        placeholder={<div>Enter some text...</div>}
        ErrorBoundary={LexicalErrorBoundary}
      />
      <HistoryPlugin />
      <AutoFocusPlugin />
    </LexicalComposer>
  );
};

export default Editor;

1-2. Modifying main.tsx

Modify src/main.tsx as follows:

import React from "react";
import ReactDOM from "react-dom/client";
import Editor from "./Editor.tsx";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <Editor />
  </React.StrictMode>
);

At this point, if you access http://localhost:5173/, you should see a simple editor like the one below.

image3.gif

2. Enabling Collaborative Editing

2-1. Modifying to Start the y-websocket Server

I will make it so that y-websocket starts when the container is launched. First, add the concurrently package to allow running multiple npm commands simultaneously.

yarn add -D concurrently

Next, modify the scripts in package.json.

  "scripts": {
    "dev": "concurrently \"vite --host=0.0.0.0\" \"npm:server:ws\"",
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "server:ws": "HOST=0.0.0.0 PORT=1234 YPERSISTENCE=./yjs-wss-db npx y-websocket"
  },

Finally, add the port to .devcontainer/docker-compose.yml.

    ports:
      - "1234:1234" # Added
      - "5173:5173"

Now, when you restart the container, the y-websocket server should also be running.

2-1. Adding the Collaboration Plugin to Lexical

Install the necessary packages.

yarn add @lexical/yjs y-websocket yjs

Modify the src/Editor.tsx created earlier as follows:

import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
import { CollaborationPlugin } from "@lexical/react/LexicalCollaborationPlugin";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { Provider } from "@lexical/yjs";
import { useCallback } from "react";
import { WebsocketProvider } from "y-websocket";
import * as Y from "yjs";

const theme = {};

const onError = (error: Error) => {
  console.error(error);
};

// Add the following method
const getDocFromMap = (id: string, yjsDocMap: Map<string, Y.Doc>): Y.Doc => {
  let doc = yjsDocMap.get(id);

  if (doc === undefined) {
    doc = new Y.Doc();
    yjsDocMap.set(id, doc);
  } else {
    doc.load();
  }

  return doc;
};

const Editor = () => {
  const initialConfig = {
    editorState: null, // Required to let CollaborationPlugin set EditorState
    nodes: [],
    namespace: "MyEditor",
    theme,
    onError,
  };

  // Add the following method
  const providerFactory = useCallback(
    (id: string, yjsDocMap: Map<string, Y.Doc>): Provider => {
      const doc = getDocFromMap(id, yjsDocMap);

      // Force cast since the official documentation doesn't seem to handle casting well either
      return new WebsocketProvider(
        "ws://localhost:1234",
        id,
        doc
      ) as unknown as Provider;
    },
    []
  );

  // Add CollaborationPlugin
  return (
    <LexicalComposer initialConfig={initialConfig}>
      <CollaborationPlugin
        id="lexical/collab"
        providerFactory={providerFactory}
        shouldBootstrap={false}
      />
      <RichTextPlugin
        contentEditable={<ContentEditable />}
        placeholder={<div>Enter some text...</div>}
        ErrorBoundary={LexicalErrorBoundary}
      />
      <HistoryPlugin />
      <AutoFocusPlugin />
    </LexicalComposer>
  );
};

export default Editor;

Restart the container again and edit the editor area in multiple tabs. If it synchronizes as shown below, you're all set.

image4.gif

Reference URLs

Discussion