Skip to content

Collaborative text

Task: keep a document that several people edit at the same time, where each editor sends edits typed against the version it last saw.

One leader applies every edit in log order, so there is never a merge. The problem is position: by the time “insert at 12” is applied, someone may have typed before position 12. Text solves it the way collaborative editors do: it is a Fugue sequence in which every character has a stable id. An insert names the id of the character it follows, a delete names ids, so an edit lands where its author meant, and two runs typed after the same character never interleave (crates/sdk/src/collections/text.rs).

#[app::state(version = 1)]
pub struct Docs {
body: Text,
rich: RichText,
}

The client keeps the ids it read (chars() returns (id, char) pairs) and sends edits against them:

/// Inserts `text` after the character `after` (`None`: at the start); returns the new ids.
pub fn insert(&mut self, after: Option<u64>, text: String) -> app::Result<Vec<String>> {
let ids = self.body.insert_after(after, &text)?;
Ok(ids.iter().map(u64::to_string).collect())
}
/// Deletes the characters `ids`; ids already deleted are skipped, an id that never
/// existed is `UnknownId` and deletes nothing.
pub fn delete(&mut self, ids: Vec<String>) -> app::Result<u64> {
let ids = ids
.iter()
.map(|id| id.parse::<u64>().map_err(|_| AppError::new("BadId")))
.collect::<Result<Vec<_>, _>>()?;
Ok(self.body.delete_ids(&ids)?)
}

insert(index, str) and delete(index, n) edit by position for a client that holds the current text; id_at(index) and index_of(id) convert between the two.

read() gives the text, chars() the characters with their ids, len() the length in one read.

RichText is a Text plus marks: ranges with a key and an optional value (bold, a link, a comment thread).

Method Does
text() / text_mut() The underlying Text.
mark(start, end, key, value, expand) Marks the range by position; expand makes it grow when text is typed at its end.
mark_ids(..) The same by character ids.
marks() The marks with their ids.
spans() The text cut into spans, each with its attributes: what a renderer draws.
clear() Removes the text and its marks.

Reads and edits load every node of the text, 1,000 per host read; len is one read. Keep one Text per document rather than one large text for everything, and use a table for document metadata.