Paid features
Collaboration
npm i @matrajs/collab The protocol is the well-trodden one. A client sends the steps it has made together with the version they applied to; the authority accepts them only if that version is still current. A client whose version is stale pulls what it missed, rebases its own unsent work over it, and tries again. Neither side loses an edit.
There is no CRDT here and no dependency. Rebasing already lives in the engine — mapping a step over another is what lets a local edit survive a remote one — so collaboration is a version counter and a transport on top of that.
The client
import { createEditor, starterKit } from '@matrajs/core'
import { collab } from '@matrajs/collab'
const editor = createEditor({
extensions: [...starterKit, collab({ clientId: 'nahim-1a2b' })] as const,
}) | Option | Type | |
|---|---|---|
clientId | string | Required. Two clients must never share one · it is how a client recognises its own work coming back. |
version | number? | The version this client starts from. Defaults to 0. |
The authority
Authority is a plain class with no server attached, so the same object works over
a WebSocket, over HTTP polling, in a Durable Object, or in an in-memory channel in a test.
import { Authority } from '@matrajs/collab'
const authority = new Authority((version) => broadcast(version)) | Member | |
|---|---|
version | How many steps it holds. |
receive(version, steps) |
Appends if the client is current, and returns whether it was.
false means pull, rebase, try again. Rejecting rather than
merging is what keeps the history linear and every client's version meaningful.
|
since(version) | Everything that happened after that version. |
The loop
Two functions, and they are the whole integration.
import { getVersion, sendableSteps } from '@matrajs/collab'
/** Send what the authority has not seen. */
function push() {
const sendable = sendableSteps(editor)
if (!sendable) return true
const accepted = authority.receive(sendable.version, sendable.steps)
if (accepted) editor.commands.confirmCollabSteps(sendable.steps.length)
return accepted
}
/** Take what we missed and rebase onto it. */
function pull() {
const missing = authority.since(getVersion(editor))
if (missing.length) editor.commands.receiveCollabSteps(missing)
}
editor.on('change', () => {
if (!push()) {
pull()
push()
}
}) | Export | |
|---|---|
sendableSteps(editor) | { version, steps, clientId }, or null when there is
nothing outstanding.
|
getVersion(editor) | The version this client believes it is on. |
receiveCollabSteps(steps) | Applies other clients' steps. Your own are skipped — they are already in the document, and applying them twice would duplicate the edit. |
confirmCollabSteps(count) | The authority took this many · stop tracking them as unconfirmed. |
Remote cursors
A second extension, because presence is optional and costs a little to keep.
import { colorFor, remoteCursorCSS, remoteCursors } from '@matrajs/collab'
const editor = createEditor({
extensions: [
...starterKit,
collab({ clientId }),
remoteCursors(),
] as const,
})
editor.on('selectionChange', () => {
const { from, to } = editor.selection
socket.send(JSON.stringify({
clientId,
anchor: from,
head: to,
meta: { name: 'Nahim' },
}))
})
socket.onmessage = (event) => editor.commands.setPresence(JSON.parse(event.data)) | Command or export | |
|---|---|
setPresence(presence) | { clientId, anchor, head, meta? } |
removePresence(clientId) | Someone left. |
clearPresence() | Everyone left · a reconnect, usually. |
colorFor(clientId) | A stable colour derived from the id, at a fixed saturation and lightness so it stays legible on light and dark. Derived beats assigned: two clients that never speak still agree on what colour a third person is. |
remoteCursorCSS | The stylesheet the decorations expect. See Styling. |
Read the cursors for an avatar row:
import type { RemoteCursors } from '@matrajs/collab'
const people = editor.extensionState<RemoteCursors>('remoteCursors')