Features / Blame and history
Blame and history
Every item Yjs stores carries the id of the client that inserted it. Yjs 14 adds attributions: a map from those ids to anything you like, here a person, that renders alongside the content. jot computes blame in the browser from the document it already has; the relay doesn't change.
In a note, ⋯ → Show blame underlines each run in its author's colour and names the authors in the margin. The "Blame" chip in the dock opens a legend with each person's share in characters; click a name to focus them. Hovering text says who wrote it and when they were last active. After you reconnect from Work offline, blame shows for four seconds, so you can see what merged.
- 1 · Who is who A people registry in the note maps Yjs client ids to people.
- 2 · Attribute Each person's inserted ids become one content map.
- 3 · Render toDelta returns every run of text with its author.
- 4 · Paint Underlines and margin names beside Lexical, never in it.
People, not tabs
Yjs gives every tab a new client id, so a client id alone isn't a person. jot keeps a second root in the note, jot-people, beside lexical-y: one attribute per client id, holding a person id, a name, a colour, a kind (person, demo or ai, with who asked for an AI's edit) and when they were last seen.
import * as Y from '@y/y';
/**
* One entry per Yjs client id, in a second root beside 'lexical-y'. The keys
* are short because the registry counts towards the note's size.
*/
export function register(doc: Y.Doc, person: { id: string; name: string; color: number }) {
doc.get('jot-people').setAttr(String(doc.clientID), {
p: person.id,
n: person.name,
c: person.color,
k: 'person',
s: Math.floor(Date.now() / 1000),
});
}
/** Client id to person id, for the content map. Every entry is a stranger's input. */
export function clientsToPeople(doc: Y.Doc): Map<number, string> {
const clients = new Map<number, string>();
for (const [key, value] of doc.get('jot-people').attrEntries() as Iterable<[string, unknown]>) {
const person = (value as { p?: unknown } | null)?.p;
if (Number.isSafeInteger(Number(key)) && typeof person === 'string') {
clients.set(Number(key), person);
}
}
return clients;
}- "You" is decided by this browser, never by the note. Each browser keeps a person id and the client ids its own sessions registered. An entry for one of those client ids is always you; an entry claiming your person id from any other client id is someone else. There are no accounts, so anyone can write any entry: names are whatever people chose, and a last-seen time more than five minutes in the future counts as never seen, so a forged entry can't outrank honest ones for good.
- What it costs. A session writes its entry on its first edit, not on a visit, and refreshes its last-seen time at most every ten minutes while editing. An entry is about 85 bytes; each refresh leaves about 26 bytes behind for good (the old value's tombstone, and the split it makes in the writer's run of items), about 160 bytes per person per hour of editing. It all counts towards the 2 MB.
Attributions
Every inserted id still in the document, split by client, grouped by the person the registry names, and attributed with createContentAttribute('insert', personId). Clients missing from the registry stay unattributed.
/** The ContentMap attributing each registered client's inserts to its person. */
export function buildContentMap(doc: Y.Doc, clients: Map<number, string>) {
const ids = Y.createInsertSetFromStructStore(doc.store, true);
const byPerson = new Map<string, Y.IdSet>();
ids.forEach((range, client) => {
const personId = clients.get(client);
if (personId === undefined) {
return;
}
let set = byPerson.get(personId);
if (set === undefined) {
set = Y.createIdSet();
byPerson.set(personId, set);
}
set.add(client, range.clock, range.len);
});
const maps = [...byPerson].map(([personId, set]) =>
Y.createIdMapFromIdSet(set, [Y.createContentAttribute('insert', personId)]),
);
return Y.createContentMap(Y.mergeIdMaps(maps), Y.createIdMap());
}Rendering the lexical-y root with createAttributionsRenderer gives the usual tree, with attribution: { insert: ['ana'] } on every text run. jot walks it into lines of runs (a length and a person) the way the inspector walks it into rows.
/** Blame for the note as it is now. Never throws. */
export function computeBlame(doc: Y.Doc, identity: LocalIdentity = localIdentity()): BlameSnapshot {
try {
const { clients, people } = readRegistry(doc, identity);
const renderer = Y.createAttributionsRenderer(buildContentMap(doc, clients));
const root = doc.get(DEFAULT_ROOT_NAME);
const delta = root.toDelta({ deep: true, renderer });
const { lines, moves } = readDelta((delta as { toJSON(): unknown }).toJSON());
if (moves) {
// Lines a peer moved or joined are not where storage has them: skip them.
markMovedLines(doc, root, lines);
}
let total = 0;
const shares: BlameSnapshot['shares'] = [];
for (const [personId, chars] of sharesOf(lines)) {
const person = people.get(personId);
if (person !== undefined) {
shares.push({ person, chars });
total += chars;
}
}
shares.sort((a, b) => b.chars - a.chars || a.person.name.localeCompare(b.person.name));
return separateColors({ lines, people, shares, total });
} catch (error) {
console.warn('jot: blame could not read this note', error);
return EMPTY_SNAPSHOT;
}
}Never guess
- Moves and joins. Layout 1 writes a moved line as a deletion and an insertion, so whoever moved it wrote it again, and blame names them.
toDeltarenders storage order, which then is Lexical's order. A note from an earlier build in layout 2 can hold move records (↷), which show a range of lines somewhere else; such a note opens read only, and blame walks its stored items to find every line a record covers, and the line a join shows in, and leaves those unpainted. If that walk and the delta disagree at all, nothing is painted. - Pairing lines. Blame's lines pair with Lexical's line elements (a walk of the editor state for blocks whose children are inline). A text unique on both sides pairs wherever it is; a repeated text pairs only in order, when both sides hold it equally often. A line whose text doesn't match its element is skipped.
- Deleted text isn't shown. jot garbage-collects it to stay under 2 MB, so blame shows who wrote what's in the note now; there's no version history to step through.
Painting without touching Lexical's DOM
A TreeWalker maps each run's offsets onto the line's text nodes (a line break counts as one character), and the runs become StaticRanges in one Highlight per person. A constructed style sheet gives each ::highlight(jot-blame-N) a 2px underline in that person's colour token; focusing a person fades the others.
/** A run of `length` characters at `start` in one of the editor's text nodes. */
export function runRange(node: Text, start: number, length: number): StaticRange {
// Static: it doesn't follow DOM changes, so it costs nothing between paints.
return new StaticRange({
startContainer: node,
startOffset: start,
endContainer: node,
endOffset: start + length,
});
}
/** One Highlight per person, underlined in their colour. Lexical's DOM is untouched. */
export function underline(people: { color: string; ranges: StaticRange[] }[]) {
const sheet = new CSSStyleSheet();
const rules = people.map(({ color, ranges }, index) => {
CSS.highlights.set(`blame-${index}`, new Highlight(...ranges));
return `::highlight(blame-${index}) { text-decoration: underline 2px ${color}; }`;
});
sheet.replaceSync(rules.join('\n'));
document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet];
return () => {
people.forEach((_, index) => CSS.highlights.delete(`blame-${index}`));
document.adoptedStyleSheets = document.adoptedStyleSheets.filter((s) => s !== sheet);
};
}The margin is an overlay beside the column: a bar for each run of lines with the same main author, named where there's room. While blame shows, it all recomputes 150ms after the last change, and at least once a second while changes keep coming. The code is in src/client/editor/blame/.