Rich text editor

Connect a ProseMirror editor to a Markdown merge column, then draw comments and other people's cursors over the text.

Collaborative text editing covers the column: a text merge column stores plain Markdown or HTML and the server merges concurrent edits inside it. This recipe connects a rich text editor to that column and adds the two things people expect from a shared document: comments that stay on their words, and live cursors.

Everything here comes from the song sheets in the BandBook example. The editor is ProseMirror; jazz-tools/prosemirror holds the glue and has no collaboration logic of its own, because the merge happens on the server.

pnpm add prosemirror-model prosemirror-state prosemirror-view prosemirror-markdown

Schema

The document is one Markdown merge column. Comments and presence are ordinary rows next to it: they point into the text with anchors and ranges (the text before, the text itself, the text after) instead of character offsets, so they survive edits that move the text around.

schema.ts
// A song sheet page is one rich-text document: lyrics, chords and notes as
// Markdown. The authority merges concurrent edits to it (SPEC 4 §4.3), so
// two people rewriting different lines, online or offline, keep both.
songSheets: s
.table(
{
workspaceId: s.uuid(),
pageId: s.uuid(),
body: s.string().merge("markdown"),
},
{ workspace: s.rel("workspaces", "workspaceId"), page: s.rel("pages", "pageId") },
)
.indexOnly(["pageId"]),
// Comments sit over a song sheet's text without being part of it: each one
// stores the text it covers and the text around it (a jazz-tools text
// range), and every reader finds it again in the sheet's current body.
sheetComments: s
.table(
{
workspaceId: s.uuid(),
pageId: s.uuid(),
author: s.uuid(),
range: s.json(),
text: s.string(),
resolved: s.boolean(),
},
{ workspace: s.rel("workspaces", "workspaceId"), page: s.rel("pages", "pageId") },
)
.indexOnly(["pageId"]),
// Who has a song sheet open and where their cursor is: one row per open
// editor, overwritten a few times a second and refreshed while it stays
// open. Readers ignore rows whose `seenAt` is old. Latest-only: a cursor's
// past positions are worth nothing, so nodes keep no history of them.
sheetPresence: s
.table(
{
workspaceId: s.uuid(),
pageId: s.uuid(),
account: s.uuid(),
cursor: s.json(),
selection: s.json().optional(),
seenAt: s.float(),
},
{ workspace: s.rel("workspaces", "workspaceId"), page: s.rel("pages", "pageId") },
)
.history("latest")
.indexOnly(["pageId"]),

Reading Markdown into the editor

The editor's document model must round-trip through Markdown: loading a value and saving it again without edits has to give back the same string. jazz-tools/prosemirror writes canonical Markdown that prosemirror-markdown's CommonMark parser reads back into the same document, including empty paragraphs (written as an empty link, []()) and lines that start with spaces (their first space is written as  ). It has writers for the CommonMark schema's nodes and marks; give it writers for any others your schema adds. A stored value written differently (by another tool, or by hand) is read into the same document and written back in canonical form only where someone edits it.

song-editor.ts
/** Reads a song sheet's stored Markdown into an editor document. */
export function parseSheet(markdown: string): Node {
return defaultMarkdownParser.parse(markdown);
}

Connecting the editor

markdownSync calls onChange with the document's Markdown after each local edit, and the value it was edited from. Write it with db.update, passing that value as editedFrom: each write is one version, and the server merges it with whatever other people wrote meanwhile. A merged value can reach the local store before the editor shows it; a keystroke in between is then merged over it locally instead of undoing it. When the merged value comes back, applyMarkdown turns it into the smallest ProseMirror change, so the local cursor and undo history stay where they were.

SongSheet.tsx
useEffect(() => {
const editor = new EditorView(mount.current!, {
state: EditorState.create({
doc: parseSheet(sheet.body),
plugins: [
...editingPlugins(),
// Every change is written as the whole value, at once, as an edit
// of the value the editor last showed or wrote. Each write is one
// version, and the authority merges it with whatever others wrote
// meanwhile; one typed before a merged value is shown is merged
// over it locally.
markdownSync({
parse: parseSheet,
onChange: (body, { editedFrom }) =>
db.update(app.songSheets, sheet.id, { body }, { editedFrom: { body: editedFrom } }),
}),
overlays({ anchors: () => db.textAnchors(), onPlaced: setPlaced }),
],
}),
editable: () => canEdit.current,
dispatchTransaction(tr) {
const next = editor.state.apply(tr);
editor.updateState(next);
setState(next);
// Presence is stored by its text, so a merged value arriving does
// not move it; only this user's own edits and selections do.
if ((tr.selectionSet || tr.docChanged) && !isRemoteChange(tr)) report.current(next);
},
});
setView(editor);
setState(editor.state);
return () => editor.destroy();
}, [db, sheet.id]);
​
// Whether the editor takes input follows the current permission.
useEffect(() => {
view?.setProps({ editable: () => editable });
}, [view, editable]);
​
// A newer value (someone else's edit, merged by the authority) is applied
// as the smallest change, so the cursor stays on the text it was on.
useEffect(() => {
if (!view) return;
const tr = applyMarkdown(view.state, sheet.body);
if (tr) view.dispatch(tr);
}, [view, sheet.body]);

