diff --git a/docs/src/demos/Examples/CollaborativeEditingWs/server.js b/docs/src/demos/Examples/CollaborativeEditingWs/server.js new file mode 100644 index 000000000..7db8af288 --- /dev/null +++ b/docs/src/demos/Examples/CollaborativeEditingWs/server.js @@ -0,0 +1,14 @@ +/* +import { Server } from '@hocuspocus/server' +import { LevelDB } from '@hocuspocus/leveldb' + +const server = Server.configure({ + port: 1234, + + persistence: new LevelDB({ + path: './database', + }), +}) + +server.listen() +*/ diff --git a/docs/src/demos/Extensions/Collaboration/index.vue b/docs/src/demos/Extensions/Collaboration/index.vue index 148386085..a420f9958 100644 --- a/docs/src/demos/Extensions/Collaboration/index.vue +++ b/docs/src/demos/Extensions/Collaboration/index.vue @@ -41,7 +41,6 @@ export default { Paragraph, Text, Collaboration.configure({ - provider: this.provider, type: this.type, }), ], diff --git a/docs/src/demos/Extensions/CollaborationCursor/index.vue b/docs/src/demos/Extensions/CollaborationCursor/index.vue index 571d362a3..187cbbf48 100644 --- a/docs/src/demos/Extensions/CollaborationCursor/index.vue +++ b/docs/src/demos/Extensions/CollaborationCursor/index.vue @@ -42,7 +42,6 @@ export default { Paragraph, Text, Collaboration.configure({ - provider: this.provider, type: this.type, }), CollaborationCursor.configure({ diff --git a/docs/src/docPages/api/extensions/collaboration-cursor.md b/docs/src/docPages/api/extensions/collaboration-cursor.md index 3783a3417..a34ae2b5b 100644 --- a/docs/src/docPages/api/extensions/collaboration-cursor.md +++ b/docs/src/docPages/api/extensions/collaboration-cursor.md @@ -21,10 +21,12 @@ yarn add @tiptap/extension-collaboration-cursor ``` ## Settings -| Option | Type | Default | Description | -| -------- | ---- | ------- | ----------- | -| provider | | | | -| type | | | | +| Option | Type | Default | Description | +| -------- | ---------- | ----------- | ----------------------------------------------------------------------------------- | +| provider | `Object` | `null` | The Y.js provider, for example a WebSocket connection. | +| name | `String` | `'Someone'` | The name of the current user. | +| color | `String` | `'#cccccc'` | The current user’s cursor color. | +| render | `Function` | … | A render function for the cursor, look at the extension source code for an example. | ## Commands | Command | Parameters | Description | diff --git a/docs/src/docPages/guide/collaborative-editing.md b/docs/src/docPages/guide/collaborative-editing.md index 85216413a..bf8785d77 100644 --- a/docs/src/docPages/guide/collaborative-editing.md +++ b/docs/src/docPages/guide/collaborative-editing.md @@ -1,27 +1,285 @@ # Collaborative editing -:::premium Requires Pro Extensions -We kindly ask you to sponsor us, before using this example in production. [Read more](/sponsor) +:::premium Become a sponsor +Using collaborative editing in production? Do the right thing and [sponsor our work](/sponsor)! ::: ## toc ## Introduction -Collaborative editing allows multiple users to work on the same text document in real-time. It’s a complex topic that you should be aware before adding it blindly to you app. No worries though, here is everything you need to know. +Real-time collaboration, syncing between different devices or 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. Don’t worry, a production-grade setup doesn’t require much code. ## Configure collaboration +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). -### WebRTC provider +Y.js is a conflict-free replicated data types implementation, or in other words: It’s reaaally good in merging changes. And to achieve that, changes don’t have to come in order. It’s totally fine to change a document while being offline and merge the it with other changes when the device is online again. -### Websocket provider +But somehow, the clients need to interchange document modifications. The most technologies used to do that are WebRTC and WebSocket, so let’s have a look those: + +### WebRTC +Anyway, let’s 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 +``` + +And create a new Y document, and register it with tiptap: + +```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) +// Point to the ProseMirror schema +const type = ydoc.getXmlFragment('prosemirror') + +const editor = new Editor({ + extensions: [ + // … + // Register the document with tiptap + Collaboration.configure({ + type: type, + }), + ], +}) +``` + +This should be enough to create collaborative instance of tiptap. Crazy, isn’t it? Try it out, and open the editor in two different browsers. Changes should be synced. + +So how does this magic work? All clients need to connect with eachother, that’s 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 server to connect clients directly with-each other, but not to sync the actual changes. + +This has two downsides, though. On the one hand, browsers refuse to connect with too many clients. With Y.js it’s enough if all clients are connected indirectly, but even that isn’t possible at some point. Or in other words, it doesn’t scale well for more than 100+ clients in the same document. + +On the other hand, it’s likely you want to involve a server to persist changes anyway. But the WebRTC signaling server (which connects client with eachother) doesn’t receive changes from clients and therefore doesn’t know what’s in the document. + +Anyway, if you want to dive deeper, head over to [the Y WebRTC repository](https://github.com/yjs/y-webrtc). + +### WebSocket (Recommended) +For most uses cases, the WebSocket provider is the recommended choice. It’s 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) +// Point to the ProseMirror schema +const type = ydoc.getXmlFragment('prosemirror') + +const editor = new Editor({ + extensions: [ + // … + // Register the document with tiptap + Collaboration.configure({ + type: type, + }), + ], +}) +``` + +That example doesn’t work out of the box. As you can see, it configures to talk to a WebSocket server which is available under `ws://127.0.0.1:1234` (WebSocket protocol, your local IP and port 1234). + +To make the server part as easy as possible, we provide you with an opinionated server package, called [hocuspocus](http://github.com/ueberdosis/hocuspocus). Create a new project, and install it as a dependency: + +```bash +# with npm +npm install @hocuspocus/server + +# with Yarn +yarn add @hocuspocus/server +``` + +Create an `index.js` and throw in the following content: + +```js +import { Server } from '@hocuspocus/server' + +const server = Server.configure({ + port: 1234, +}) + +server.listen() +``` + +That’s all. Start your new WebSocket server: + +```bash +node ./index.js +``` + +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 be in sync with all other clients. Amazing, isn’t it? ### Add cursors +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 type = ydoc.getXmlFragment('prosemirror') + +const editor = new Editor({ + extensions: [ + // … + Collaboration.configure({ + type: type, + }), + // Register the collaboration cursor extension + CollaborationCursor.configure({ + provider: this.provider, + name: 'Cyndi Lauper', + color: '#f783ac', + }), + ], +}) +``` + +As you can see, you can pass a name and color for every users. Look at the [collaborative editing example](/exmplaes/collaborative-editing), to see a more advanced example. ### Offline support +Adding offline support to your collaborative editor is basically a one liner, thanks to the [Y IndexedDB adapter](https://github.com/yjs/y-indexeddb). Install it: + +```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() +const type = ydoc.getXmlFragment('prosemirror') +// Store the Y document in the browser +const indexdb = new IndexeddbPersistence('example-document', ydoc) + +const editor = new Editor({ + extensions: [ + // … + Collaboration.configure({ + type: type, + }), + ], +}) +``` + +All changes will then be stored in the browser, even if you close the tab, go offline, or make changes while working offline. The next time you’re online, the WebSocket provider will try to find a connection and eventually sync the changes. + +Yes, it’s magic. And you should sponsor [Kevin Jahns on GitHub](https://github.com/dmonad), he is the brain behind Y.js. ## Store the content +Our collaborative editing backend is ready to handle advanced usage, like authorization, persistence and scaling. Let’s go through a few common use cases here! -### Client-only implementation +### Authorization +With the `onJoinDocument` hook you can write a custom Promise to check if a client is authorized -### Server implementation +```js +import { Server } from '@hocuspocus/server' +const server = Server.configure({ + onJoinDocument(data, resolve, reject) { + const { + documentName, clientID, requestHeaders, clientsCount, document, + } = data + // Your code here, for example a request to an API + + // If the user is authorized … + resolve() + + // if the user isn’t authorized … + reject() + }, +}) + +server.listen() +``` + +### Persist the document +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: + +```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 +If you want to pass the data to an API, you can use the `onChange` hook, which is executed when a document changes. With the `debounce` setting you can slow down requests to your API, with the `debounceMaximum` setting you can make sure the content is sent to your API at least every few seconds: + +```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) + debounceMaximum: 10000, + + // executed when the document is changed + onChange(data) { + const { + documentName, clientID, requestHeaders, clientsCount, document, + } = data + + }, +}) + +server.listen() +``` + +### Scale with Redis (Advanced) +If you want to scale the WebSocket server, you can spawn multiple instances behind a load balancer and sync changes between the instances through Redis. Install the Redis adapter and register it with hocuspocus: + +```js +import { Server } from '@hocuspocus/server' +import { Redis } from '@hocuspocus/redis' + +const server = Server.configure({ + persistence: new Redis('redis://:password@127.0.0.1:1234/0'), +}) + +server.listen() +``` diff --git a/docs/src/links.yaml b/docs/src/links.yaml index 0e18ee3c7..cb506cf3d 100644 --- a/docs/src/links.yaml +++ b/docs/src/links.yaml @@ -51,7 +51,6 @@ link: /guide/getting-started/vue-cli - title: Configure the editor link: /guide/configure-the-editor - new: true - title: Create a new toolbar link: /guide/create-your-editor draft: true @@ -68,7 +67,6 @@ link: /guide/working-with-typescript - title: Collaborative editing link: /guide/collaborative-editing - draft: true pro: true - title: API