Live collaboration with Yjs over ActionCable

All editors now work on a server document at the same time instead of
taking turns behind an edit lock.

- The document's content is a sequence of Yjs updates (document_updates);
  DocumentChannel stores and relays them immediately, checks access on
  subscribe and again whenever links or memberships change.
- app/frontend/collab/: the store stays position-based; a binding maps it
  to a Yjs form with stable row/column ids, so a row inserted by one
  person doesn't misplace what another is typing. Remote changes are
  applied only after pending local ones went into Yjs.
- Per-person undo (Y.UndoManager), presence via awareness: the others'
  selections as coloured boxes with names, avatars in the toolbar.
- Existing JSON documents convert on first open, deterministically, so
  two people opening one at once end up with the same Yjs state.
- Editors compact the stored updates after 200 and keep a JSON snapshot
  current for export and duplicating. Edit lock and polling are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-28 20:41:02 +02:00
co-authored by Claude Opus 5.5
parent e14bada114
commit 7fce972fdc
27 changed files with 1685 additions and 413 deletions
+401
View File
@@ -0,0 +1,401 @@
/**
* One live-editing session on a server document: the Yjs document, its
* connection to the server (ActionCable, see app/channels/document_channel.rb),
* per-person undo and the presence of the others (awareness: who is here,
* what they have selected).
*
* The store stays the single source for the UI. Local edits flow
* store → Binding.sync → Yjs → server; the others' edits flow
* server → Yjs → readDocument → store.applyExternalState. Before a remote
* change is applied, any not-yet-synced local change goes into Yjs first, so
* neither side can overwrite the other.
*/
import * as Y from 'yjs';
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness';
import { fromBase64, toBase64 } from 'lib0/buffer';
import { createConsumer, type Consumer, type Subscription } from '@rails/actioncable';
import { reactive, ref, watch, type WatchStopHandle } from 'vue';
import { useDocument, setCollabHooks, contentFingerprint, type UndoProvider } from '../store/document';
import type { DocumentState } from '../store/types';
import type { Role } from '../io/api';
import { Binding, LOCAL, REMOTE, allAxes, initialUpdate, isInitialized, readDocument, type Axes } from './ydoc';
import { validateDocument } from '../io/json';
const SYNC_DELAY = 40; // ms: coalesce a burst of store changes into one Yjs transaction
const SNAPSHOT_EVERY = 30_000; // JSON snapshot for the server (export, duplicate)
const COMPACT_AFTER = 200; // stored updates before they're merged into one
let consumer: Consumer | null = null;
const cable = () => (consumer ??= createConsumer('/cable'));
export interface Peer {
clientId: number;
name: string;
color: string;
sheetId: string | null;
tableId: string | null;
/** selection rectangle in current positions, or null */
rect: { c0: number; r0: number; c1: number; r1: number } | null;
}
export interface SessionEvents {
onRole(role: Role): void;
onRevoked(): void;
onTitle(title: string): void;
}
interface InitMsg {
type: 'init';
role: Role;
title: string;
updates: { id: number; data: string }[];
content?: string;
}
type Msg =
| InitMsg
| { type: 'update'; id: number; data: string; from: string }
| { type: 'awareness'; data: string; from: string }
| { type: 'compacted'; removed: number[]; id: number }
| { type: 'meta'; title: string }
| { type: 'role'; role: Role }
| { type: 'revoked' }
| { type: 'error'; error: string };
type SelIds = { tableId: string; a: [string, string]; f: [string, string] } | null;
export class CollabSession {
readonly ydoc = new Y.Doc();
readonly awareness = new Awareness(this.ydoc);
readonly peers = reactive<Peer[]>([]);
readonly online = ref(false);
role: Role = 'viewer';
private store = useDocument();
private binding = new Binding(this.ydoc);
private undoManager: Y.UndoManager | null = null;
private sub: Subscription | null = null;
private key = Math.random().toString(36).slice(2) + Date.now().toString(36);
private syncTimer: ReturnType<typeof setTimeout> | undefined;
private snapshotTimer: ReturnType<typeof setInterval> | undefined;
private compactTimer: ReturnType<typeof setTimeout> | undefined;
private queue: Msg[] = [];
private queued = false;
private knownIds = new Set<number>();
private axes = new Map<string, Axes>();
private lastSnapshot = '';
private initialized = false;
private stopWatch: WatchStopHandle | null = null;
private attached = false;
private ready!: { resolve: (d: DocumentState) => void; reject: (e: Error) => void };
readonly canUndo = ref(false);
readonly canRedo = ref(false);
constructor(
private docId: string,
private token: string | null,
private user: { name: string; color: string },
private events: SessionEvents,
) {
this.ydoc.on('update', (update: Uint8Array, origin: unknown) => {
if (origin === REMOTE || !this.canEdit) return;
this.sub?.perform('update', { data: toBase64(update) });
});
this.awareness.on('update', ({ added, updated, removed }: Record<string, number[]>, origin: unknown) => {
if (origin !== 'local') return;
const mine = [...added, ...updated, ...removed].filter((c) => c === this.ydoc.clientID);
if (mine.length) this.sub?.perform('awareness', { data: toBase64(encodeAwarenessUpdate(this.awareness, mine)) });
});
this.awareness.on('change', () => this.updatePeers());
}
get canEdit() {
return this.role !== 'viewer';
}
/** Connect; resolves with the document once the server sent it. */
start(): Promise<DocumentState> {
const done = new Promise<DocumentState>((resolve, reject) => (this.ready = { resolve, reject }));
this.sub = cable().subscriptions.create(
{ channel: 'DocumentChannel', id: this.docId, token: this.token, key: this.key },
{
received: (m: Msg) => this.receive(m),
connected: () => (this.online.value = true),
disconnected: () => (this.online.value = false),
rejected: () => this.ready.reject(new Error('Kein Zugriff auf dieses Dokument.')),
},
);
return done;
}
/** Take over the store: sync hooks, per-person undo, own presence. */
attach(): UndoProvider {
this.attached = true;
this.axes = allAxes(this.ydoc);
setCollabHooks({
onChange: () => this.scheduleSync(),
onStructure: (tableId, axis, at, delta) => this.binding.structure(tableId, axis, at, delta),
});
const root = this.ydoc.getMap('numolus');
this.undoManager = new Y.UndoManager(root, { trackedOrigins: new Set([LOCAL]), captureTimeout: 400 });
const refresh = () => {
this.canUndo.value = this.undoManager!.canUndo();
this.canRedo.value = this.undoManager!.canRedo();
};
this.undoManager.on('stack-item-added', refresh);
this.undoManager.on('stack-item-popped', refresh);
this.undoManager.on('stack-cleared', refresh);
this.awareness.setLocalState({ user: this.user, sheetId: null, sel: null });
this.stopWatch = watch(
() => [this.store.doc.selection, this.store.doc.activeSheetId] as const,
() => this.publishSelection(),
{ deep: true, immediate: true },
);
this.snapshotTimer = setInterval(() => this.sendSnapshot(), SNAPSHOT_EVERY);
this.lastSnapshot = contentFingerprint(this.store.doc);
this.rebuild(); // whatever arrived since start() resolved
return {
undo: () => this.undoRedo('undo'),
redo: () => this.undoRedo('redo'),
canUndo: this.canUndo,
canRedo: this.canRedo,
};
}
/** Leave: send what's pending, say goodbye to the others, disconnect. */
destroy() {
this.flushLocal();
this.sendSnapshot();
if (this.attached) setCollabHooks(null);
this.stopWatch?.();
clearInterval(this.snapshotTimer);
clearTimeout(this.compactTimer);
removeAwarenessStates(this.awareness, [this.ydoc.clientID], 'local');
this.awareness.destroy();
this.undoManager?.destroy();
this.sub?.unsubscribe();
this.sub = null;
this.ydoc.destroy();
}
// ---- local → Yjs ---------------------------------------------------------
private scheduleSync() {
if (!this.canEdit) return;
clearTimeout(this.syncTimer);
this.syncTimer = setTimeout(() => this.flushLocal(), SYNC_DELAY);
}
/** Put not-yet-synced store changes into Yjs now. */
flushLocal() {
if (this.syncTimer === undefined && !this.binding.hasPending) return;
clearTimeout(this.syncTimer);
this.syncTimer = undefined;
if (!this.canEdit || !isInitialized(this.ydoc)) return;
this.binding.sync(this.store.doc);
this.axes = allAxes(this.ydoc);
}
private undoRedo(which: 'undo' | 'redo') {
this.flushLocal();
if (which === 'undo') this.undoManager?.undo();
else this.undoManager?.redo();
this.rebuild();
}
// ---- server → Yjs ----------------------------------------------------------
private receive(m: Msg) {
// bursts are applied together, after the current task
this.queue.push(m);
if (this.queued) return;
this.queued = true;
queueMicrotask(() => {
this.queued = false;
const msgs = this.queue.splice(0);
this.process(msgs);
});
}
private process(msgs: Msg[]) {
let changed = false;
this.flushLocal();
for (const m of msgs) {
switch (m.type) {
case 'init':
this.init(m);
changed = this.initialized;
break;
case 'update':
this.knownIds.add(m.id);
if (m.from !== this.key) {
Y.applyUpdate(this.ydoc, fromBase64(m.data), REMOTE);
changed = true;
}
break;
case 'awareness':
applyAwarenessUpdate(this.awareness, fromBase64(m.data), 'remote');
break;
case 'compacted':
for (const id of m.removed) this.knownIds.delete(id);
this.knownIds.add(m.id);
clearTimeout(this.compactTimer);
this.compactTimer = undefined;
break;
case 'meta':
this.events.onTitle(m.title);
break;
case 'role':
this.role = m.role;
this.events.onRole(m.role);
break;
case 'revoked':
this.events.onRevoked();
return;
}
}
if (changed && this.attached) this.rebuild();
this.maybeCompact();
}
private init(m: InitMsg) {
const first = !this.initialized;
this.role = m.role;
// what the server has, to find out what it's missing from us (reconnect)
const server = new Y.Doc();
for (const u of m.updates) {
this.knownIds.add(u.id);
const bytes = fromBase64(u.data);
Y.applyUpdate(server, bytes);
Y.applyUpdate(this.ydoc, bytes, REMOTE);
}
if (!m.updates.length && m.content && !isInitialized(this.ydoc)) {
// not yet converted: everyone converts the same way (see initialUpdate)
let json: DocumentState;
try {
json = validateDocument(JSON.parse(m.content));
} catch (err) {
this.ready.reject(err as Error);
return;
}
const u = initialUpdate(json);
Y.applyUpdate(this.ydoc, u, REMOTE);
if (this.canEdit) this.sub?.perform('update', { data: toBase64(u) });
} else if (!first && this.canEdit) {
const missing = Y.encodeStateAsUpdate(this.ydoc, Y.encodeStateVector(server));
if (missing.length > 2) this.sub?.perform('update', { data: toBase64(missing) });
}
server.destroy();
if (!isInitialized(this.ydoc)) {
this.ready.reject(new Error('Das Dokument ist leer.'));
return;
}
this.initialized = true;
if (first) {
this.events.onTitle(m.title);
this.ready.resolve(readDocument(this.ydoc));
} else {
this.events.onRole(m.role);
}
}
/** Show the current Yjs state in the store, keeping this person's view. */
private rebuild() {
const selIds = this.selectionIds();
this.store.applyExternalState(readDocument(this.ydoc));
this.axes = allAxes(this.ydoc);
this.restoreSelection(selIds);
this.updatePeers();
}
// ---- selection by ids (survives rows inserted by others) --------------------
private idsAt(tableId: string, c: number, r: number): [string, string] | null {
const ax = this.axes.get(tableId);
return ax && ax.cols[c] !== undefined && ax.rows[r] !== undefined ? [ax.cols[c], ax.rows[r]] : null;
}
private posOf(tableId: string, ids: [string, string]): { col: number; row: number } | null {
const ax = this.axes.get(tableId);
if (!ax) return null;
const col = ax.cols.indexOf(ids[0]);
const row = ax.rows.indexOf(ids[1]);
return col < 0 || row < 0 ? null : { col, row };
}
private selectionIds(): SelIds {
const s = this.store.doc.selection;
if (!s) return null;
const a = this.idsAt(s.tableId, s.anchor.col, s.anchor.row);
const f = this.idsAt(s.tableId, s.focus.col, s.focus.row);
return a && f ? { tableId: s.tableId, a, f } : null;
}
private restoreSelection(ids: SelIds) {
const s = this.store.doc.selection;
if (!ids || !s || s.tableId !== ids.tableId) return;
const anchor = this.posOf(ids.tableId, ids.a);
const focus = this.posOf(ids.tableId, ids.f);
if (!anchor || !focus) return; // its row/column is gone: stay where it was (clamped)
if (anchor.col === s.anchor.col && anchor.row === s.anchor.row && focus.col === s.focus.col && focus.row === s.focus.row) return;
this.store.doc.selection = { tableId: ids.tableId, anchor, focus };
}
// ---- presence ---------------------------------------------------------------
private publishSelection() {
const sel = this.selectionIds();
this.awareness.setLocalStateField('sheetId', this.store.doc.activeSheetId);
this.awareness.setLocalStateField('sel', sel);
}
private updatePeers() {
const out: Peer[] = [];
for (const [clientId, st] of this.awareness.getStates()) {
if (clientId === this.ydoc.clientID || !st.user) continue;
const sel = st.sel as SelIds;
let rect: Peer['rect'] = null;
if (sel) {
const a = this.posOf(sel.tableId, sel.a);
const f = this.posOf(sel.tableId, sel.f);
if (a && f) {
rect = {
c0: Math.min(a.col, f.col),
c1: Math.max(a.col, f.col),
r0: Math.min(a.row, f.row),
r1: Math.max(a.row, f.row),
};
}
}
out.push({
clientId,
name: String(st.user.name ?? '?'),
color: String(st.user.color ?? '#888'),
sheetId: (st.sheetId as string) ?? null,
tableId: sel?.tableId ?? null,
rect,
});
}
this.peers.splice(0, this.peers.length, ...out);
}
// ---- housekeeping ------------------------------------------------------------
/** JSON of the document for the server (export, duplicate, list), when changed. */
private sendSnapshot() {
if (!this.canEdit || !this.initialized || !this.attached) return;
const fp = contentFingerprint(this.store.doc);
if (fp === this.lastSnapshot) return;
this.lastSnapshot = fp;
this.sub?.perform('snapshot', { content: JSON.stringify(this.store.doc) });
}
/** Many stored updates → one; whoever notices first does it. */
private maybeCompact() {
if (!this.canEdit || this.compactTimer || this.knownIds.size < COMPACT_AFTER) return;
this.compactTimer = setTimeout(() => {
this.flushLocal();
this.sub?.perform('compact', {
state: toBase64(Y.encodeStateAsUpdate(this.ydoc)),
ids: [...this.knownIds],
snapshot: JSON.stringify(this.store.doc),
});
// if no confirmation arrives, try again later
this.compactTimer = setTimeout(() => (this.compactTimer = undefined), 30_000);
}, 1000 + Math.random() * 3000);
}
}