tiptap/docs/src/docPages/guide/collaborative-editing.md

381 lines
15 KiB
Markdown
Raw Normal View History

2020-10-05 20:56:45 +08:00
# Collaborative editing
2020-12-04 00:23:54 +08:00
:::pro Become a sponsor
2020-11-28 00:23:04 +08:00
Using collaborative editing in production? Do the right thing and [sponsor our work](/sponsor)!
:::
## toc
2020-10-05 20:56:45 +08:00
## Introduction
2020-11-28 05:17:03 +08:00
Real-time collaboration, syncing between different devices and working offline used to be hard. We provide everything you need to keep everything in sync, conflict-free with the power of [Y.js](https://github.com/yjs/yjs). The following guide explains all things to take into account when you consider to make tiptap collaborative. Dont worry, a production-grade setup doesnt require much code.
2020-10-05 20:56:45 +08:00
## Configure collaboration
2020-11-28 00:23:04 +08:00
The underyling schema tiptap uses is an excellent foundation to sync documents. With the [`Collaboration`](/api/extensions/collaboration) you can tell tiptap to track changes to the document with [Y.js](https://github.com/yjs/yjs).
2020-10-05 20:56:45 +08:00
2020-11-28 00:23:04 +08:00
Y.js is a conflict-free replicated data types implementation, or in other words: Its reaaally good in merging changes. And to achieve that, changes dont have to come in order. Its totally fine to change a document while being offline and merge the it with other changes when the device is online again.
2020-10-05 20:56:45 +08:00
2020-11-28 00:23:04 +08:00
But somehow, the clients need to interchange document modifications. The most technologies used to do that are WebRTC and WebSocket, so lets have a look those:
### WebRTC
Anyway, lets take the first steps. Install the dependencies:
```bash
# with npm
npm install @tiptap/extension-collaboration yjs y-webrtc
# with Yarn
yarn add @tiptap/extension-collaboration yjs y-webrtc
```
2020-11-30 21:27:36 +08:00
Now, create a new Y document, and register it with tiptap:
2020-11-28 00:23:04 +08:00
```js
import { Editor } from '@tiptap/core'
import Collaboration from '@tiptap/extension-collaboration'
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
// A new Y document
const ydoc = new Y.Doc()
// Registered with a WebRTC provider
const provider = new WebrtcProvider('example-document', ydoc)
const editor = new Editor({
extensions: [
// …
// Register the document with tiptap
Collaboration.configure({
2020-11-30 21:27:36 +08:00
provider
2020-11-28 00:23:04 +08:00
}),
],
})
```
2020-11-28 05:17:03 +08:00
This should be enough to create a collaborative instance of tiptap. Crazy, isnt it? Try it out, and open the editor in two different browsers. Changes should be synced between different windows.
2020-11-28 00:23:04 +08:00
2020-11-28 05:17:03 +08:00
So how does this magic work? All clients need to connect with eachother, thats the job of providers. The [WebRTC](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API) provider is the easiest way to get started with, as it requires a public server to connect clients directly with-each other, but not to sync the actual changes. This has two downsides, though.
2020-11-28 00:23:04 +08:00
2020-11-28 05:17:03 +08:00
On the one hand, browsers refuse to connect with too many clients. With Y.js its enough if all clients are connected indirectly, but even that isnt possible at some point. Or in other words, it doesnt scale well for more than 100+ clients in the same document.
2020-11-28 00:23:04 +08:00
2020-11-28 05:17:03 +08:00
On the other hand, its likely you want to involve a server to persist changes anyway. But the WebRTC signaling server (which connects all clients with eachother) doesnt receive the changes and therefore doesnt know whats in the document.
2020-11-28 00:23:04 +08:00
2020-11-28 05:17:03 +08:00
Anyway, if you want to dive deeper, head over to [the Y WebRTC repository](https://github.com/yjs/y-webrtc) on GitHub.
2020-11-28 00:23:04 +08:00
### WebSocket (Recommended)
For most uses cases, the WebSocket provider is the recommended choice. Its very flexible and can scale very well. For the client, the example is nearly the same, only the provider is different. Install the dependencies first:
```bash
# with npm
npm install @tiptap/extension-collaboration yjs y-websocket
# with Yarn
yarn add @tiptap/extension-collaboration yjs y-websocket
```
And then register the WebSocket provider with tiptap:
```js
import { Editor } from '@tiptap/core'
import Collaboration from '@tiptap/extension-collaboration'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
// A new Y document
const ydoc = new Y.Doc()
// Registered with a WebSocket provider
const provider = new WebsocketProvider('ws://127.0.0.1:1234', 'example-document', ydoc)
const editor = new Editor({
extensions: [
// …
// Register the document with tiptap
Collaboration.configure({
2020-11-30 21:27:36 +08:00
provider
2020-11-28 00:23:04 +08:00
}),
],
})
```
2020-11-28 05:17:03 +08:00
That example doesnt work out of the box. As you can see, its configured to talk to a WebSocket server which is available under `ws://127.0.0.1:1234` (WebSocket protocol, your local IP and port 1234). You need to set this up, too.
2020-11-28 00:23:04 +08:00
2020-12-04 22:12:38 +08:00
To make the server part as easy as possible, we provide you with an opinionated server package, called hocuspocus (NOT PUBLISHED YET). Create a new project, and install the hocuspocus server as a dependency:
2020-11-28 00:23:04 +08:00
```bash
# with npm
npm install @hocuspocus/server
# with Yarn
yarn add @hocuspocus/server
```
2020-11-28 05:17:03 +08:00
Create an `index.js` and throw in the following content, to create, configure and start your very own WebSocket server:
2020-11-28 00:23:04 +08:00
```js
import { Server } from '@hocuspocus/server'
const server = Server.configure({
port: 1234,
})
server.listen()
```
2020-11-28 05:17:03 +08:00
Thats all. Start the script with:
2020-11-28 00:23:04 +08:00
```bash
node ./index.js
```
2020-11-28 05:17:03 +08:00
This should output something like “Listening on ws://127.0.0.1:1234”. If you go back to your tiptap editor and hit reload, it should connect to the WebSocket server and changes should sync with all other clients. Amazing, isnt it?
2020-10-05 20:56:45 +08:00
### Add cursors
2020-11-28 00:23:04 +08:00
If you want to enable users to see the cursor and text selections of each other, add the [`CollaborationCursor`](/api/extensions/collaboration-cursor) extension.
```js
import { Editor } from '@tiptap/core'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
const ydoc = new Y.Doc()
const provider = new WebsocketProvider('ws://127.0.0.1:1234', 'example-document', ydoc)
const editor = new Editor({
extensions: [
// …
Collaboration.configure({
2020-11-30 21:27:36 +08:00
provider
2020-11-28 00:23:04 +08:00
}),
// Register the collaboration cursor extension
CollaborationCursor.configure({
provider: this.provider,
name: 'Cyndi Lauper',
color: '#f783ac',
}),
],
})
```
2020-11-30 21:44:18 +08:00
As you can see, you can pass a name and color for every user. Look at the [collaborative editing example](/examples/collaborative-editing), to see a more advanced example.
2020-10-05 20:56:45 +08:00
### Offline support
2020-11-28 05:17:03 +08:00
Adding offline support to your collaborative editor is basically a one-liner, thanks to the fantastic [Y IndexedDB adapter](https://github.com/yjs/y-indexeddb). Install it:
2020-11-28 00:23:04 +08:00
```bash
# with npm
npm install y-indexeddb
# with Yarn
yarn add y-indexeddb
```
And connect it with a Y document:
```js
import { Editor } from '@tiptap/core'
import Collaboration from '@tiptap/extension-collaboration'
import * as Y from 'yjs'
import { IndexeddbPersistence } from 'y-indexeddb'
const ydoc = new Y.Doc()
// Store the Y document in the browser
const indexdb = new IndexeddbPersistence('example-document', ydoc)
const editor = new Editor({
extensions: [
// …
Collaboration.configure({
2020-11-30 21:27:36 +08:00
provider
2020-11-28 00:23:04 +08:00
}),
],
})
```
2020-11-28 05:17:03 +08:00
All changes will be stored in the browser then, even if you close the tab, go offline, or make changes while working offline. Next time you are online, the WebSocket provider will try to find a connection and eventually sync the changes.
2020-11-28 00:23:04 +08:00
2020-11-28 05:17:03 +08:00
Yes, its magic. As already mentioned, that is all based on the fantastic Y.js framework. And if youre using it, or our integration, you should definitely [sponsor Kevin Jahns on GitHub](https://github.com/dmonad), he is the brain behind Y.js.
2020-10-05 20:56:45 +08:00
## Store the content
2020-11-28 05:17:03 +08:00
Our collaborative editing backend is ready to handle advanced use cases, like authorization, persistence and scaling. Lets go through a few common use cases here!
2020-11-28 00:23:04 +08:00
2020-12-04 00:23:54 +08:00
:::pro Backend as a Service (Paid)
2020-12-04 20:41:05 +08:00
Dont want to wrap your head around the backend part? No worries, we offer a managed backend. For less than 1.000 documents, its $49/month (VAT may apply) and probably saves you a ton of time. Send us an email to [humans@tiptap.dev](mailto:humans@tiptap.dev) for further details.
2020-12-04 00:23:54 +08:00
:::
2020-12-04 20:41:05 +08:00
### The document name
The document name is `'example-document'` in all examples here, but it could be any string. In a real-world app youd probably add the name of your entity, the ID of the entity and in some cases even the field (if you have multiple fields that you want to make collaborative). Here is how that could look like for a CMS:
```js
const documentName = 'page.140.content'
```
In the backend, you can split the string to know the user is typing on a page with the ID 140 in the `content` field and manage authorization and such accordingly. New documents are created on the fly, no need to tell the backend about them, besides passing a string to the provider.
2020-12-02 22:35:41 +08:00
### Authentication
With the `onConnect` hook you can write a custom Promise to check if a client is authenticated. That can be a request to an API, to a microservice, a database query, or whatever is needed, as long as its executing `resolve()` at some point. You can also pass contextual data to the `resolve()` method which will be accessible in other hooks.
```js
import { Server } from '@hocuspocus/server'
const server = Server.configure({
onConnect(data, resolve, reject) {
2020-12-03 00:14:40 +08:00
const { requestHeaders, requestParameters } = data
2020-12-02 22:35:41 +08:00
// Your code here, for example a request to an API
2020-12-04 01:13:43 +08:00
// If the user is not authenticated …
2020-12-03 00:14:40 +08:00
if (requestParameters.access_token !== 'super-secret-token') {
2020-12-02 22:35:41 +08:00
return reject()
}
// Set contextual data
const context = {
user_id: 1234,
}
2020-12-04 01:13:43 +08:00
// If the user is authenticated …
2020-12-02 22:35:41 +08:00
resolve(context)
},
})
server.listen()
```
2020-11-28 00:23:04 +08:00
### Authorization
2020-12-02 22:35:41 +08:00
With the `onJoinDocument` hook you can check if a user is authorized to edit the current document. This works in the same way the [Authentication](#authentication) works.
2020-11-28 00:23:04 +08:00
```js
import { Server } from '@hocuspocus/server'
const server = Server.configure({
onJoinDocument(data, resolve, reject) {
2020-12-02 22:35:41 +08:00
const {
clientsCount,
context,
document,
documentName,
requestHeaders,
2020-12-03 00:14:40 +08:00
requestParameters,
2020-12-02 22:35:41 +08:00
} = data
2020-11-28 00:23:04 +08:00
// Your code here, for example a request to an API
2020-12-02 22:35:41 +08:00
// Access the contextual data from the onConnect hook, in this example this will print { user_id: 1234 }
console.log(context)
2020-11-28 00:23:04 +08:00
// If the user is authorized …
resolve()
// if the user isnt authorized …
reject()
},
})
server.listen()
```
### Persist the document
2020-11-28 05:17:03 +08:00
By default, documents are only stored in the memory. Hence they are deleted when the WebSocket server is stopped. To prevent this, store changes on the hard disk with the LevelDB adapter. When you restart the server, itll restore documents from the hard disk, in that case from the `./database` folder:
2020-11-28 00:23:04 +08:00
```js
import { Server } from '@hocuspocus/server'
import { LevelDB } from '@hocuspocus/leveldb'
const server = Server.configure({
persistence: new LevelDB({
path: './database',
}),
})
server.listen()
```
### Send it to an API
To pass the updated documents to an API, or to a database, you can use the `onChange` hook, which is executed when a document changes. With the `debounce` setting you can slow down the execution, with the `debounceMaxWait` setting you can make sure the content is sent at least every few seconds:
2020-11-28 00:23:04 +08:00
```js
import { Server } from '@hocuspocus/server'
const server = Server.configure({
// time to wait before sending changes (in milliseconds)
debounce: 2000,
// maximum time to wait (in milliseconds)
debounceMaxWait: 10000,
2020-11-28 00:23:04 +08:00
// executed when the document is changed
onChange(data) {
2020-12-02 22:35:41 +08:00
const {
clientsCount,
document,
documentName,
requestHeaders,
2020-12-03 00:14:40 +08:00
requestParameters,
2020-12-02 22:35:41 +08:00
} = data
2020-11-28 00:23:04 +08:00
2020-12-02 22:35:41 +08:00
// Your code here, for example a request to an API
2020-11-28 00:23:04 +08:00
},
})
server.listen()
```
2020-12-04 00:23:54 +08:00
There is no method to restore documents from an external source, so youll need a [persistence driver](#persist-the-document) though. Those persistence drivers store every change to the document. Thats probably not needed in your external source, but is needed to make the merging of changes conflict-free in the collaborative editing backend.
2020-11-28 00:23:04 +08:00
### Scale with Redis (Advanced)
:::warning Keep in mind
2020-12-04 22:12:38 +08:00
The redis adapter only syncs document changes. Collaboration cursors are not yet supported.
:::
To scale the WebSocket server, you can spawn multiple instances of the server behind a load balancer and sync changes between the instances through Redis. Import the Redis adapter and register it with hocuspocus. For a full documentation on all available redis and redis cluster options, check out the [ioredis API docs](https://github.com/luin/ioredis/blob/master/API.md).
2020-10-05 20:56:45 +08:00
2020-11-28 00:23:04 +08:00
```js
import { Server } from '@hocuspocus/server'
import { Redis } from '@hocuspocus/redis'
2020-10-05 20:56:45 +08:00
2020-11-28 00:23:04 +08:00
const server = Server.configure({
persistence: new Redis({
host: '127.0.0.1',
port: 6379,
}),
})
server.listen()
```
If you want to use a redis cluster, use the redis cluster adapter:
```js
import { Server } from '@hocuspocus/server'
import { RedisCluster } from '@hocuspocus/redis'
const server = Server.configure({
persistence: new RedisCluster({
scaleReads: 'all',
redisOptions: {
host: '127.0.0.1',
port: 6379,
}
}),
2020-11-28 00:23:04 +08:00
})
2020-10-05 20:56:45 +08:00
2020-11-28 00:23:04 +08:00
server.listen()
```
2020-12-04 22:12:38 +08:00
## Pitfalls
### Schema updates
tiptap is very strict with the [schema](/api/schema), that means, if you add something thats not allowed according to the configured schema itll be thrown away. That can lead to a strange behaviour when multiple clients with different schemas share changes to a document.
Lets say you added an editor to your app and the first people use it already. They have all a loaded instance of tiptap with all default extensions, and therefor a schema that only allows those. But you want to add task lists in the next update, so you add the extension and deploy again.
A new user opens your app and has the updated schema (with task lists), while all others still have the old schema (without task lists). The new user checks out the newly added tasks lists and adds it to a document to show that feature to other users in that document. But then, it magically disappears right after she added it. What happened?
When one user adds a new node (or mark), that change will be synced to all other connected clients. The other connected clients apply those changes to the editor, and tiptap, strict as it is, removes the newly added node, because its not allowed according to their (old) schema. Those changes will be synced to other connected clients and oops, its removed everywhere. To avoid this you have a few options:
1. Never change the schema (not cool).
2. Force clients to update when you deploy a new schema (tough).
3. Keep track of the schema version and disable the editor for clients with an outdated schema (depends on your setup).
Its on our list to provide features to make that easier. If youve got an idea how to improve that, share it with us!