710 lines
26 KiB
TypeScript
710 lines
26 KiB
TypeScript
/**
|
||
* 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=<id>`. Without an account, that's all there is.
|
||
* - **server** documents: on the numolus server (src/io/api.ts), URL
|
||
* `/d/<id>`. With an account, new documents are created there and local
|
||
* ones can be uploaded; they can be shared. A share link
|
||
* (`/d/<id>#k=<token>`) also opens a server document without an account --
|
||
* the token is kept in this browser (localStorage) and sent along.
|
||
*
|
||
* 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, shallowRef } 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';
|
||
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. */
|
||
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 ANON_COLORS = ['#e67e22', '#2980b9', '#27ae60', '#8e44ad', '#c0392b', '#16a085'];
|
||
/** Anonymous people are shown as two random words, e.g. "Kiesel Komet". */
|
||
const ANON_WORDS_A = ['Kiesel', 'Wolke', 'Anker', 'Nebel', 'Kompass', 'Laterne', 'Moos', 'Feder', 'Bernstein', 'Brise', 'Quelle', 'Funke'];
|
||
const ANON_WORDS_B = ['Komet', 'Fuchs', 'Welle', 'Ahorn', 'Leuchtturm', 'Otter', 'Gipfel', 'Mond', 'Dachs', 'Birke', 'Falke', 'Stern'];
|
||
|
||
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, role, owner_name, updated_at, updated_by_name } = d;
|
||
return { id, title, 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 */
|
||
}
|
||
}
|
||
|
||
/** 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: `${pick(ANON_WORDS_A)} ${pick(ANON_WORDS_B)}`, color: pick(ANON_COLORS) };
|
||
}
|
||
|
||
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, ...) */
|
||
server: null as ServerDocMeta | null,
|
||
});
|
||
/** 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;
|
||
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<Role>(() =>
|
||
state.current?.source === 'server' ? (state.server?.role ?? 'viewer') : 'owner',
|
||
);
|
||
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 local 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<void> = Promise.resolve();
|
||
const warnedShared = new Set<string>();
|
||
|
||
const post = (m: Msg) => channel?.postMessage(m);
|
||
const enqueue = (job: () => Promise<void>) => {
|
||
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' && 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)) {
|
||
// 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;
|
||
}
|
||
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 (local documents) ---------------------------------------
|
||
function persist(doc: DocumentState) {
|
||
const c = state.current;
|
||
if (!c || c.source !== 'local' || !lib) return; // server documents: CollabSession
|
||
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 });
|
||
});
|
||
}
|
||
|
||
// ---- leaving / opening -----------------------------------------------
|
||
async function leaveCurrent() {
|
||
const c = state.current;
|
||
if (!c) return;
|
||
if (c.source === 'server') {
|
||
collab.value?.destroy();
|
||
collab.value = null;
|
||
store.setUndoProvider(null);
|
||
state.server = null;
|
||
} else {
|
||
store.flushSave();
|
||
await writeQueue;
|
||
}
|
||
}
|
||
|
||
async function openLocal(id: string, opts: { push?: boolean } = {}): Promise<boolean> {
|
||
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<boolean> {
|
||
if (isCurrent({ source: 'server', id })) return true;
|
||
let meta: ServerDocMeta;
|
||
try {
|
||
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
|
||
? 'Dieses Dokument gibt es nicht, oder du hast keinen Zugriff darauf.'
|
||
: `Dokument konnte nicht geladen werden: ${(err as Error).message}`,
|
||
);
|
||
return false;
|
||
}
|
||
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 = await s.start();
|
||
} catch (err) {
|
||
s.destroy();
|
||
toasts.error(`Dokument konnte nicht geöffnet werden: ${(err as Error).message}`);
|
||
return false;
|
||
}
|
||
await leaveCurrent();
|
||
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)) {
|
||
Object.assign(link, { title: meta.title, role: meta.role });
|
||
writeLinks(state.linkDocs);
|
||
}
|
||
applyReadOnly();
|
||
setUrl(ref, 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<DocRef | null> {
|
||
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)) {
|
||
// 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) });
|
||
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<Map<string, string>> {
|
||
const done = new Map<string, string>();
|
||
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=<token>` 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/<id>[#k=<token>]` or `/?doc=<id>`. */
|
||
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<Msg>) => 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();
|
||
collab.value?.destroy();
|
||
});
|
||
// 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();
|
||
collab.value?.flushLocal();
|
||
}
|
||
});
|
||
state.ready = true;
|
||
}
|
||
|
||
return {
|
||
state,
|
||
currentTitle,
|
||
currentRole,
|
||
canEdit,
|
||
peers,
|
||
online,
|
||
init,
|
||
open,
|
||
create,
|
||
createBlank,
|
||
createSample,
|
||
rename,
|
||
duplicate,
|
||
remove,
|
||
upload,
|
||
refreshServerList,
|
||
};
|
||
}
|
||
|
||
let instance: ReturnType<typeof createLibrary> | null = null;
|
||
|
||
export function useLibrary() {
|
||
if (!instance) instance = createLibrary();
|
||
return instance;
|
||
}
|