Real-time collaborative editing is a technique where multiple people
on different machines can edit the same document at the same time.
Changes are propagated to other participants over the network and show
up in their views of the document as soon as they arrive.
Here’s a toy collaborative editing setup that lives within this page:
Server
The main difficulty with this style of editing is handling of
conflicting edits—since network communication isn’t instantaneous, it
is possible for people to make changes at the same time, which have to
be reconciled in some way when synchronizing everybody up again.
CodeMirror comes with utilities for collaborative editing based on
operational
transformation
with a central authority (server) that assigns a definite order to the
changes. This example describes the practical information you need to
set up such a system. For more theoretical information, see this blog
post.
(It is also possible to wire up different collaborative editing
algorithms to CodeMirror. See for example
Yjs.)
Principles
Collaborative systems implemented with the
@codemirror/collab package work like this:
-
There is a central system (authority) that builds up a history of
changes. -
Individual editors (peers) track which version of the authority’s
history they have synchronized with, and which local (unconfirmed)
changes they have made on top of that. -
All peers set up some way to receive new changes from the
authority. When changes come in…-
If some of those changes are the peer’s own changes, those
changes are removed from the list of unconfirmed changes. -
Remote changes are applied to the local editor state.
-
If there are unconfirmed changes present, operational
transformation is used to transpose the remote changes across
the unconfirmed local ones, and vice versa, so that the remote
changes can be applied to the peer’s current document, and the
updated local changes can be submitted to the server as if they
came after the remote changes. -
The peer’s document version is moved forward.
-
-
Whenever there are unconfirmed local changes, the peer should try
to send them to the authority, along with its current synchronized
version.-
If that version matches the server’s version, the server
accepts the changes and adds them to its history. -
Otherwise, it rejects them, and the peer must wait until it
receives the conflicting changes, and try again after integrating
them.
-
The more tricky logic that a peer must apply is implemented in the
@codemirror/collab package, but to set up a collaborative system you
must implement the authority (which can be very simple) and wire up
the communication between the peers and the authority (which can get a
bit more subtle due to the nature of networked/asynchronous systems).
The Authority
In this example, the authority is a web
worker. That
helps simulate the asynchronous nature of communication, and the need
to serialize data that goes over the communication channel, while
still allowing everything to run inside the browser. In the real
world, it’ll typically be a server program communicating with peers
using HTTP requests or
websockets.
Error handling will be omitted throughout the example to keep things
concise.
The state kept by the authority is just an array of updates (holding a
change set and a client ID), and a current
document.
import {ChangeSet, Text} from "@codemirror/state"
import {Update} from "@codemirror/collab"
let updates: Update[] = []
let doc = Text.of(["Start document"])
The document is used by new peers to be able to join the session—they
must ask the authority for a starting document and version before they
are able to participate.
This code implements the three message types that the worker handles.
-
pullUpdates
is used to ask the authority if any new updates
have come in since a given document version. It “blocks” until new
changes come in when asked for the current version (this is what
thepending
variable is used for). -
pushUpdates
is used to send an array of updates. If there are
no conflicts (the base version matches the authority’s version),
the updates are stored, the document is rolled forward, and any
waitingpullUpdates
requests are notified. -
getDocument
is used by new peers to retrieve a starting
state.
We’ll use a crude mechanism based on
postMessage
and message
channels
to communicate between the main page and the worker. Feel free to
ignore the message-channel-related code in resp
, since that’s not
terribly relevant here.
let pending: ((value: any) => void)[] = []
self.onmessage = event => {
function resp(value: any) {
event.ports[0].postMessage(JSON.stringify(value))
}
let data = JSON.parse(event.data)
if (data.type == "pullUpdates") {
if (data.version < updates.length)
resp(updates.slice(data.version))
else
pending.push(resp)
} else if (data.type == "pushUpdates") {
if (data.version != updates.length) {
resp(false)
} else {
for (let update of data.updates) {
let changes = ChangeSet.fromJSON(update.changes)
updates.push({changes, clientID: update.clientID})
doc = changes.apply(doc)
}
resp(true)
while (pending.length) pending.pop()!(data.updates)
}
} else if (data.type == "getDocument") {
resp({version: updates.length, doc: doc.toString()})
}
}
The Peer
On the other side of the connection, I’m using some messy magic code
to introduce fake latency and broken connections (the scissor controls
in the demo above). This isn’t very interesting, so I’m hiding it in a
Connection
class which is omitted from the code below (you can find
the full code on
GitHub).
These wrappers interact with the worker process through messages,
returning a promise that eventually resolves with some result (when
the connection is cut, the promises will just hang until it is
reestablished).
function pushUpdates(
connection: Connection,
version: number,
fullUpdates: readonly Update[]
): Promise<boolean> {
let updates = fullUpdates.map(u => ({
clientID: u.clientID,
changes: u.changes.toJSON()
}))
return connection.request({type: "pushUpdates", version, updates})
}
function pullUpdates(
connection: Connection,
version: number
): Promise<readonly Update[]> {
return connection.request({type: "pullUpdates", version})
.then(updates => updates.map(u => ({
changes: ChangeSet.fromJSON(u.changes),
clientID: u.clientID
})))
}
function getDocument(
connection: Connection
): Promise<{version: number, doc: Text}> {
return connection.request({type: "getDocument"}).then(data => ({
version: data.version,
doc: Text.of(data.doc.split("n"))
}))
}
To manage the communication with the authority, we use a view
plugin (which are almost always the right place
for asynchronous logic in CodeMirror). This plugin will constantly (in
an async loop) try to pull in new updates and, if it gets them, apply
them to the editor using the
receiveUpdates
function.
When the content of the editor changes, the plugin starts trying to
push its local updates. It keeps a field to make sure it only has one
running push request, and crudely sets a timeout to retry pushing when
there are still unconfirmed changes after the request. This can happen
when the push failed or new changes were introduced while it was in
progress.
(The request scheduling is something you’ll definitely want to do in
a more elaborate way in a real setup. It can help to include both
pushing and pulling in a single state machine, where the peer only
does one of the two at a time.)
The peerExtension
function returns such a plugin plus a collab
extension configured with the appropriate start version.
function peerExtension(startVersion: number, connection: Connection) {
let plugin = ViewPlugin.fromClass(class {
private pushing = false
private done = false
constructor(private view: EditorView) { this.pull() }
update(update: ViewUpdate) {
if (update.docChanged) this.push()
}
async push() {
let updates = sendableUpdates(this.view.state)
if (this.pushing || !updates.length) return
this.pushing = true
let version = getSyncedVersion(this.view.state)
await pushUpdates(connection, version, updates)
this.pushing = false
if (sendableUpdates(this.view.state).length)
setTimeout(() => this.push(), 100)
}
async pull() {
while (!this.done) {
let version = ge