Comments that stay on their words

captureRange stores a selection as the text it covers plus a little text on either side. overlays finds every range again in the current document and draws it as a decoration; when the text under a comment changes, it is found by its surroundings with lower confidence, and when the text is gone the comment is reported as orphaned instead of jumping somewhere else.

SongSheet.tsx
const { data: comments } = useAll(
app.sheetComments.where({ pageId: sheet.pageId, resolved: false }),
);
const drawn = useMemo<Overlay[]>(
() => [
...(comments ?? []).map((comment) => ({
id: comment.id,
range: comment.range as unknown as TextRange,
attrs: { class: "bb-sheet-comment" },
})),
...presenceOverlays(presence.collaborators),
],
[comments, presence.collaborators],
);
useEffect(() => {
if (view) view.dispatch(setOverlays(view.state.tr, drawn));
}, [view, drawn]);

Each placed overlay comes back through onPlaced with its position and how it was found. A comment found with less confidence than usual has drifted: its words were edited. Storing it again from the current text keeps later lookups exact:

SongSheet.tsx
// A comment found with less confidence than usual drifted: its words were
// edited. Its author stores it again from the current text, so later
// readers find it at full confidence.
useEffect(() => {
if (!view) return;
for (const spot of placed) {
const comment = comments?.find((row) => row.id === spot.overlay.id);
if (!comment || !spot.drifted || comment.author !== me) continue;
db.update(app.sheetComments, comment.id, {
range: captureRange(view.state, db.textAnchors(), spot.from, spot.to),
});
}
}, [placed]);

The sidebar inserts new comments from the current selection, and uses the placed positions to sort comments in document order and to jump to one:

SongSheet.tsx
function CommentSidebar(props: {
view: EditorView | null;
state: EditorState | null;
comments: SheetComment[];
placed: readonly PlacedOverlay[];
workspaceId: string;
pageId: string;
/** Page editors may resolve anyone's comment; others only their own. */
canModerate: boolean;
name: (account: string) => string;
}) {
const db = useDb();
const { me } = useWorkspace();
const [draft, setDraft] = useState("");
const { view, state, placed } = props;
const selection = state?.selection;
​
const addComment = () => {
if (!view || !selection || selection.empty || !draft.trim()) return;
db.insert(app.sheetComments, {
workspaceId: props.workspaceId,
pageId: props.pageId,
author: me,
// The commented text and the text around it: anyone finds it again,
// however the sheet changes after this.
range: captureRange(view.state, db.textAnchors(), selection.from, selection.to),
text: draft.trim(),
resolved: false,
});
setDraft("");
};
​
const spotOf = (id: string) => placed.find((spot) => spot.overlay.id === id);
const ordered = [...props.comments].sort(
(a, b) => (spotOf(a.id)?.from ?? Infinity) - (spotOf(b.id)?.from ?? Infinity),
);
​
return (
<div className="bb-sheet-comments">
<VStack gap={3} width="100%">
<Text type="label">Comments</Text>
<VStack gap={2}>
<TextArea
label="New comment"
isLabelHidden
rows={2}
value={draft}
placeholder={
selection && !selection.empty ? "Comment on the selection" : "Select text to comment"
}
isDisabled={!selection || selection.empty}
onChange={setDraft}
/>
<Button
label="Comment"
size="sm"
isDisabled={!selection || selection.empty || !draft.trim()}
onClick={addComment}
/>
</VStack>
{ordered.map((comment) => {
const spot = spotOf(comment.id);
// The commented words as they read now, or as they were stored.
const quote =
spot && !spot.orphaned && state
? state.doc.textBetween(spot.from, spot.to, " ")
: (comment.range as unknown as TextRange).matched;
return (
<Card key={comment.id} padding={3}>
<VStack gap={1}>
<HStack gap={2} justify="between" align="center">
<Text type="supporting">{props.name(comment.author)}</Text>
{spot?.orphaned && <Badge label="Text removed" />}
</HStack>
<Text type="supporting">“{quote}”</Text>
<Text>{comment.text}</Text>
<HStack gap={2}>
{spot && !spot.orphaned && view && (
<Button
label="Show"
size="sm"
variant="ghost"
onClick={() => {
view.dispatch(
view.state.tr
.setSelection(TextSelection.create(view.state.doc, spot.from, spot.to))
.scrollIntoView(),
);
view.focus();
}}
/>
)}
{(props.canModerate || comment.author === me) && (
<Button
label="Resolve"
size="sm"
variant="ghost"
onClick={() => db.update(app.sheetComments, comment.id, { resolved: true })}
/>
)}
</HStack>
</VStack>
</Card>
);
})}
</VStack>
</div>
);
}

Ranges are plain JSON, so a server job or an LLM can create them too: a range with only matched (and optionally before and after) is found wherever that text is.

Live cursors

