loading...
Cover image for Let's Build a Collaborative Rich Text Editor

Let's Build a Collaborative Rich Text Editor

kannndev profile image Kannan ・4 min read

Hello Everyone👋,

In this article we will see how to build a collabrative rich text editor.

If you already know how the collabration works realtime feel free to skip the theory section.

Theory:

To build a collabrative one we need to know how to handle the conflicts during collabration.

There are two most widely used algorithms to handle the conflicts:

  1. Operational Transformation
  2. Conflict-free replicated data type

Operational Transformation:

Operational Transformation (OT) is an algorithm/technique for the transformation of operations such that they can be applied to documents whose states have diverged, bringing them both back to the same state.

This works in client-server model.

A Quick Overview how it works:

  • Every action(insert or delete) is represented as an operation.
  • These actions are sent to the server where each operation is applied to the document and broadcasts to the other clients.
  • In case of conflicts the server's transform function takes two operations as inputs and tries to apply the second operation preserving the first operations intended change.

OT

This technique is used by Google Docs, Google Slides, Wave etc.

Js libraries based on OT: sharedb

Conflict-free replicated data type:

Conflict-free Replicated Data Type (CRDT) is a set of data structures that can be replicated across network and can guarantee the data to be consistent and correct eventually. Those data structure do not make assumptions on how the data are replicated, or the order of the data it arrives.

There are a lot of different CRDT algorithms that allow the implementation of shared types. Some CRDTs work with Peer to peer (mostly) message propagation, some rely on client-server models.

A Quick Overview how it works:

Since there are lot of approaches out there, on high level

  • All the operations are broadcasted to all the clients first
  • when there is a conflict they are resolved in such a way that
T(o1, o2) == T(o2, o1)
Enter fullscreen mode Exit fullscreen mode

The result of two operation must be equal irrespective of the order of the operations. So that it final result is same across all clients.

CRDT

This technique is used by Figma, Apple Notes etc.

Js libraries based on CRDT: Yjs, Automerge

Note: OT and CRDT are much more complex than the short overview above. If you are planning to implement yourself read the research papers for better understanding.

Code:

To implement this we will be using the following Js Libraries

  1. React
  2. Nodejs
  3. QuillJs
  4. Websockets
  5. Sharedb
  6. websocket-json-stream
  7. Rich-text

Set up the Server:

touch app.js
yarn add ws sharedb rich-text @teamwork/websocket-json-stream
Enter fullscreen mode Exit fullscreen mode
const WebSocket = require('ws');
const WebSocketJSONStream = require('@teamwork/websocket-json-stream');
const ShareDB = require('sharedb');

/**
 * By Default Sharedb uses JSON0 OT type.
 * To Make it compatible with our quill editor.
 * We are using this npm package called rich-text
 * which is based on quill delta
 */
ShareDB.types.register(require('rich-text').type);

const shareDBServer = new ShareDB();
const connection = shareDBServer.connect();

/**
 * 'documents' is collection name(table name in sql terms)
 * 'firstDocument' is the id of the document
 */
const doc = connection.get('documents', 'firstDocument');

doc.fetch(function (err) {
  if (err) throw err;
  if (doc.type === null) {
    /**
     * If there is no document with id "firstDocument" in memory
     * we are creating it and then starting up our ws server
     */
    doc.create([{ insert: 'Hello World!' }], 'rich-text', () => {
      const wss = new WebSocket.Server({ port: 8080 });

      wss.on('connection', function connection(ws) {
        // For transport we are using a ws JSON stream for communication
        // that can read and write js objects.
        const jsonStream = new WebSocketJSONStream(ws);
        share.listen(jsonStream);
      });
    });
    return;
  }
});
Enter fullscreen mode Exit fullscreen mode

Sharedb uses an in-memory data store. To persist the data we can use MongoDB, PostgresQL adaptor.

Set up the Client:

Let us create a react app using create-react-app and add the dependencies.

npx create-react-app collaborative-rte
cd collaborative-rte
yarn add sharedb rich-text quill
Enter fullscreen mode Exit fullscreen mode

Note: React-quill which is a unofficial react wrapper over quill js also can be used. Personally I like to use quilljs as it has better docs.

Our editor component:

import React, { useEffect } from 'react';
import Quill from 'quill';
import 'quill/dist/quill.bubble.css';
import Sharedb from 'sharedb/lib/client';
import richText from 'rich-text';

// Registering the rich text type to make sharedb work
// with our quill editor
Sharedb.types.register(richText.type);

// Connecting to our socket server
const socket = new WebSocket('ws://127.0.0.1:8080');
const connection = new Sharedb.Connection(socket);

// Querying for our document
const doc = connection.get('documents', 'firstDocument');

function App() {
  useEffect(() => {
    doc.subscribe(function (err) {
      if (err) throw err;

      const toolbarOptions = ['bold', 'italic', 'underline', 'strike', 'align'];
      const options = {
        theme: 'bubble',
        modules: {
          toolbar: toolbarOptions,
        },
      };
      let quill = new Quill('#editor', options);
      /**
       * On Initialising if data is present in server
       * Updaing its content to editor
       */
      quill.setContents(doc.data);

      /**
       * On Text change publishing to our server
       * so that it can be broadcasted to all other clients
       */
      quill.on('text-change', function (delta, oldDelta, source) {
        if (source !== 'user') return;
        doc.submitOp(delta, { source: quill });
      });

      /** listening to changes in the document
       * that is coming from our server
       */
      doc.on('op', function (op, source) {
        if (source === quill) return;
        quill.updateContents(op);
      });
    });
    return () => {
      connection.close();
    };
  }, []);

  return (
    <div style={{ margin: '5%', border: '1px solid' }}>
      <div id='editor'></div>
    </div>
  );
}

export default App;

Enter fullscreen mode Exit fullscreen mode

let us start the server now and run the react application. Open App in two windows and type something. We could see that it is in sync between tabs.

Output

Feel free to playaround with the code here:
React App
Server

Please like and share if you find this interesting.

Discussion

pic
Editor guide