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
+82 -4
View File
@@ -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
View File
@@ -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,
};
}