Each open editor keeps one presence row with its cursor (an anchor) and selection (a range). capturePresence makes both from the editor state; presenceOverlays turns other people's rows into a caret with their name and a selection highlight, which go through the same setOverlays as comments.

use-sheet-presence.ts
/**
* Who else has this song sheet open, and where their cursors are. Each open
* editor keeps one presence row: written as the selection moves (at most
* every 150 ms), refreshed every 10 s while it stays open and deleted when it
* closes. A row this client has not seen refreshed for 30 s belongs to an
* editor that went away; timing it by this client's clock, not the writer's,
* keeps a wrong clock elsewhere from hiding or keeping anyone, or from
* deleting this account's live rows on its other devices. Names and
* colours come from the row's account, never from what the row says.
*/
export function useSheetPresence(pageId: string) {
const db = useDb();
const { me, members, workspace } = useWorkspace();
const rowId = useRef<string | null>(null);
const last = useRef<SelectionPresence | undefined>(undefined);
const pending = useRef<SelectionPresence | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [now, setNow] = useState(() => Date.now());
​
const write = (presence: SelectionPresence) => {
const values = {
cursor: presence.cursor,
selection: presence.selection ?? null,
seenAt: Date.now(),
};
if (rowId.current) {
db.update(app.sheetPresence, rowId.current, values);
} else {
rowId.current = db.insert(app.sheetPresence, {
workspaceId: workspace.id,
pageId,
account: me,
...values,
}).value.id;
}
last.current = presence;
};
​
useEffect(() => {
const heartbeat = setInterval(() => {
setNow(Date.now());
if (rowId.current) db.update(app.sheetPresence, rowId.current, { seenAt: Date.now() });
}, HEARTBEAT_MS);
return () => {
clearInterval(heartbeat);
if (timer.current) clearTimeout(timer.current);
if (rowId.current) db.delete(app.sheetPresence, rowId.current);
rowId.current = null;
};
}, [db, pageId]);
​
/** Call with the editor's state after each selection or document change. */
const report = (state: EditorState) => {
const presence = capturePresence(state, db.textAnchors());
if (samePresence(last.current, presence)) return;
pending.current = presence;
if (timer.current) return;
write(presence);
pending.current = null;
timer.current = setTimeout(function flush() {
timer.current = null;
if (pending.current) {
write(pending.current);
pending.current = null;
timer.current = setTimeout(flush, PRESENCE_WRITE_MS);
}
}, PRESENCE_WRITE_MS);
};
​
const { data: rows } = useAll(app.sheetPresence.where({ pageId }));
​
// When this client last saw each row change.
const seen = useRef(new Map<string, { seenAt: number; at: number }>());
const collaborators = useMemo<Collaborator[]>(() => {
const live: Collaborator[] = [];
for (const row of rows ?? []) {
const last = seen.current.get(row.id);
if (!last || last.seenAt !== row.seenAt) {
seen.current.set(row.id, { seenAt: row.seenAt, at: Date.now() });
}
if (row.id === rowId.current || now - seen.current.get(row.id)!.at > STALE_MS) continue;
live.push({
id: row.id,
name: memberName(members, row.account),
color: colorFor(row.account),
cursor: row.cursor as unknown as TextAnchor,
selection: (row.selection ?? undefined) as unknown as TextRange | undefined,
});
}
return live;
}, [rows, now, members]);
​
// This account's rows left behind by a closed laptop or a crashed tab:
// timed, like everyone's, by when this client last saw them change, so
// another device of the same account with a slow clock keeps its row.
useEffect(() => {
for (const row of rows ?? []) {
const at = seen.current.get(row.id)?.at;
if (row.account === me && row.id !== rowId.current && at && now - at > STALE_MS) {
db.delete(app.sheetPresence, row.id);
}
}
}, [rows, now, me, db]);
return { collaborators, report };
}

Presence rows are written a few times a second, and their past positions are worth nothing, so declare the table latest-only with .history("latest"): Jazz then keeps only each row's latest version instead of every one. Such a table can't be read through history, at a snapshot or in a serializable transaction, and can't use branchBy or text-merging columns. Switching an existing table to latest-only drops its history for good, so the first deployment that does it must pass allowDroppingHistory: true to createJazz. Each write prunes by the schema its client runs, so rolling back to a version that keeps full history stops the pruning, but what was dropped stays gone and the table still can't be read through history. Throttle the writes, and skip them when only a merged value arrived: presence is stored by its text, so it still finds its place. Refresh the row on an interval while the editor is open and delete it on close. Treat a row as gone when this client has not seen it refreshed for a while (timed by this client's clock, so a wrong clock elsewhere hides nobody), and delete your own rows left behind by a closed tab. Take names and colours from the row's account, not from the row.

Permissions

Comments and presence carry their own pageId, so they can follow the page's permissions while having rules of their own: anyone who can read the page may comment and be seen. A comment is edited, resolved or removed by its author or by the page's editors, and only its owner moves a cursor. See the songSheets, sheetComments and sheetPresence policies in BandBook's permissions.ts.