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:
@@ -17,7 +17,7 @@
|
||||
* qualifier, so `Umsatz!B2` resolves the same from any sheet.
|
||||
*/
|
||||
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref, shallowRef, type Ref } from 'vue';
|
||||
import {
|
||||
Workbook,
|
||||
toA1,
|
||||
@@ -91,6 +91,29 @@ export function setPersistHandler(fn: ((doc: DocumentState) => void) | null) {
|
||||
persistHandler = fn;
|
||||
}
|
||||
|
||||
/** Live collaboration hooks (src/collab/binding.ts), unset otherwise:
|
||||
* `onChange` fires synchronously on every content or view change (the
|
||||
* binding debounces), `onStructure` right when a row/column is inserted or
|
||||
* deleted mid-table -- the binding needs to know that a whole row moved,
|
||||
* not just that many cells changed. */
|
||||
export interface CollabHooks {
|
||||
onChange(): void;
|
||||
onStructure(tableId: string, axis: 'row' | 'col', at: number, delta: 1 | -1): void;
|
||||
}
|
||||
let collabHooks: CollabHooks | null = null;
|
||||
export function setCollabHooks(h: CollabHooks | null) {
|
||||
collabHooks = h;
|
||||
}
|
||||
|
||||
/** Replaces the store's own snapshot undo while a document is edited
|
||||
* together (per-person undo, see src/collab/session.ts). */
|
||||
export interface UndoProvider {
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
canUndo: Ref<boolean>;
|
||||
canRedo: Ref<boolean>;
|
||||
}
|
||||
|
||||
/** The part of a document that is *content* -- everything but the per-view
|
||||
* state (selection, active sheet/object). Used by undo history to tell an
|
||||
* edit from a mere cursor move, and by the library for the same distinction
|
||||
@@ -669,7 +692,10 @@ function createStore() {
|
||||
function scheduleSave() {
|
||||
clearTimeout(persistTimer);
|
||||
persistTimer = setTimeout(flushSave, 250);
|
||||
if (!applyingExternal) collabHooks?.onChange();
|
||||
}
|
||||
/** true while a collaborator's change is being applied -- not a local edit */
|
||||
let applyingExternal = false;
|
||||
/** Write a pending save right now (page hidden, switching documents). */
|
||||
function flushSave() {
|
||||
if (persistTimer === undefined) return;
|
||||
@@ -766,6 +792,7 @@ function createStore() {
|
||||
*/
|
||||
function markDirty() {
|
||||
scheduleSave();
|
||||
if (undoProvider.value) return; // shared document: undo lives in Yjs
|
||||
if (!historyPending.value) {
|
||||
redoStack.length = 0;
|
||||
historyTick.value++;
|
||||
@@ -789,10 +816,22 @@ function createStore() {
|
||||
historyTick.value++;
|
||||
}
|
||||
|
||||
const canUndo = computed(() => (historyTick.value, undoStack.length > 0 || historyPending.value));
|
||||
const canRedo = computed(() => (historyTick.value, redoStack.length > 0));
|
||||
const undoProvider = shallowRef<UndoProvider | null>(null);
|
||||
function setUndoProvider(p: UndoProvider | null) {
|
||||
undoProvider.value = p;
|
||||
resetHistory();
|
||||
}
|
||||
const canUndo = computed(() =>
|
||||
undoProvider.value
|
||||
? undoProvider.value.canUndo.value
|
||||
: (historyTick.value, undoStack.length > 0 || historyPending.value),
|
||||
);
|
||||
const canRedo = computed(() =>
|
||||
undoProvider.value ? undoProvider.value.canRedo.value : (historyTick.value, redoStack.length > 0),
|
||||
);
|
||||
|
||||
function undo() {
|
||||
if (undoProvider.value) return undoProvider.value.undo();
|
||||
commitHistoryStep(); // flush a pending (not yet coalesced) change first
|
||||
const prev = undoStack.pop();
|
||||
if (!prev) return;
|
||||
@@ -806,6 +845,7 @@ function createStore() {
|
||||
historyTick.value++;
|
||||
}
|
||||
function redo() {
|
||||
if (undoProvider.value) return undoProvider.value.redo();
|
||||
clearTimeout(historyTimer);
|
||||
const next = redoStack.pop();
|
||||
if (!next) return;
|
||||
@@ -820,7 +860,8 @@ function createStore() {
|
||||
|
||||
// ---- id / name helpers ------------------------------------------
|
||||
let seq = 0;
|
||||
const uid = (p: string) => `${p}-${Date.now().toString(36)}-${seq++}`;
|
||||
// random part: ids must not collide between people editing the same document
|
||||
const uid = (p: string) => `${p}-${Date.now().toString(36)}-${seq++}${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
function uniqueName(base: string, taken: Set<string>): string {
|
||||
const b = base.trim() || 'Element';
|
||||
@@ -2233,6 +2274,7 @@ function createStore() {
|
||||
const t = tableById(tableId);
|
||||
if (!t) return;
|
||||
const clamped = Math.max(0, Math.min(Math.floor(at), t.rows));
|
||||
collabHooks?.onStructure(tableId, 'row', clamped, 1);
|
||||
t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, 1);
|
||||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, 1);
|
||||
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, 1);
|
||||
@@ -2247,6 +2289,7 @@ function createStore() {
|
||||
const t = tableById(tableId);
|
||||
if (!t || t.rows <= 1) return;
|
||||
const clamped = Math.max(0, Math.min(Math.floor(at), t.rows - 1));
|
||||
collabHooks?.onStructure(tableId, 'row', clamped, -1);
|
||||
t.cells = shiftCellKeyedMap(t.cells, 'row', clamped, -1);
|
||||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'row', clamped, -1);
|
||||
if (t.rowHeights) t.rowHeights = shiftAxisKeyedMap(t.rowHeights, clamped, -1);
|
||||
@@ -2263,6 +2306,7 @@ function createStore() {
|
||||
const t = tableById(tableId);
|
||||
if (!t) return;
|
||||
const clamped = Math.max(0, Math.min(Math.floor(at), t.cols));
|
||||
collabHooks?.onStructure(tableId, 'col', clamped, 1);
|
||||
t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, 1);
|
||||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, 1);
|
||||
t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, 1);
|
||||
@@ -2277,6 +2321,7 @@ function createStore() {
|
||||
const t = tableById(tableId);
|
||||
if (!t || t.cols <= 1) return;
|
||||
const clamped = Math.max(0, Math.min(Math.floor(at), t.cols - 1));
|
||||
collabHooks?.onStructure(tableId, 'col', clamped, -1);
|
||||
t.cells = shiftCellKeyedMap(t.cells, 'col', clamped, -1);
|
||||
if (t.formats) t.formats = shiftCellKeyedMap(t.formats, 'col', clamped, -1);
|
||||
t.colWidths = shiftAxisKeyedMap(t.colWidths, clamped, -1);
|
||||
@@ -2946,6 +2991,37 @@ function createStore() {
|
||||
// as a new edit. Persistence, unlike undo history, has no such carve-out.
|
||||
scheduleSave();
|
||||
}
|
||||
/**
|
||||
* A collaborator's change: take the new content, keep what is this
|
||||
* person's own view -- the open sheet, the selected object, the selection
|
||||
* and each sheet's zoom. History is untouched (undo is per person, in
|
||||
* Yjs) and it doesn't count as a local change.
|
||||
*/
|
||||
function applyExternalState(next: DocumentState) {
|
||||
const zoom = new Map(doc.sheets.map((s) => [s.id, s.zoom]));
|
||||
const keepSheet = next.sheets.some((s) => s.id === doc.activeSheetId);
|
||||
const view = {
|
||||
activeSheetId: keepSheet ? doc.activeSheetId : next.sheets[0]?.id,
|
||||
activeObjectId: doc.activeObjectId,
|
||||
selection: doc.selection,
|
||||
};
|
||||
for (const s of next.sheets) {
|
||||
if (zoom.has(s.id)) s.zoom = zoom.get(s.id);
|
||||
}
|
||||
applyingExternal = true;
|
||||
try {
|
||||
applyDocumentState({ ...next, ...view } as DocumentState);
|
||||
if (doc.activeObjectId && !doc.sheets.some((s) => s.objects.some((o) => o.id === doc.activeObjectId))) {
|
||||
doc.activeObjectId = null;
|
||||
}
|
||||
if (doc.selection && !tableById(doc.selection.tableId)) doc.selection = null;
|
||||
if (doc.selection) clampSelection(doc.selection.tableId);
|
||||
} finally {
|
||||
applyingExternal = false;
|
||||
}
|
||||
cancelSave();
|
||||
}
|
||||
|
||||
/** Loading a different document outright (JSON import "replace", "reset to
|
||||
* seed") -- unlike undo/redo, this starts a fresh undo history rather
|
||||
* than leaving the previous document's edits undoable into the new one. */
|
||||
@@ -3159,6 +3235,8 @@ function createStore() {
|
||||
flushSave,
|
||||
hasPendingSave,
|
||||
cancelSave,
|
||||
applyExternalState,
|
||||
setUndoProvider,
|
||||
engine: () => wb,
|
||||
};
|
||||
|
||||
|
||||
+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