HighX
Client

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

documentWhat to render. undefined until the first snapshot arrives
change(op)Records a change. Accepted while offline; it travels on reconnection
collaboratorsEveryone 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() / canUndoAn undo stack rebased against everyone else's edits
isDirtyWhether anything here is unacknowledged
divergedThe 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's

ProseMirror 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.

On this page