Creating a Collaborative Editor with Draftjs for Fun

June 28, 2020

Draft.Js is a popular rich text editor intended to be used with React. Unsurprisingly, it is also created by Facebook.

Like other rich text editors, Draft.js is a wrapper around contenteditable and the native Selection API. If you've ever worked with the native contenteditable and window.Selection you will know that they are a huge pain. Nick Santos wrote a very persuasive article about how contenteditable is mathematically doomed to fail.

DraftJs depends on immutable state management. This has a number of advantages. For example, since editorState is immutable, we can cheaply check whether a component should update:

class extends React.Component {
  shouldComponentUpdate(nextProps, nextState) {
    return nextProps.editorState !== this.props.editorState
  }
}

However immutability does not come without performance penalties. Immutability also contributes to code complexity - some things are not as easy as they should be in Draft.Js. For example, this is the code needed to insert an empty block into the editor.

Creating A Collaborative Editor

You've probably used Google docs. It's collaborative. If I'm editing the same document as my friend, Google Docs keeps track of and displays team member's changes simultaneously. Moreover, edits are annotated. Can the same thing be accomplished using Draft.Js?

Here's my first attempt (and here's the source). Note: a cookie is used, so in order for the server to detect two distinct users you'll need to open a new window in incognito or on a separate computer/browser.

The Strategy: WebSockets and Diff/Patch

Here are a few (probably obvious) observations about how this problem of collaborative editing can be tackled in DraftJs.

From the outset, we know that in order to make Draft.Js collaborative we'll need to use Web Sockets. This isn't 2006, the user shouldn't need to refresh the page to see edits.

Since our updates to editorState will be broadcasted over web sockets, we also know that we will need to frequently serialize and deserialize editorState. I'm talking about convertToRaw and convertFromRaw.

The client is only privy to its current editorState and whatever is broadcasted from the server via WebSocket.

Hence, in this scenario the server is the "source of truth." I suppose this wouldn't differ from the conventional situation where raw editorState is stored in a database. But I think the difference is that the flow of information is bidirectional: the client updates server state and vice-versa.

Since the server-side editorState is deserialized (i.e., not an immutable object but just a plan ole' javascript object), we don't need to worry about computing the deltas between immutable editorStates. We can just compute the deltas between all the raw states received from the clients.

So far we have:

  • We need web sockets
  • The raw editorState on the server is the "source of truth"
  • Clients (or users) emit their current state at regular intervals to the server

In response to an emission from the client, the server does three things:

  1. Calculate a delta between its raw state and the most recently emitted client state.
  2. Patches its own internal state.
  3. Broadcasts to all clients the calculated delta.

This sounds kind of rough but it's not bad at all with some the help of some third-party libraries. I used jsondiffpatch to get the deltas between states. This can be as simple as:

// we should wrap the raw serverState in a closure, but for brevity we'll do this
let rawServerState = {
  blocks: [],
  entityMap: {}
}
// this would come from the client via WebSocket
let rawClientState = {
  blocks: [{ text: 'lorem', key: 'A' }],  // some keys omitted here for brevity
  entityMap: {}
}
let delta = require('jsondiffpatch').diff(rawServerState, rawClientState)

The delta can then be used to patch the server state:

let patched = require('jsondiffpatch').patch(rawServerState, delta)

Here's a working example:

const j = require('jsondiffpatch')
const WebSocket = require('ws')
const http = require('http')
const PORT = process.env.PORT || 1234
const server = http.createServer(app)
const wss = new WebSocket.Server({ server })
const createStore = () => {
  let initialState = {
    blocks: [{text: ''}],
    entityMap: {}
  }
  let state = { ...initialState }
  let users = {}
  return {
    initialState,
    getState: () => state,
    patch: (diff) => {
      state = j.patch(state, diff)
    }
  } 
}
const store = createStore()
wss.on('connection', function connection (ws, req) {
  const token = req.headers.cookie && req.headers.cookie.split('=')[1]
  // When a new user connects, we need to get the delta between the server's current state
  // and the empty, initialState and emit that. The client can then patch
  // its own state when the window loads.
  const delta = j.diff(store.initialState, store.getState())
  ws.send(JSON.stringify({ delta })) 
  
  // each client will broadcast its state at a regular interval if there are changes.
  ws.on('message', function incoming (data) {
    let { raw } = JSON.parse(data) 
    // 1. Get the delta between the server's current state and the client-emitted state
    // note that delta will be null if there's no change.
    let delta = j.diff(store.getState(), raw)
    if (delta) {
      // 2. We need to patch the server state so that it doesn't become stale
      store.patch(delta)
      // 3. Emit the delta to all of the clients.
      wss.clients.forEach(function each (client) {
        if (client !== ws && client.readyState === WebSocket.OPEN) {
          client.send(JSON.stringify({ delta }))
        }
      })
    }
  })
})
server.listen(PORT, () => {
  console.log(`listening on http://localhost:${PORT}`)
})

Hence, we are leaning heavily on jsondiffpatch to do the heavy lifting.

Now let's look at the client side.

import React from 'react'
import { Editor, EditorState, convertToRaw, convertFromRaw } from 'draft-js'
import debounce from 'debounce'
import j from 'jsondiffpatch'
class CollaborativeEditor extends React.Component {
  state = {
    editorState: EditorState.createEmpty()
  }
  
  _isUnmounted = false
    
  componentDidMount () {
    let host = window.location.origin.replace(/^http/, 'ws')
    this.ws = new window.WebSocket(host)
    this.ws.onmessage = (event) => {
      if (this._isUnmounted) return
      this.handleMessage(JSON.parse(event.data))
    }
  }
    
  componentWillUnmount () {
    this.ws.close()
    delete this.ws
    this._isUnmounted = true
  }
  
  handleMessage = ({ delta }) => {
    if (!delta) return
    let raw = convertToRaw(this.state.editorState.getCurrentContent())
    let nextContentState = convertFromRaw(j.patch(raw, delta))
    this.setState({
      editorState: EditorState.push(editorState, nextContentState)
    })
  }
 
  broadcast = debounce((editorState) => {
    if (!this.ws) return
    this.ws.send(JSON.stringify({
      raw: convertToRaw(editorState.getCurrentContent())
    }))
  }, 300)
 
  onChange = editorState => {
    this.broadcast(editorState)
    this.setState({ editorState })
  }
  render () {
    return (
      <Editor 
        editorState={this.state.editorState}
        onChange={this.onChange}
      />
    )
  }
}

As you might expect, we:

  • Use the componentDidMount and componentWillUnmount lifecycle hooks to manage the client WebSocket connection.
  • Whenever onChange is called by the Draft Editor, we also invoke this.broadcast. The broadcast class method is debounced for obvious reasons (to avoid flooding the channel). We broadcast to the server the client's current raw, serialized state ever 300 ms using the third-party library debounce.
  • When the client receives a delta from the server, we again use convertToRaw to get the most recent raw client-side state.  We then patch the client side state (using jsondiffpatch) and then unmarshal the result to rehydrate the client-side editorState. 

In part two, we'll discuss selection state bookkeeping in collaborative editing. For example, we want to attribute another user's edits to a document to that user rather than the current, editing user.