Easy collaborative editor with tiptap and ProseMirror9 min read

A month ago, I wrote a small tutorial introducing tiptap collaborative Editor. Since then, I have made two packages out of it. Though it is not mandatory to read it first, it will help you understand how those packages work under the hood.

The goal of this article is to show you how to use both of those packages together to add a nicely featured collaborative rich text editor to your VueJS application.

The ProseMirror socket server

To synchronize many editors, each of them must connect to a socket server. The server will collect modifications made on each editor and dispatch them to every other editor. This way every editor is always synchronized with one another.

The tiptap-collab-server package can handle many different documents at the same time, and for each of them it handles text modifications of course, cursors and selections. It also provides hooks for programmers to implement connection guards (if you want to secure access to documents) or do some custom actions when a user connects.

This package is provided with a functional example, so open-up your terminal and navigate to your projects folder. Then :

# Clone the repository
git clone git@github.com:naept/tiptap-collab-server.git

# Install dependencies
npm install

# Compile the library
npm run build

# And run the example server
npm run serve-example

Congratulations, half the work is done!

Before going any further, let’s check the code of this example out, and what this package has to offer.

The CollabServer object

First step is importing the package:

import CollabServer from 'tiptap-collab-server'

And creating a CollabServer object:

new CollabServer({
  port: 6002,
  namespaceFilter: /^\/[a-zA-Z0-9_/-]+$/,
  lockDelay: 1000,
  lockRetries: 10,
})

Several parameters are available. You can specify the port on which you want to serve the socket server. I’ve found out that port 6000 was blocked by my web browser, so port 6002 it is.

The namespaceFilter is a regular expression used to extract the name of the namespace that the user wants to join from the URL. So if a user connects to a web-socket using the URL http://localhost:6002/awesome-namespace, he will connect to the namespace named awesome-namespace.

For this first release, the tiptap-collab-server package uses files for a database, like in the example provided by tiptap. Those files need to be locked to prevent two or more users to apply modifications at the same time on one document. When a file is locked, the server will try again after a period of time (that’s lockDelay), and it will try a given number of times (that’s lockRetries) before giving up and throwing an error.

Except for the port number, you needn’t worry about those parameters.

The CollabServer provides a few hook functions. All of them work as Promises. The first parameter is an object containing some arguments you can use inside the hook function. The second parameter is a function named resolve, which resolves the promise when called. And the third parameter is a function named reject, which rejects the promise when called. Let’s dive into those functions.

The connection guard

When a user joins a room, which will happen when a new editor is created on the client side (we’ll see this a little further), the connectionGuard function is called. It provides a few arguments that you can use, to connect the user to a back-end for example :

  • namespaceName: It’s a String containing the namespace name, the one extracted from the URL.
  • roomName: It’s a String containing the room name, also extracted from the URL. It’s the part after the slash following the namespace.
  • clientID: It’s a String containing the ID of the client connected to the server. This ID is defined on the client side. We’ll talk about it again later.
  • requestHeader: It’s a pointer to the socket request headers. You might need it to pass along those headers to a back-end, to identify the connecting user for example.
  • options: It’s an Object that you can define on the client side. Some options that you might want to pass along to use on the server side.

The client connect hook

After passing the connection guard, the onClientConnect function will be called. The arguments it provides are similar to the connectionGuard‘s ones:

  • namespaceName
  • roomName
  • clientID
  • requestHeaders
  • clientsCount

This last one is a Number stating the number of clients currently connected to this particular document.

The document initialization hook

Then the initDocument function is called. At this time, the document content and its version have been retrieved from the database and are passed as arguments to this function with the usual:

  • namespaceName
  • roomName
  • clientID
  • requestHeaders
  • clientsCount
  • version: It’s a Number with the current collaborative document version (see ProseMirror documentation)
  • doc: It’s a prosemirror-model Node object containing the current document content.

When calling the resolve function here, you may pass an object with the version and doc attributes. If you do, the current document and version will be overwritten with these.

I personally use this hook to retrieve the document from back-end when the first user connects to it. In the tiptap-collab-server package’s example, it is used to create a non empty document when a first user connects to it.

The document leave hook

When a client disconnects from the collaborative document, the leaveDocument function is called, giving one more argument than initDocument:

  • namespaceName
  • roomName
  • clientID
  • requestHeaders
  • clientsCount
  • version
  • doc
  • deleteDatabase

deleteDatabase is a function that deleted the current document from the database.

It is used in the example to delete all files linked to this document from the database when the last connected user disconnects from a collaborative document. I personally send the new document version to the back-end before deleting the database. This way, the socket server project directory is not cluttered with unused files.

The client disconnect hook

Finally, the onClientDisconnect function is called with the same arguments as onClientConnect.

All those hook functions return the CollabServer, so they are chainable.

Launch the collaborative server

The last thing to do is call the serve function, that will actually launch the collaborative server.

Well! You’re all set, and you know everything… about the server side of this tiptap collaborative editor. Let’s turn ourselves to the client side.

