Collaborative fields
useCollaborative — a field several people change at once, with no conflict to resolve.
The counterpart to useDraft, and the difference between
them is the whole point.
useDraft is right for a record: two people setting a colour is a genuine
either/or, and the loser is told. useCollaborative is right for a field where
the answer is almost always "both", and where being told about a conflict would
be a failure rather than a feature.
const body = useCollaborative<Document, Ops>({
algebra: textAlgebra(),
documentType: documents.notes.body, // from the generated api
documentId: noteId,
history: true,
});There is no conflict, no takeTheirs, and no keepMine. Two people typing
in one paragraph is the ordinary case, not a collision.
What you get
document | What to render. undefined until the first snapshot arrives |
change(op) | Records a change. Accepted while offline; it travels on reconnection |
collaborators | Everyone else here, by actor, with their awareness data |
announce(data) | Publishes this client's caret or selection. Never stored |
mapPosition(n) | Moves a position through the last remote change |
undo() / canUndo | An undo stack rebased against everyone else's edits |
isDirty | Whether anything here is unacknowledged |
diverged | The server and this client disagreed and the document was rebuilt |
Rebasing, pacing, one write in flight, reconnection, divergence, and recovery all happen underneath and none of it reaches the view.
The documentType comes from the generated documents export, so opening a
field that is an ordinary column is a type error rather than a runtime
surprise. Only declared fields appear there.
Undo
undo() takes back your work, not the last thing that happened. The stack
is transformed against ops arriving from everyone else, so undoing after a
colleague has typed does not remove their paragraph.
This is the piece most often skipped, and skipping it is very visible.
Awareness and carets
announce publishes whatever this domain calls awareness; collaborators
reports everyone else's. The payload is opaque to the framework, so what a
cursor is stays the domain's business.
For text, @highx/prosemirror reads it:
const carets = writing.collaborators
.map((person) => caretFrom(person.actor, person.data))
.filter((caret) => caret !== undefined);caretDecorations(doc, carets) renders them: a zero-width widget for the
caret, an inline range for the selection, and colourFor(actor) giving a
stable colour per person without anyone storing one — so somebody's cursor,
their avatar, and their badge are one colour across the interface.
Carets are decorations, never content. A cursor is not part of anybody's document.
Awareness is never replayed. A client that joins or reloads sees no carets until other people move, which is the design working rather than a bug. If you want them on join, re-announce when the document resets.
Rich text
The engine is owned and the view is rented. @highx/prosemirror binds a
ProseMirror editor over the text algebra:
const ops = opsFrom(baseline, editorState.doc); // a local edit, as ops
const tr = transactionForRuns(state, runs, { mapOffset }); // someone else'sProseMirror does the part that is genuinely hard and genuinely not ours: IME composition, Android keyboards that misreport what was typed, paste, drag and drop, mobile selection. It is a leaf package, so that dependency reaches nothing else in HighX.
Two rules keep the directions from chasing each other. A transaction built from
a remote op is marked remote and never read back as typing — without it one
keystroke circles the room forever. And it sets addToHistory: false, so undo
stays yours.
Measure a local edit from the runs the client holds, not from the editor's
own previous state. ProseMirror cannot represent a document with no blocks —
its emptiest state is one empty paragraph, a single newline — while a shared
document genuinely starts with nothing. Measuring from the editor is one
character adrift from the first keystroke, and every op reaches past the end.
That is what opsFrom takes a baseline for.
Formatting is not markdown
The stored form is attributed runs: every block ends with a newline that carries the block's identity, and inline marks live on the text.
That is a merging decision. Applying a mark moves no positions, so someone bolding a phrase and someone typing inside it are not in conflict. Markdown encodes formatting as characters, so a mark insertion shifts every position after it — and two people bolding overlapping ranges converge on a string that no longer parses. Convergence guarantees agreement, not validity.
Markdown remains right as a projection: input rules that consume their delimiters, import and export at the boundary, and a plain-text excerpt for list projections.