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:
+90
-235
@@ -10,16 +10,16 @@
|
||||
* (`/d/<id>#k=<token>`) also opens a server document without an account --
|
||||
* the token is kept in this browser (localStorage) and sent along.
|
||||
*
|
||||
* Persistence: the store's debounced save lands in `persist()` and goes to
|
||||
* IndexedDB or to the server. Server documents have a single-editor lock
|
||||
* (until live collaboration exists): whoever holds it edits, everyone else
|
||||
* sees the document read-only and follows its changes by polling.
|
||||
* Local documents are saved by the store's debounced save (`persist()`).
|
||||
* Server documents are edited live, together with everyone who has them
|
||||
* open: a CollabSession (src/collab/session.ts) keeps the store and the
|
||||
* shared Yjs document in step over ActionCable.
|
||||
*
|
||||
* Other tabs: a BroadcastChannel keeps lists current and lets a local
|
||||
* document open in two tabs follow the other tab's saves.
|
||||
*/
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
import { computed, reactive, shallowRef } from 'vue';
|
||||
import { useDocument, setPersistHandler, setReadOnlyNotifier, contentFingerprint } from './document';
|
||||
import type { DocumentState } from './types';
|
||||
import { DocumentLibrary, newDocId, type DocMeta } from '../io/library';
|
||||
@@ -28,6 +28,7 @@ import { validateDocument } from '../io/json';
|
||||
import { createBlankDocument, createSeedDocument } from '../data/seed';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import { useSession } from './session';
|
||||
import { CollabSession } from '../collab/session';
|
||||
|
||||
/** The single-document localStorage keys used before the library existed --
|
||||
* read once and moved into the library as its first document. */
|
||||
@@ -37,9 +38,8 @@ const LINKS_KEY = 'numolus:links';
|
||||
const CHANNEL = 'numolus:library';
|
||||
const DEFAULT_TITLE = 'Unbenannt';
|
||||
const APP_TITLE = 'numolus';
|
||||
const UPLOAD_DELAY = 800; // ms after the last change
|
||||
const HEARTBEAT = 30_000; // refresh the edit lock (server TTL is 90 s)
|
||||
const POLL = 10_000; // read-only: look for someone else's changes
|
||||
const ANON_COLORS = ['#e67e22', '#2980b9', '#27ae60', '#8e44ad', '#c0392b', '#16a085'];
|
||||
const ANON_NAMES = ['Fuchs', 'Dachs', 'Luchs', 'Otter', 'Eule', 'Biber', 'Reh', 'Igel'];
|
||||
|
||||
export type DocSource = 'local' | 'server';
|
||||
export interface DocRef {
|
||||
@@ -61,8 +61,8 @@ type Msg =
|
||||
| { type: 'here'; id: string };
|
||||
|
||||
function metaOf(d: ServerDoc): ServerDocMeta {
|
||||
const { id, title, version, role, owner_name, updated_at, updated_by_name } = d;
|
||||
return { id, title, version, role, owner_name, updated_at, updated_by_name };
|
||||
const { id, title, role, owner_name, updated_at, updated_by_name } = d;
|
||||
return { id, title, role, owner_name, updated_at, updated_by_name };
|
||||
}
|
||||
|
||||
function readLinks(): LinkDoc[] {
|
||||
@@ -80,6 +80,13 @@ function writeLinks(links: LinkDoc[]) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Name + colour shown to the others: the account, or a random animal. */
|
||||
function presenceIdentity(user: { name: string; color: string } | null) {
|
||||
if (user) return { name: user.name, color: user.color };
|
||||
const pick = <T>(a: T[]) => a[Math.floor(Math.random() * a.length)];
|
||||
return { name: `Anonym (${pick(ANON_NAMES)})`, color: pick(ANON_COLORS) };
|
||||
}
|
||||
|
||||
function createLibrary() {
|
||||
const store = useDocument();
|
||||
const toasts = useToasts();
|
||||
@@ -96,13 +103,12 @@ function createLibrary() {
|
||||
/** documents opened via share link without an account */
|
||||
linkDocs: readLinks(),
|
||||
current: null as DocRef | null,
|
||||
/** the open server document's metadata (title, role, version, ...) */
|
||||
/** the open server document's metadata (title, role, ...) */
|
||||
server: null as ServerDocMeta | null,
|
||||
/** someone else holds the edit lock on the open server document */
|
||||
lockedBy: null as string | null,
|
||||
/** server saves waiting or failing */
|
||||
syncState: 'idle' as 'idle' | 'saving' | 'offline',
|
||||
});
|
||||
/** the live session of the open server document */
|
||||
const collab = shallowRef<CollabSession | null>(null);
|
||||
const identity = presenceIdentity(session.state.user);
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
const c = state.current;
|
||||
@@ -114,11 +120,14 @@ function createLibrary() {
|
||||
const currentRole = computed<Role>(() =>
|
||||
state.current?.source === 'server' ? (state.server?.role ?? 'viewer') : 'owner',
|
||||
);
|
||||
const canEdit = computed(() => currentRole.value !== 'viewer' && !state.lockedBy);
|
||||
const canEdit = computed(() => currentRole.value !== 'viewer');
|
||||
/** others on the open document right now */
|
||||
const peers = computed(() => collab.value?.peers ?? []);
|
||||
const online = computed(() => collab.value?.online.value ?? true);
|
||||
|
||||
let lib: DocumentLibrary | null = null;
|
||||
let channel: BroadcastChannel | null = null;
|
||||
/** content fingerprint of what's saved for the open document */
|
||||
/** content fingerprint of what's saved for the open local document */
|
||||
let savedContent = '';
|
||||
/** `updatedAt` of the open local document as this tab last wrote or read it */
|
||||
let knownUpdatedAt = 0;
|
||||
@@ -126,15 +135,6 @@ function createLibrary() {
|
||||
let writeQueue: Promise<void> = Promise.resolve();
|
||||
const warnedShared = new Set<string>();
|
||||
|
||||
// server document state
|
||||
let uploadTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let uploading: Promise<void> | null = null;
|
||||
let pendingJson: string | null = null;
|
||||
let pendingFp = '';
|
||||
let holdsLock = false;
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const post = (m: Msg) => channel?.postMessage(m);
|
||||
const enqueue = (job: () => Promise<void>) => {
|
||||
writeQueue = writeQueue.then(job).catch((err) => {
|
||||
@@ -188,19 +188,15 @@ function createLibrary() {
|
||||
|
||||
function applyReadOnly() {
|
||||
store.ui.readOnly =
|
||||
state.current?.source !== 'server'
|
||||
? null
|
||||
: currentRole.value === 'viewer'
|
||||
? 'Nur Lesen – du kannst dieses Dokument ansehen, aber nicht bearbeiten.'
|
||||
: state.lockedBy
|
||||
? `${state.lockedBy} bearbeitet gerade – nur Lesen, bis die Bearbeitung beendet ist.`
|
||||
: null;
|
||||
state.current?.source === 'server' && currentRole.value === 'viewer'
|
||||
? 'Nur Lesen – du kannst dieses Dokument ansehen, aber nicht bearbeiten.'
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Put `next` into the store as the open document. */
|
||||
function show(ref: DocRef, next: DocumentState, keepView = false) {
|
||||
if (keepView && next.sheets.some((s) => s.id === store.doc.activeSheetId)) {
|
||||
// someone else's save: take the content, keep this tab's cursor/sheet
|
||||
// another tab's save: take the content, keep this tab's cursor/sheet
|
||||
next.activeSheetId = store.doc.activeSheetId;
|
||||
next.activeObjectId = store.doc.activeObjectId;
|
||||
next.selection = store.doc.selection;
|
||||
@@ -213,12 +209,10 @@ function createLibrary() {
|
||||
updateWindowTitle();
|
||||
}
|
||||
|
||||
// ---- persistence -----------------------------------------------------
|
||||
// ---- persistence (local documents) ---------------------------------------
|
||||
function persist(doc: DocumentState) {
|
||||
const c = state.current;
|
||||
if (!c) return;
|
||||
if (c.source === 'server') return persistServer(doc);
|
||||
if (!lib) return;
|
||||
if (!c || c.source !== 'local' || !lib) return; // server documents: CollabSession
|
||||
const id = c.id;
|
||||
const json = JSON.stringify(doc);
|
||||
const fp = contentFingerprint(doc);
|
||||
@@ -246,181 +240,14 @@ function createLibrary() {
|
||||
});
|
||||
}
|
||||
|
||||
function persistServer(doc: DocumentState) {
|
||||
if (!canEdit.value) return;
|
||||
const fp = contentFingerprint(doc);
|
||||
if (fp === savedContent && pendingJson === null) return; // cursor move only
|
||||
pendingJson = JSON.stringify(doc);
|
||||
pendingFp = fp;
|
||||
clearTimeout(uploadTimer);
|
||||
uploadTimer = setTimeout(() => void upload(), UPLOAD_DELAY);
|
||||
}
|
||||
|
||||
/** Send the pending change of the open server document (one at a time). */
|
||||
async function upload(): Promise<void> {
|
||||
clearTimeout(uploadTimer);
|
||||
uploadTimer = undefined;
|
||||
if (uploading) {
|
||||
await uploading;
|
||||
if (pendingJson !== null) return upload();
|
||||
return;
|
||||
}
|
||||
const c = state.current;
|
||||
const meta = state.server;
|
||||
if (!c || c.source !== 'server' || !meta || pendingJson === null) return;
|
||||
const json = pendingJson;
|
||||
const fp = pendingFp;
|
||||
pendingJson = null;
|
||||
state.syncState = 'saving';
|
||||
uploading = (async () => {
|
||||
try {
|
||||
const r = await api<{ document: ServerDocMeta }>('PATCH', `/documents/${c.id}`, {
|
||||
body: { content: json, base_version: meta.version },
|
||||
shareToken: shareToken(c.id),
|
||||
});
|
||||
if (isCurrent(c)) {
|
||||
state.server = { ...meta, ...r.document, role: meta.role };
|
||||
savedContent = fp;
|
||||
}
|
||||
state.syncState = 'idle';
|
||||
} catch (err) {
|
||||
await uploadFailed(c, err, json, fp);
|
||||
} finally {
|
||||
uploading = null;
|
||||
}
|
||||
})();
|
||||
await uploading;
|
||||
if (pendingJson !== null && isCurrent(c)) return upload();
|
||||
}
|
||||
|
||||
async function uploadFailed(c: DocRef, err: unknown, json: string, fp: string) {
|
||||
if (!(err instanceof ApiError) || err.status === 0 || err.status >= 500) {
|
||||
// offline / server trouble: keep it and try again
|
||||
state.syncState = 'offline';
|
||||
if (pendingJson === null) {
|
||||
pendingJson = json;
|
||||
pendingFp = fp;
|
||||
}
|
||||
clearTimeout(uploadTimer);
|
||||
uploadTimer = setTimeout(() => void upload(), 5000);
|
||||
return;
|
||||
}
|
||||
state.syncState = 'idle';
|
||||
// the server's version wins; edits made meanwhile would be based on the
|
||||
// rejected state, so they go too
|
||||
pendingJson = null;
|
||||
clearTimeout(uploadTimer);
|
||||
if (err.status === 423) {
|
||||
holdsLock = false;
|
||||
state.lockedBy = (err.body.holder_name as string) || 'Jemand anderes';
|
||||
toasts.error(`${state.lockedBy} bearbeitet dieses Dokument inzwischen – deine letzte Änderung wurde nicht gespeichert.`);
|
||||
} else if (err.status === 409) {
|
||||
toasts.error('Das Dokument wurde inzwischen anderswo geändert – deine letzte Änderung wurde nicht gespeichert.');
|
||||
} else {
|
||||
toasts.error(`Speichern fehlgeschlagen: ${err.message}`);
|
||||
}
|
||||
if (isCurrent(c)) await reloadServer(c.id, true);
|
||||
startServerTimers();
|
||||
}
|
||||
|
||||
/** Before leaving the open server document: send what's pending. */
|
||||
async function flushServer() {
|
||||
store.flushSave();
|
||||
if (pendingJson !== null || uploading) await upload();
|
||||
}
|
||||
|
||||
// ---- edit lock + polling ---------------------------------------------
|
||||
function stopServerTimers() {
|
||||
clearInterval(heartbeatTimer);
|
||||
clearInterval(pollTimer);
|
||||
heartbeatTimer = pollTimer = undefined;
|
||||
}
|
||||
|
||||
function startServerTimers() {
|
||||
stopServerTimers();
|
||||
if (state.current?.source !== 'server') return;
|
||||
if (holdsLock) heartbeatTimer = setInterval(() => void heartbeat(), HEARTBEAT);
|
||||
else pollTimer = setInterval(() => void poll(), POLL);
|
||||
}
|
||||
|
||||
/** Try to take the edit lock of the open server document. */
|
||||
async function takeLock(id: string): Promise<boolean> {
|
||||
if (currentRole.value === 'viewer') return false;
|
||||
try {
|
||||
const r = await api<{ lock: { mine: boolean; holder_name: string | null } }>('POST', `/documents/${id}/lock`, {
|
||||
shareToken: shareToken(id),
|
||||
});
|
||||
holdsLock = r.lock.mine;
|
||||
state.lockedBy = r.lock.mine ? null : r.lock.holder_name || 'Jemand anderes';
|
||||
} catch {
|
||||
holdsLock = false;
|
||||
}
|
||||
applyReadOnly();
|
||||
return holdsLock;
|
||||
}
|
||||
|
||||
async function heartbeat() {
|
||||
const c = state.current;
|
||||
if (c?.source !== 'server') return;
|
||||
const had = holdsLock;
|
||||
await takeLock(c.id);
|
||||
if (had && !holdsLock) {
|
||||
toasts.error(`${state.lockedBy ?? 'Jemand anderes'} bearbeitet dieses Dokument jetzt.`);
|
||||
startServerTimers();
|
||||
}
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
const c = state.current;
|
||||
if (c?.source !== 'server' || document.visibilityState === 'hidden') return;
|
||||
if (currentRole.value !== 'viewer' && (await takeLock(c.id))) {
|
||||
await reloadServer(c.id, true);
|
||||
toasts.info('Du kannst das Dokument jetzt bearbeiten.');
|
||||
startServerTimers();
|
||||
return;
|
||||
}
|
||||
await reloadServer(c.id, true);
|
||||
}
|
||||
|
||||
function releaseLock(id: string) {
|
||||
if (!holdsLock) return;
|
||||
holdsLock = false;
|
||||
void api('DELETE', `/documents/${id}/lock`, { shareToken: shareToken(id), keepalive: true }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Fetch the server document again; with `keepView` only when it changed. */
|
||||
async function reloadServer(id: string, keepView: boolean) {
|
||||
const known = keepView ? state.server?.version : undefined;
|
||||
try {
|
||||
const q = known ? `?known_version=${known}` : '';
|
||||
const r = await api<{ document: ServerDoc; unchanged?: boolean }>('GET', `/documents/${id}${q}`, {
|
||||
shareToken: shareToken(id),
|
||||
});
|
||||
if (!isCurrent({ source: 'server', id })) return;
|
||||
const { content } = r.document;
|
||||
state.server = metaOf(r.document);
|
||||
if (!r.unchanged && content !== undefined) {
|
||||
show({ source: 'server', id }, validateDocument(JSON.parse(content)), keepView);
|
||||
}
|
||||
updateWindowTitle();
|
||||
applyReadOnly();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 404) {
|
||||
toasts.error('Kein Zugriff mehr auf dieses Dokument.');
|
||||
await openFallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- leaving / opening -----------------------------------------------
|
||||
async function leaveCurrent() {
|
||||
const c = state.current;
|
||||
if (!c) return;
|
||||
if (c.source === 'server') {
|
||||
await flushServer();
|
||||
stopServerTimers();
|
||||
releaseLock(c.id);
|
||||
state.lockedBy = null;
|
||||
collab.value?.destroy();
|
||||
collab.value = null;
|
||||
store.setUndoProvider(null);
|
||||
state.server = null;
|
||||
} else {
|
||||
store.flushSave();
|
||||
@@ -451,9 +278,10 @@ function createLibrary() {
|
||||
|
||||
async function openServer(id: string, opts: { push?: boolean } = {}): Promise<boolean> {
|
||||
if (isCurrent({ source: 'server', id })) return true;
|
||||
let r: { document: ServerDoc };
|
||||
let meta: ServerDocMeta;
|
||||
try {
|
||||
r = await api<{ document: ServerDoc }>('GET', `/documents/${id}`, { shareToken: shareToken(id) });
|
||||
const r = await api<{ document: ServerDoc }>('GET', `/documents/${id}?content=0`, { shareToken: shareToken(id) });
|
||||
meta = metaOf(r.document);
|
||||
} catch (err) {
|
||||
toasts.error(
|
||||
err instanceof ApiError && err.status === 404
|
||||
@@ -462,20 +290,47 @@ function createLibrary() {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const { content } = r.document;
|
||||
const meta = metaOf(r.document);
|
||||
const ref: DocRef = { source: 'server', id };
|
||||
const s = new CollabSession(id, shareToken(id), identity, {
|
||||
onTitle: (title) => {
|
||||
if (!isCurrent(ref) || !state.server) return;
|
||||
state.server = { ...state.server, title };
|
||||
updateWindowTitle();
|
||||
void refreshServerList();
|
||||
},
|
||||
onRole: (role) => {
|
||||
if (!isCurrent(ref) || !state.server || state.server.role === role) return;
|
||||
state.server = { ...state.server, role };
|
||||
applyReadOnly();
|
||||
toasts.info(role === 'viewer' ? 'Du kannst dieses Dokument jetzt nur noch ansehen.' : 'Du kannst dieses Dokument jetzt bearbeiten.');
|
||||
},
|
||||
onRevoked: () => {
|
||||
if (!isCurrent(ref)) return;
|
||||
toasts.error('Du hast keinen Zugriff mehr auf dieses Dokument.');
|
||||
state.linkDocs = state.linkDocs.filter((l) => l.id !== id);
|
||||
writeLinks(state.linkDocs);
|
||||
void refreshServerList().then(() => {
|
||||
state.current = null;
|
||||
collab.value?.destroy();
|
||||
collab.value = null;
|
||||
store.setUndoProvider(null);
|
||||
return openFallback();
|
||||
});
|
||||
},
|
||||
});
|
||||
let next: DocumentState;
|
||||
try {
|
||||
next = validateDocument(JSON.parse(content ?? ''));
|
||||
next = await s.start();
|
||||
} catch (err) {
|
||||
toasts.error(`Dokument konnte nicht geladen werden: ${(err as Error).message}`);
|
||||
s.destroy();
|
||||
toasts.error(`Dokument konnte nicht geöffnet werden: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
await leaveCurrent();
|
||||
state.server = meta;
|
||||
state.lockedBy = null;
|
||||
holdsLock = false;
|
||||
show({ source: 'server', id }, next);
|
||||
state.server = { ...meta, role: s.role };
|
||||
collab.value = s;
|
||||
show(ref, next);
|
||||
store.setUndoProvider(s.attach());
|
||||
// remember the title for link-only documents
|
||||
const link = state.linkDocs.find((l) => l.id === id);
|
||||
if (link && (link.title !== meta.title || link.role !== meta.role)) {
|
||||
@@ -483,9 +338,7 @@ function createLibrary() {
|
||||
writeLinks(state.linkDocs);
|
||||
}
|
||||
applyReadOnly();
|
||||
if (meta.role !== 'viewer') await takeLock(id);
|
||||
startServerTimers();
|
||||
setUrl({ source: 'server', id }, opts.push ?? true);
|
||||
setUrl(ref, opts.push ?? true);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -555,11 +408,10 @@ function createLibrary() {
|
||||
|
||||
async function contentOf(ref: DocRef): Promise<{ title: string; json: string } | null> {
|
||||
if (isCurrent(ref)) {
|
||||
if (ref.source === 'server') await flushServer();
|
||||
else {
|
||||
store.flushSave();
|
||||
await writeQueue;
|
||||
}
|
||||
// the open document: straight from the store, always current
|
||||
store.flushSave();
|
||||
await writeQueue;
|
||||
return { title: currentTitle.value, json: JSON.stringify(store.doc) };
|
||||
}
|
||||
if (ref.source === 'server') {
|
||||
const r = await api<{ document: ServerDoc }>('GET', `/documents/${ref.id}`, { shareToken: shareToken(ref.id) });
|
||||
@@ -637,7 +489,7 @@ function createLibrary() {
|
||||
* this browser once it's safely on the server; an open one is reopened
|
||||
* from there. Returns the server id of each uploaded document.
|
||||
*/
|
||||
async function upload_(localIds: string[]): Promise<Map<string, string>> {
|
||||
async function upload(localIds: string[]): Promise<Map<string, string>> {
|
||||
const done = new Map<string, string>();
|
||||
if (!lib || !session.signedIn.value) return done;
|
||||
for (const id of localIds) {
|
||||
@@ -812,16 +664,17 @@ function createLibrary() {
|
||||
// don't lose the last moments of typing when the tab goes away
|
||||
window.addEventListener('pagehide', () => {
|
||||
store.flushSave();
|
||||
if (state.current?.source === 'server') {
|
||||
if (pendingJson !== null) void upload();
|
||||
releaseLock(state.current.id);
|
||||
}
|
||||
collab.value?.destroy();
|
||||
});
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (pendingJson !== null || uploading) e.preventDefault(); // unsaved server changes
|
||||
// back from the browser's page cache: the live session is gone
|
||||
window.addEventListener('pageshow', (e) => {
|
||||
if (e.persisted && state.current?.source === 'server') location.reload();
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') store.flushSave();
|
||||
if (document.visibilityState === 'hidden') {
|
||||
store.flushSave();
|
||||
collab.value?.flushLocal();
|
||||
}
|
||||
});
|
||||
state.ready = true;
|
||||
}
|
||||
@@ -831,6 +684,8 @@ function createLibrary() {
|
||||
currentTitle,
|
||||
currentRole,
|
||||
canEdit,
|
||||
peers,
|
||||
online,
|
||||
init,
|
||||
open,
|
||||
create,
|
||||
@@ -839,7 +694,7 @@ function createLibrary() {
|
||||
rename,
|
||||
duplicate,
|
||||
remove,
|
||||
upload: upload_,
|
||||
upload,
|
||||
refreshServerList,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user