The tiptap collaboration extension

The tiptap-extension-collaboration package represents the other side of the mirror. It is inspired by the one provided by tiptap, with a few upgrades.

First of all, the socket management is internalized. No need to worry about opening and closing, or managing the events. All you need to provide is a few parameters that I will detail in the next part of this article.

An example is provided with the tiptap-extension-collaboration package as well, so once again, open-up your terminal and navigate to your projects folder. Then :

# Clone the repository
git clone git@github.com:naept/tiptap-extension-collaboration.git

# Install dependencies
yarn install

# Compile the library
npm run build

# And run the example
npm run serve-example

It will open your browser to http://localhost:8080. And as it is a collaborative editor, why not duplicate that browser instance ?

Screenshot tiptap collaborative example

Try typing in one of the editors. You will see all the other editors keep in sync, and even show the cursor. If you select some text, you will see the selection appear in every other editor. Isn’t that great ?

Now let’s see how you can use this tiptap-extension-collaboration package in your own project.

Set collaboration extension up

The tiptap collaboration extension needs to be declared like any other tiptap extension:

import { Collaboration } from 'tiptap-extension-collaboration'

new Editor({
  extensions: [
    new Collaboration({
      socketServerBaseURL: 'http://localhost:6002',
      namespace: 'Directory-A',
      room: 'Document-1',

      clientID: String(Math.floor(Math.random() * 0xFFFFFFFF)),
      joinOptions: {},

      debounce: 250,
      keepFocusOnBlur: false,

      onConnected: () => {},
      onConnectedFailed: (error) => {},
      onDisconnected: () => {},
      onClientsUpdate: ({clientsIDs, clientID}) => {},
      onSaving: () => {},
      onSaved: () => {},
    }),
  ],
})

You just need to set some parameters.

The socketServerURL is the URL and port of the tiptap-collab-server instance. In our case it’s http://localhost:6002. The namespace and room are the names of the namespace and room you want this editor to join on the server.

The clientID is a String that must be unique to each instance of the editor. If you don’t set this one up, it will automatically use a random number (using the expression that is presented here). I personally append the user ID to it, so I can retrieve and display the name of all the connected users.

Remember the server’s connectionGuard function? And its options parameter? Well the joinOptions is the Object that will be available in the server’s connectionGuard function.

In order for the socket server not to be overloaded with requests, clients only send data to the server if the user hasn’t typed anything for a given period of time. The default value is 250ms and can be changed by setting the debounce parameter.

If the focus gets out of the editor, by default, your cursor will stop being displayed on the other connected editors. If you want to keep displaying the last position it was in, you can set the keepFocusOnBlur option to true.

As soon as the collaboration extension is created, it will try to connect to the server. And depending on the events that will happen next, the following callbacks will be called:

  • onConnected is called once the connection has been accepted by the server (the connectionGuard passed)
  • onConnectionFailed is called if the connection has been refused by the server (the connectionGuard did not pass)
  • onClientsUpdate is called once after the client successfully connected to the server, and is called again every time a new user connects to this same collaborative document (same namespace, same room). It provides 2 parameters:
    • clientsIDs is the list of the IDs of all connected editors;
    • clientID is the ID of this instance of the editor.
  • onSaving is called each time this instance sends out update data to the server.
  • onSaved is called each time the editor receives data from the server.
  • and finally onDisconnected is called when the editor is disconnected from the server (that being at it’s request or not).

Displaying other clients cursors

The tiptap-extension-collaboration package also provides a tiptap extension to display cursors and selections. It actually adds decorations to the text, a span with the cursor class at the cursor position, and a span with the selection class around the selection. Both spans are also attributed a class named client-append with clientID.packageThe tiptap-extension-collaboration package also provides a tiptap extension to display cursors and selections. It actually adds decorations to the text, a span with the cursor class at the cursor position, and a span with the selection class around the selection. Both spans are also attributed a class named client-append with clientID.

Applying CSS to actually show cursors and selections on screen falls into your hands. A way of doing it is suggested in the package example project, but I won’t dive into it in this already long article.

This extension is optional and, once again, needs to be declared like any other tiptap extension:

import { Collaboration, Cursors } from 'tiptap-extension-collaboration'

new Editor({
  extensions: [
    new Cursors(),
    new Collaboration({
      socketServerBaseURL: 'http://localhost:6002',
      namespace: 'Directory-A',
      room: 'Document-1',

      clientID: String(Math.floor(Math.random() * 0xFFFFFFFF)),
      joinOptions: {},

      debounce: 250,
      keepFocusOnBlur: false,

      onConnected: () => {},
      onConnectedFailed: (error) => {},
      onDisconnected: () => {},
      onClientsUpdate: ({clientsIDs, clientID}) => {},
      onSaving: () => {},
      onSaved: () => {},
    }),
  ],
})

Have fun

I hope this set of packages will be useful, and that this article will push you into implementing collaborative editors with tiptap.

Thanks for reading me. Have a nice day!

Related Posts

Leave a Reply