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:
+3
-20
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user