Rich text / foldkit-richtext
Shared documents
Several replicas can edit one document when the edits are ordered by a server, as
foldkit-sync and foldkit-durable order any durable Message. The difficulty is only that
an edit written as "insert at offset 12 of run t" means something else once another
replica's edit lands first. RichText.Replicated gives every character an identity that
outlives the edits around it, and restates each edit in those terms:
shared state --project--> Document --command--> result --translate--> ops
^ |
+------------------------------ applyOps --------------------------+The editor, its commands and its renderers keep working on a plain Document. The state
holds the blocks, every character typed (deleted ones stay, as tombstones, because a later
op may be anchored on them, until a Collect removes them), and the marks on each:
import * as RichText from 'foldkit-richtext'
const { Replicated } = RichText
const seed = RichText.decodeDocument({
version: 1,
children: [
{ type: 'Paragraph', id: 'p', children: [{ type: 'Text', id: 't', text: 'hello', marks: [] }] },
],
})
let shared = Replicated.fromDocument(seed, 'doc:seed')
const shown = Replicated.project(shared)
const caret = { node: shown.children[0]!.children[0]!.id, offset: 5, affinity: 'after' as const }
const result = RichText.run(
{ document: shown, selection: { type: 'Range', anchor: caret, focus: caret } },
{ type: 'InsertText', text: '!' },
{ mint: () => crypto.randomUUID() },
)
if (result.ok) {
const { ops, selection } = Replicated.translate(shared, result, 'edit:1')
shared = Replicated.applyOps(shared, ops)
const next = { document: Replicated.project(shared), selection: Replicated.resolve(shared, selection) }
}fromDocument(document, key)andtranslate(state, result, key)mint new identities underkey, which has to be unique to that call among everything the state will see, such as a random id minted in a Command. The ids the command minted itself are replaced, so itsmintneeds no care.translate(state, result, key, { continues })carries text typed right after the last character of an insert thatcontinuesaccepts on that insert, from its next index, rather than starting a new one.coalesce(ops)then folds a run of such inserts into one op, so a burst of typing sent together is oneInsert. Accept only inserts this replica minted: two replicas continuing one insert at once claim the same characters, and the later-committed loses its text.translateis pure and reads nothing but its arguments. Theopsare what travels: every replica applies the same ops in the server's order and projects the same document.applyOpsnever throws. An op that no longer fits changes nothing: a block already deleted, an id already taken, a move or anUnjointhat would put a block inside itself. Text typed into a block another replica deleted is gone with it. An insert or split anchored on a character that is gone (its insert was refused, or aCollectremoved it) lands at the end of the block it was typed in, or of the block that one was joined into.applyOpschecks structure, not vocabulary: aRetypeorSetPropsop takes any node kind and any JSON props. An application applying ops from replicas it does not trust checks those against its own Kit before committing them.- A selection travels as anchors (
translate'sselection, oranchor(state, selection)), andresolveplaces it again after other replicas' ops: after the same character, or the nearest one before it still shown. - Two replicas' concurrent inserts at one place both survive, the later-committed first. A mark covers the characters it named, so text another replica typed inside the range before the mark arrived stays unmarked.
- A
Collectop removes deleted text, in two phases so a deletion is kept for one whole interval: each removes what the previous one found deleted, then marks what is deleted now. Commit it through the log like any op, so every replica removes the same text at the same point. An op made by a replica offline across two collections, anchored on removed text, takes the lost-anchor fallback, and an undo from before then finds less to restore. An insert keeps one deleted character at its end, so its indexes are never reused. Block records stay; only their text goes. invert(state, ops)gives the ops that undoops, computed againststate, the stateopswere applied to. Applied later, after anything else, they take back only whatopsdid:- characters it inserted are deleted, and characters it deleted come back
(
Undelete); - marks, block types and props return to what they were;
- a split is joined, and a join is split out again (
Unjoin), with its text and nested blocks; - a deleted block returns (
UndeleteBlock), and a moved one goes back beside the sibling it followed.
Joinwithat: 'end'), so what was typed there since stays before it: splitabat its start, typexin the empty first half, and undoing the split givesxab. A redone join goes right after the character its text followed, or at the block's end when its text ended the block. A restored mark goes last among its run's marks, whose order carries no meaning. A prop an edit added where there was none stays, sinceSetPropscannot delete one. Inverting the inverse, against the state it was applied to, gives the redo.- characters it inserted are deleted, and characters it deleted come back
(
Run identities in the projection are the first character's, so they are stable while text is added after them; block identities are stable for a block's whole life.