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
+3 -20
View File
@@ -1,8 +1,7 @@
/**
* The numolus server API (Rails, /api). JSON in and out, the session cookie
* for the signed-in user, the CSRF token from the page's meta tag, and two
* per-request extras: X-Client-Id (this browser tab, the edit-lock holder)
* and X-Share-Token (a share link's token, for access via link).
* for the signed-in user, the CSRF token from the page's meta tag, and
* X-Share-Token (a share link's token, for access via link).
*/
export class ApiError extends Error {
@@ -36,7 +35,6 @@ export interface User {
export interface ServerDocMeta {
id: string;
title: string;
version: number;
role: Role;
owner_name: string;
updated_at: string;
@@ -44,8 +42,8 @@ export interface ServerDocMeta {
}
export interface ServerDoc extends ServerDocMeta {
/** JSON snapshot (the live content comes through DocumentChannel) */
content?: string;
lock?: { locked_by_other: boolean; holder_name: string | null };
}
export interface ShareInfo {
@@ -54,20 +52,6 @@ export interface ShareInfo {
invitations: { email: string; role: 'viewer' | 'editor'; expires_at: string }[];
}
/** One id per browser tab, kept across reloads of that tab. */
export const clientId: string = (() => {
const key = 'numolus:client-id';
try {
const existing = sessionStorage.getItem(key);
if (existing) return existing;
const id = Math.random().toString(36).slice(2) + Date.now().toString(36);
sessionStorage.setItem(key, id);
return id;
} catch {
return Math.random().toString(36).slice(2);
}
})();
function csrfToken(): string {
return document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
}
@@ -83,7 +67,6 @@ export async function api<T = Record<string, unknown>>(method: string, path: str
const headers: Record<string, string> = {
Accept: 'application/json',
'X-CSRF-Token': csrfToken(),
'X-Client-Id': clientId,
};
if (opts.body !== undefined) headers['Content-Type'] = 'application/json';
if (opts.shareToken) headers['X-Share-Token'] = opts.shareToken;