/** * Glue between the document store (one open document) and where documents * live: * * - **local** documents: in this browser's IndexedDB (src/io/library.ts), * URL `/?doc=`. Without an account, that's all there is. * - **server** documents: on the numolus server (src/io/api.ts), URL * `/d/`. With an account, new documents are created there and local * ones can be uploaded; they can be shared. A share link * (`/d/#k=`) 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. * * 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 { useDocument, setPersistHandler, setReadOnlyNotifier, contentFingerprint } from './document'; import type { DocumentState } from './types'; import { DocumentLibrary, newDocId, type DocMeta } from '../io/library'; import { api, ApiError, type Role, type ServerDoc, type ServerDocMeta } from '../io/api'; import { validateDocument } from '../io/json'; import { createBlankDocument, createSeedDocument } from '../data/seed'; import { useToasts } from '../composables/useToasts'; import { useSession } from './session'; /** The single-document localStorage keys used before the library existed -- * read once and moved into the library as its first document. */ const LEGACY_KEYS = ['numolus:doc:v2', 'numbers-study:doc:v2']; /** share-link tokens this browser holds without an account */ 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 export type DocSource = 'local' | 'server'; export interface DocRef { source: DocSource; id: string; } export interface LinkDoc { id: string; token: string; title: string; role: Role; } type Msg = | { type: 'list' } | { type: 'saved'; id: string } | { type: 'deleted'; id: string } | { type: 'hello'; id: string } | { 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 }; } function readLinks(): LinkDoc[] { try { return JSON.parse(localStorage.getItem(LINKS_KEY) || '[]') as LinkDoc[]; } catch { return []; } } function writeLinks(links: LinkDoc[]) { try { localStorage.setItem(LINKS_KEY, JSON.stringify(links)); } catch { /* private mode -- the link itself still works */ } } function createLibrary() { const store = useDocument(); const toasts = useToasts(); const session = useSession(); const state = reactive({ ready: false, /** false = IndexedDB unavailable (private mode in some browsers, ...) */ available: true, /** local documents (IndexedDB) */ docs: [] as DocMeta[], /** server documents of the signed-in user: own + shared with them */ serverDocs: [] as ServerDocMeta[], /** documents opened via share link without an account */ linkDocs: readLinks(), current: null as DocRef | null, /** the open server document's metadata (title, role, version, ...) */ 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', }); const currentTitle = computed(() => { const c = state.current; if (!c) return DEFAULT_TITLE; if (c.source === 'server') return state.server?.title ?? DEFAULT_TITLE; return state.docs.find((d) => d.id === c.id)?.title ?? DEFAULT_TITLE; }); /** role in the open document; local documents are always the user's own */ const currentRole = computed(() => state.current?.source === 'server' ? (state.server?.role ?? 'viewer') : 'owner', ); const canEdit = computed(() => currentRole.value !== 'viewer' && !state.lockedBy); let lib: DocumentLibrary | null = null; let channel: BroadcastChannel | null = null; /** content fingerprint of what's saved for the open document */ let savedContent = ''; /** `updatedAt` of the open local document as this tab last wrote or read it */ let knownUpdatedAt = 0; /** local writes run one after another, in order */ let writeQueue: Promise = Promise.resolve(); const warnedShared = new Set(); // server document state let uploadTimer: ReturnType | undefined; let uploading: Promise | null = null; let pendingJson: string | null = null; let pendingFp = ''; let holdsLock = false; let heartbeatTimer: ReturnType | undefined; let pollTimer: ReturnType | undefined; const post = (m: Msg) => channel?.postMessage(m); const enqueue = (job: () => Promise) => { writeQueue = writeQueue.then(job).catch((err) => { console.error(err); toasts.error(`Speichern fehlgeschlagen: ${(err as Error).message}`); }); return writeQueue; }; const shareToken = (id: string) => state.linkDocs.find((l) => l.id === id)?.token ?? null; const isCurrent = (ref: DocRef) => state.current?.source === ref.source && state.current.id === ref.id; async function refreshList() { if (lib) state.docs = await lib.list(); } async function refreshServerList() { if (!session.signedIn.value) return; try { state.serverDocs = (await api<{ documents: ServerDocMeta[] }>('GET', '/documents')).documents; } catch (err) { console.error(err); } } function uniqueTitle(base: string, taken: string[]): string { const set = new Set(taken.map((t) => t.toLowerCase())); if (!set.has(base.toLowerCase())) return base; for (let n = 2; ; n++) { const t = `${base} ${n}`; if (!set.has(t.toLowerCase())) return t; } } function setUrl(ref: DocRef, push: boolean) { const url = new URL(location.href); url.hash = ''; if (ref.source === 'server') { url.pathname = `/d/${ref.id}`; url.searchParams.delete('doc'); } else { url.pathname = '/'; url.searchParams.set('doc', ref.id); } if (url.href === location.href) return; if (push) history.pushState(null, '', url); else history.replaceState(null, '', url); } function updateWindowTitle() { document.title = `${currentTitle.value} – ${APP_TITLE}`; } 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; } /** 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 next.activeSheetId = store.doc.activeSheetId; next.activeObjectId = store.doc.activeObjectId; next.selection = store.doc.selection; } state.current = ref; store.replaceDocument(next); // it came from where it's saved -- writing it straight back is pointless store.cancelSave(); savedContent = contentFingerprint(store.doc); updateWindowTitle(); } // ---- persistence ----------------------------------------------------- function persist(doc: DocumentState) { const c = state.current; if (!c) return; if (c.source === 'server') return persistServer(doc); if (!lib) return; const id = c.id; const json = JSON.stringify(doc); const fp = contentFingerprint(doc); const changed = fp !== savedContent; savedContent = fp; void enqueue(async () => { const meta = (await lib!.getMeta(id)) ?? { id, title: currentTitle.value, createdAt: Date.now(), updatedAt: Date.now(), }; if (!changed) { // only the cursor/active sheet moved: saved so reopening restores // it, but never over newer content another tab has written meanwhile if (meta.updatedAt !== knownUpdatedAt) return; await lib!.put(meta, json); return; } const updatedAt = Math.max(Date.now(), meta.updatedAt + 1); await lib!.put({ ...meta, updatedAt }, json); if (isCurrent({ source: 'local', id })) knownUpdatedAt = updatedAt; await refreshList(); post({ type: 'saved', id }); }); } 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 { 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 { 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; state.server = null; } else { store.flushSave(); await writeQueue; } } async function openLocal(id: string, opts: { push?: boolean } = {}): Promise { if (!lib) return false; if (isCurrent({ source: 'local', id })) return true; const [meta, json] = await Promise.all([lib.getMeta(id), lib.getContent(id)]); if (!meta || json === undefined) return false; let next: DocumentState; try { next = validateDocument(JSON.parse(json)); } catch (err) { toasts.error(`Dokument konnte nicht geladen werden: ${(err as Error).message}`); return false; } await leaveCurrent(); show({ source: 'local', id }, next); knownUpdatedAt = meta.updatedAt; applyReadOnly(); setUrl({ source: 'local', id }, opts.push ?? true); post({ type: 'hello', id }); return true; } async function openServer(id: string, opts: { push?: boolean } = {}): Promise { if (isCurrent({ source: 'server', id })) return true; let r: { document: ServerDoc }; try { r = await api<{ document: ServerDoc }>('GET', `/documents/${id}`, { shareToken: shareToken(id) }); } catch (err) { toasts.error( err instanceof ApiError && err.status === 404 ? 'Dieses Dokument gibt es nicht, oder du hast keinen Zugriff darauf.' : `Dokument konnte nicht geladen werden: ${(err as Error).message}`, ); return false; } const { content } = r.document; const meta = metaOf(r.document); let next: DocumentState; try { next = validateDocument(JSON.parse(content ?? '')); } catch (err) { toasts.error(`Dokument konnte nicht geladen werden: ${(err as Error).message}`); return false; } await leaveCurrent(); state.server = meta; state.lockedBy = null; holdsLock = false; show({ source: 'server', id }, next); // 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)) { Object.assign(link, { title: meta.title, role: meta.role }); writeLinks(state.linkDocs); } applyReadOnly(); if (meta.role !== 'viewer') await takeLock(id); startServerTimers(); setUrl({ source: 'server', id }, opts.push ?? true); return true; } function open(ref: DocRef, opts: { push?: boolean } = {}) { return ref.source === 'server' ? openServer(ref.id, opts) : openLocal(ref.id, opts); } // ---- creating -------------------------------------------------------- /** Add a document and open it: on the server when signed in, else locally. */ async function create(doc: DocumentState, title: string): Promise { const t = title.trim() || DEFAULT_TITLE; if (session.signedIn.value) { try { const r = await api<{ document: ServerDocMeta }>('POST', '/documents', { body: { title: uniqueTitle(t, state.serverDocs.map((d) => d.title)), content: JSON.stringify(doc) }, }); await refreshServerList(); post({ type: 'list' }); await openServer(r.document.id); return { source: 'server', id: r.document.id }; } catch (err) { toasts.error(`Anlegen auf dem Server fehlgeschlagen: ${(err as Error).message}`); return null; } } if (!lib) { store.replaceDocument(doc); // no storage at all: at least show it return null; } const id = newDocId(); const now = Date.now(); await enqueue(() => lib!.put({ id, title: uniqueTitle(t, state.docs.map((d) => d.title)), createdAt: now, updatedAt: now }, JSON.stringify(doc)), ); await refreshList(); post({ type: 'list' }); await openLocal(id); return { source: 'local', id }; } const createBlank = () => create(createBlankDocument(), DEFAULT_TITLE); const createSample = () => create(createSeedDocument(), 'Muster · Umsatzanalyse'); // ---- managing ---------------------------------------------------------- async function rename(ref: DocRef, title: string) { const t = title.trim(); if (!t) return; if (ref.source === 'server') { try { const r = await api<{ document: ServerDocMeta }>('PATCH', `/documents/${ref.id}`, { body: { title: t }, shareToken: shareToken(ref.id), }); if (isCurrent(ref) && state.server) state.server = { ...state.server, title: r.document.title }; await refreshServerList(); } catch (err) { toasts.error(`Umbenennen fehlgeschlagen: ${(err as Error).message}`); } } else { if (!lib) return; await enqueue(() => lib!.patchMeta(ref.id, { title: t })); await refreshList(); } if (isCurrent(ref)) updateWindowTitle(); post({ type: 'list' }); } 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; } } if (ref.source === 'server') { const r = await api<{ document: ServerDoc }>('GET', `/documents/${ref.id}`, { shareToken: shareToken(ref.id) }); return r.document.content === undefined ? null : { title: r.document.title, json: r.document.content }; } if (!lib) return null; const [meta, json] = await Promise.all([lib.getMeta(ref.id), lib.getContent(ref.id)]); return meta && json !== undefined ? { title: meta.title, json } : null; } /** A copy next to the original (server copies go to the own account). */ async function duplicate(ref: DocRef) { try { const src = await contentOf(ref); if (!src) return; const title = `${src.title} (Kopie)`; if (ref.source === 'server') { await api('POST', '/documents', { body: { title: uniqueTitle(title, state.serverDocs.map((d) => d.title)), content: src.json }, }); await refreshServerList(); } else if (lib) { const now = Date.now(); await enqueue(() => lib!.put( { id: newDocId(), title: uniqueTitle(title, state.docs.map((d) => d.title)), createdAt: now, updatedAt: now }, src.json, ), ); await refreshList(); } post({ type: 'list' }); } catch (err) { toasts.error(`Duplizieren fehlgeschlagen: ${(err as Error).message}`); } } /** Delete (own / local) or leave (shared with me / link). */ async function remove(ref: DocRef) { if (ref.source === 'server') { const wasCurrent = isCurrent(ref); if (wasCurrent) await leaveCurrent(); if (session.signedIn.value) { try { await api('DELETE', `/documents/${ref.id}`); } catch (err) { if (!(err instanceof ApiError && err.status === 404)) { toasts.error(`Löschen fehlgeschlagen: ${(err as Error).message}`); return; } } } state.linkDocs = state.linkDocs.filter((l) => l.id !== ref.id); writeLinks(state.linkDocs); await refreshServerList(); post({ type: 'list' }); if (wasCurrent) { state.current = null; await openFallback(); } return; } if (!lib) return; await enqueue(() => lib!.remove(ref.id)); await refreshList(); post({ type: 'deleted', id: ref.id }); if (isCurrent(ref)) { state.current = null; await openFallback(); } } /** * Move local documents into the account (server). Each one is removed from * 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> { const done = new Map(); if (!lib || !session.signedIn.value) return done; for (const id of localIds) { try { const src = await contentOf({ source: 'local', id }); if (!src) continue; const r = await api<{ document: ServerDocMeta }>('POST', '/documents', { body: { title: src.title, content: src.json }, }); done.set(id, r.document.id); const wasCurrent = isCurrent({ source: 'local', id }); if (wasCurrent) await openServer(r.document.id, { push: false }); await enqueue(() => lib!.remove(id)); post({ type: 'deleted', id }); } catch (err) { toasts.error(`Hochladen fehlgeschlagen: ${(err as Error).message}`); } } await Promise.all([refreshList(), refreshServerList()]); post({ type: 'list' }); return done; } /** Most recently changed document, or a new blank one. */ async function openFallback() { state.current = null; const server = state.serverDocs[0]; const local = state.docs[0]; const serverNewer = server && (!local || Date.parse(server.updated_at) > local.updatedAt); if (serverNewer && (await openServer(server.id, { push: false }))) return; if (local && (await openLocal(local.id, { push: false }))) return; await createBlank(); } // ---- share links ------------------------------------------------------- /** Redeem `#k=` for document `id` (from the URL). */ async function joinByLink(id: string, token: string) { try { const r = await api<{ document: ServerDocMeta }>('POST', `/documents/${id}/join`, { body: { token } }); if (session.signedIn.value) { await refreshServerList(); // it's in the account now } else { state.linkDocs = [ { id, token, title: r.document.title, role: r.document.role }, ...state.linkDocs.filter((l) => l.id !== id), ]; writeLinks(state.linkDocs); } } catch { toasts.error('Dieser Link ist nicht (mehr) gültig.'); } } /** After signing in: documents opened via link become part of the account. */ async function adoptLinkDocs() { if (!session.signedIn.value || !state.linkDocs.length) return; for (const l of state.linkDocs) { await api('POST', `/documents/${l.id}/join`, { body: { token: l.token } }).catch(() => {}); } state.linkDocs = []; writeLinks([]); } // ---- other tabs -------------------------------------------------------- async function onMessage(m: Msg) { if (m.type === 'list') { await Promise.all([refreshList(), refreshServerList()]); state.linkDocs = readLinks(); updateWindowTitle(); return; } const cur = state.current?.source === 'local' ? state.current.id : null; if (m.type === 'saved') { await refreshList(); // our own unsaved edit wins (it's written right after); otherwise // follow -- a pending save that's only a cursor move doesn't count const localEdit = store.hasPendingSave() && contentFingerprint(store.doc) !== savedContent; if (m.id === cur && !localEdit && lib) { const [meta, json] = await Promise.all([lib.getMeta(m.id), lib.getContent(m.id)]); if (meta && json !== undefined && isCurrent({ source: 'local', id: m.id })) { show({ source: 'local', id: m.id }, validateDocument(JSON.parse(json)), true); knownUpdatedAt = meta.updatedAt; } } } else if (m.type === 'deleted') { await refreshList(); if (m.id === cur) { toasts.info('Dieses Dokument ist nicht mehr in diesem Browser (gelöscht oder hochgeladen).'); state.current = null; await refreshServerList(); await openFallback(); } } else if (m.id === cur) { // hello / here: the same local document is open in another tab if (m.type === 'hello') post({ type: 'here', id: m.id }); if (!warnedShared.has(m.id)) { warnedShared.add(m.id); toasts.info( 'Dieses Dokument ist auch in einem anderen Tab geöffnet. Gleichzeitige Änderungen können sich überschreiben.', ); } } } // ---- start ------------------------------------------------------------- async function migrateLegacy() { for (const key of LEGACY_KEYS) { let raw: string | null = null; try { raw = localStorage.getItem(key); } catch { return; } if (!raw) continue; try { const doc = validateDocument(JSON.parse(raw)); const now = Date.now(); await lib!.put({ id: newDocId(), title: 'Mein Dokument', createdAt: now, updatedAt: now }, JSON.stringify(doc)); } catch { continue; // corrupt -- leave it where it is } for (const k of LEGACY_KEYS) localStorage.removeItem(k); return; } } /** What the URL asks for: `/d/[#k=]` or `/?doc=`. */ function refFromUrl(): { ref: DocRef | null; token: string | null } { const m = location.pathname.match(/^\/d\/([0-9a-f-]{36})\/?$/i); if (m) { const token = new URLSearchParams(location.hash.slice(1)).get('k'); return { ref: { source: 'server', id: m[1] }, token }; } const id = new URL(location.href).searchParams.get('doc'); return { ref: id ? { source: 'local', id } : null, token: null }; } async function init() { setReadOnlyNotifier((why) => toasts.info(why)); try { lib = await DocumentLibrary.open(); await migrateLegacy(); await refreshList(); } catch (err) { console.error(err); lib = null; state.available = false; if (!session.signedIn.value) { toasts.error('Dokumente können in diesem Browser nicht gespeichert werden (IndexedDB nicht verfügbar).'); } } await adoptLinkDocs(); await refreshServerList(); if (typeof BroadcastChannel !== 'undefined') { channel = new BroadcastChannel(CHANNEL); channel.onmessage = (e: MessageEvent) => void onMessage(e.data); } setPersistHandler(persist); const { ref, token } = refFromUrl(); if (ref?.source === 'server' && token) await joinByLink(ref.id, token); if (ref && !(await open(ref, { push: false })) && ref.source === 'local') { toasts.error('Das angeforderte Dokument gibt es in diesem Browser nicht.'); } if (!state.current) await openFallback(); window.addEventListener('popstate', () => { const { ref: r } = refFromUrl(); if (r && !isCurrent(r)) void open(r, { push: false }); }); // 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); } }); window.addEventListener('beforeunload', (e) => { if (pendingJson !== null || uploading) e.preventDefault(); // unsaved server changes }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') store.flushSave(); }); state.ready = true; } return { state, currentTitle, currentRole, canEdit, init, open, create, createBlank, createSample, rename, duplicate, remove, upload: upload_, refreshServerList, }; } let instance: ReturnType | null = null; export function useLibrary() { if (!instance) instance = createLibrary(); return instance; }