Concepts / Presence and cursors
Presence and cursors
Who's here and where their caret is aren't part of the note. They travel in Yjs awareness: a small, short-lived state per client that the provider relays, and that disappears when the client leaves.
import { createDOMRange } from '@lexical/selection';
import type { YBinding, YCursor } from '@lexical/y';
import type { Awareness } from '@y/protocols/awareness';
import type { LexicalEditor } from 'lexical';
/** Publish who you are and where your selection is, as Yjs relative positions. */
export function publishPresence(editor: LexicalEditor, binding: YBinding, awareness: Awareness) {
awareness.setLocalStateField('user', { name: 'Ana', color: 1 });
return editor.registerUpdateListener(() => {
awareness.setLocalStateField('cursor', binding.getCursor());
});
}
/** Resolve everyone else's cursor against this document, as DOM ranges to draw. */
export function remoteRanges(editor: LexicalEditor, binding: YBinding, awareness: Awareness) {
const ranges: { name: string; range: Range }[] = [];
editor.getEditorState().read(
() => {
for (const [clientID, state] of awareness.getStates()) {
const cursor = state.cursor as YCursor | null | undefined;
if (clientID === awareness.clientID || cursor == null) continue;
const points = binding.$resolveCursor(cursor); // null if the text is gone
if (points === null) continue;
const range = createDOMRange(
editor,
points.anchor.getNode(),
points.anchor.offset,
points.focus.getNode(),
points.focus.offset,
);
if (range !== null) ranges.push({ name: String(state.user?.name), range });
}
},
{ editor },
);
return ranges;
}Cursors that survive edits
binding.getCursor() turns the Lexical selection into Yjs relative positions, anchored to characters rather than offsets: a caret to the character on its left, a range to its first and last characters. A peer resolves them against its own copy with binding.$resolveCursor(), inside a read, so a caret stays put while others type before it. It returns null when the position can't be resolved.
In jot
- Each browser keeps a name, like "Calm Otter", and a colour: an index into eight peer colour tokens, so colours follow light and dark (
presence.ts). RemoteCarets.tsxdraws each peer's caret and selection over the page fromcreateDOMRange. The name shows while the caret moves, and shrinks to the bar after three seconds.- The same states give the avatars at the top right and Note info's "Here now", where someone whose caret moved in the last minute is editing, and anyone else is idle (
people.tsx). - Awareness comes from strangers, so names and colours are checked before they're drawn: a name is cut to 40 characters, and a colour is wrapped into the palette.
Collaborators inside the tab
A peer doesn't need another browser. The example note's demo collaborators, "Ana (demo)" and "jot AI (demo)", and jot AI are in-tab peers: each has its own Y.Doc and client id, a headless editor, and an Awareness of its own. Their awareness changes are applied to the note's, which the provider sends like any other, so their carets show in every window. The example runs its demo only in the tab that created it; other windows see it through the relay (peers/peer.ts).
On the relay
The Durable Object relays awareness without y-protocols' Awareness class, whose timer would keep the object from hibernating. It keeps each client's last state, forwards changes, and announces a client's removal when its socket closes (awareness.ts). Clients time out stale states on their own.