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:

ts
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) and translate(state, result, key) mint new identities under key, 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 its mint needs no care.
  • translate(state, result, key, { continues }) carries text typed right after the last character of an insert that continues accepts 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 one Insert. Accept only inserts this replica minted: two replicas continuing one insert at once claim the same characters, and the later-committed loses its text.
  • translate is pure and reads nothing but its arguments. The ops are what travels: every replica applies the same ops in the server's order and projects the same document.
  • applyOps never throws. An op that no longer fits changes nothing: a block already deleted, an id already taken, a move or an Unjoin that 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 a Collect removed it) lands at the end of the block it was typed in, or of the block that one was joined into.
  • applyOps checks structure, not vocabulary: a Retype or SetProps op 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's selection, or anchor(state, selection)), and resolve places 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 Collect op 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 undo ops, computed against state, the state ops were applied to. Applied later, after anything else, they take back only what ops did:
    • 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.
    Everyone else's edits stay. Collaborative undo is applying these as a new edit, which converges like any other. The text an undone split joins back goes at the end of the first half as it is then (a Join with at: 'end'), so what was typed there since stays before it: split ab at its start, type x in the empty first half, and undoing the split gives xab. 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, since SetProps cannot delete one. Inverting the inverse, against the state it was applied to, gives the redo.

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.