Store multiple documents in IndexedDB with a document list

Documents now live in an IndexedDB library instead of a single
localStorage slot: each has its own ?doc=<id> URL, a title (editable in
the toolbar, used for export file names) and appears in Datei ›
Dokumente (open, rename, duplicate, delete). New/sample/import create
new documents instead of replacing the open one. Tabs stay in step via
BroadcastChannel. The old localStorage document is migrated once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-28 19:48:49 +02:00
co-authored by Claude Opus 5.5
parent 8e974cc870
commit 873cd50eac
10 changed files with 867 additions and 56 deletions
+11
View File
@@ -19,6 +19,7 @@
"@vue/eslint-config-typescript": "^14.1.3",
"eslint": "^9.15.0",
"eslint-plugin-vue": "^9.31.0",
"fake-indexeddb": "^6.2.5",
"jsdom": "^25.0.1",
"prettier": "^3.3.3",
"typescript": "~5.6.3",
@@ -2885,6 +2886,16 @@
"node": ">=12.0.0"
}
},
"node_modules/fake-indexeddb": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+1
View File
@@ -26,6 +26,7 @@
"@vue/eslint-config-typescript": "^14.1.3",
"eslint": "^9.15.0",
"eslint-plugin-vue": "^9.31.0",
"fake-indexeddb": "^6.2.5",
"jsdom": "^25.0.1",
"prettier": "^3.3.3",
"typescript": "~5.6.3",
+66 -18
View File
@@ -7,6 +7,9 @@ import BaseModal from './BaseModal.vue';
import CustomFunctionsModal from './CustomFunctionsModal.vue';
import NamedRangesModal from './NamedRangesModal.vue';
import MacrosModal from './MacrosModal.vue';
import DocumentsModal from './DocumentsModal.vue';
import { useLibrary } from '../store/library';
import { parseDocument } from '../io/json';
import TheLogo from './TheLogo.vue';
import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue';
import { WICOM, WICOM_SOCIAL } from '../config/brand';
@@ -28,6 +31,7 @@ import {
import type { Delimiter } from '../io/csv';
const store = useDocument();
const library = useLibrary();
const { mode: themeMode, cycle: cycleTheme } = useTheme();
const toasts = useToasts();
@@ -105,6 +109,17 @@ const aboutOpen = ref(false);
const functionsOpen = ref(false);
const namedRangesOpen = ref(false);
const macrosOpen = ref(false);
const documentsOpen = ref(false);
// ---- document title (inline rename) ------------------------------------
const titleDraft = ref(library.currentTitle.value);
watch(library.currentTitle, (t) => (titleDraft.value = t));
function commitTitle() {
const id = library.state.currentId;
const t = titleDraft.value.trim();
if (!id || !t) titleDraft.value = library.currentTitle.value;
else if (t !== library.currentTitle.value) void library.rename(id, t);
}
const busy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
@@ -157,7 +172,7 @@ function runCsvExport() {
}
async function runNumExport() {
try {
await exportNum(store);
await exportNum(store, library.currentTitle.value);
toasts.success('Dokument als .num gespeichert.');
} catch (err) {
toasts.error(`Speichern fehlgeschlagen: ${(err as Error).message}`);
@@ -165,13 +180,13 @@ async function runNumExport() {
}
function runJsonExport() {
exportJson(store);
exportJson(store, library.currentTitle.value);
toasts.success('Dokument als JSON exportiert.');
}
async function runXlsxExport() {
busy.value = true;
try {
await exportXlsx(store);
await exportXlsx(store, library.currentTitle.value);
toasts.success('Arbeitsmappe als XLSX exportiert.');
} catch (err) {
toasts.error(`XLSX-Export fehlgeschlagen: ${(err as Error).message}`);
@@ -247,13 +262,15 @@ async function handleFile(file: File) {
defineExpose({ handleFile });
async function doImport(action: 'replace' | 'append' | 'confirm') {
async function doImport(action: 'new' | 'replace' | 'append' | 'confirm') {
const ctx = importCtx.value;
if (!ctx) return;
busy.value = true;
try {
if (ctx.kind === 'json') {
if (action === 'replace') {
if (action === 'new') {
await library.create(parseDocument(ctx.text), ctx.fileName.replace(/\.(num|json)$/i, ''));
} else if (action === 'replace') {
if (!confirm('Aktuelles Dokument vollständig ersetzen?')) return;
importJsonReplace(store, ctx.text);
} else {
@@ -279,18 +296,14 @@ async function doImport(action: 'replace' | 'append' | 'confirm') {
}
}
function onNew() {
if (confirm('Neues, leeres Dokument anlegen? Alle Änderungen gehen verloren.')) {
store.newDocument();
toasts.info('Neues Dokument angelegt.');
}
async function onNew() {
await library.createBlank();
toasts.info('Neues Dokument angelegt.');
}
function onSample() {
if (confirm('Musterdokument laden? Alle Änderungen gehen verloren.')) {
store.resetToSeed();
toasts.info('Musterdokument geladen.');
}
async function onSample() {
await library.createSample();
toasts.info('Musterdokument als neues Dokument angelegt.');
}
</script>
@@ -303,6 +316,17 @@ function onSample() {
<button class="by" type="button" @click="aboutOpen = true">wicom</button>
</span>
</div>
<input
v-model="titleDraft"
class="doc-title"
type="text"
title="Dokumentname (klicken zum Umbenennen)"
aria-label="Dokumentname"
:size="Math.max(8, Math.min(28, titleDraft.length + 1))"
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
@keydown.esc.prevent="titleDraft = library.currentTitle.value; ($event.target as HTMLInputElement).blur()"
@blur="commitTitle()"
/>
<button
class="gear"
:class="{ on: store.ui.settingsOpen }"
@@ -381,7 +405,8 @@ function onSample() {
<div ref="fileMenuWrap" class="menu-wrap">
<button class="btn" @click="fileMenuOpen = !fileMenuOpen">Datei ▾</button>
<ul v-if="fileMenuOpen" class="menu" @click="fileMenuOpen = false">
<li><button @click="onNew">Neu</button></li>
<li><button @click="documentsOpen = true">Dokumente …</button></li>
<li><button @click="onNew">Neues Dokument</button></li>
<li><button @click="onSample">Muster · Umsatzanalyse</button></li>
<li class="sep" />
<li><button @click="pickFile">Öffnen / Importieren … (.num .json .csv .xlsx)</button></li>
@@ -433,7 +458,7 @@ function onSample() {
@close="importOpen = false"
>
<template v-if="importCtx?.kind === 'json'">
<p>JSON-Dokument erkannt. Wie einfügen?</p>
<p>Dokument erkannt. Als eigenes Dokument öffnen oder in das aktuelle übernehmen?</p>
</template>
<template v-else-if="importCtx?.kind === 'csv'">
@@ -471,7 +496,8 @@ function onSample() {
<button @click="importOpen = false">Abbrechen</button>
<template v-if="importCtx?.kind === 'json'">
<button @click="doImport('append')">Anhängen</button>
<button class="primary" @click="doImport('replace')">Ersetzen</button>
<button @click="doImport('replace')">Ersetzen</button>
<button class="primary" @click="doImport('new')">Als neues Dokument</button>
</template>
<button v-else class="primary" :disabled="busy" @click="doImport('confirm')">Importieren</button>
</template>
@@ -481,6 +507,7 @@ function onSample() {
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
<MacrosModal :open="macrosOpen" @close="macrosOpen = false" />
<DocumentsModal :open="documentsOpen" @close="documentsOpen = false" />
<!-- Über / Impressum -->
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
@@ -634,6 +661,27 @@ function onSample() {
.btn-link:hover {
color: var(--accent);
}
.doc-title {
min-width: 0;
max-width: 240px;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
padding: 4px 7px;
font: inherit;
font-size: 13.5px;
font-weight: 600;
color: var(--text);
text-overflow: ellipsis;
}
.doc-title:hover {
border-color: var(--border);
}
.doc-title:focus {
outline: none;
border-color: var(--accent);
background: var(--surface-2);
}
.gear {
display: inline-flex;
align-items: center;
+211
View File
@@ -0,0 +1,211 @@
<script setup lang="ts">
/**
* "Dokumente" -- every document stored in this browser (IndexedDB, see
* src/store/library.ts): open, rename, duplicate, delete, create. The open
* document is marked; opening another one switches in place (and pushes a
* history entry, so the browser's back button returns to the previous one).
*/
import { nextTick, ref, watch } from 'vue';
import { useLibrary } from '../store/library';
import type { DocMeta } from '../io/library';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const library = useLibrary();
const renamingId = ref<string | null>(null);
const renameValue = ref('');
const renameInput = ref<HTMLInputElement[] | null>(null);
watch(
() => props.open,
(o) => {
if (o) renamingId.value = null;
},
);
const fmt = new Intl.DateTimeFormat('de-CH', { dateStyle: 'medium', timeStyle: 'short' });
function when(ms: number): string {
const diff = Date.now() - ms;
if (diff < 60_000) return 'gerade eben';
if (diff < 3_600_000) return `vor ${Math.floor(diff / 60_000)} Min.`;
return fmt.format(ms);
}
async function openDoc(d: DocMeta) {
if (renamingId.value) return;
await library.open(d.id);
emit('close');
}
async function startRename(d: DocMeta) {
renamingId.value = d.id;
renameValue.value = d.title;
await nextTick();
renameInput.value?.[0]?.select();
}
async function commitRename() {
const id = renamingId.value;
renamingId.value = null;
if (id) await library.rename(id, renameValue.value);
}
async function remove(d: DocMeta) {
if (!confirm(`„${d.title}" endgültig löschen?`)) return;
await library.remove(d.id);
}
async function createNew() {
await library.createBlank();
emit('close');
}
</script>
<template>
<BaseModal :open="open" title="Dokumente" wide @close="emit('close')">
<p v-if="!library.state.available" class="hint">
In diesem Browser können keine Dokumente gespeichert werden (IndexedDB nicht verfügbar,
z. B. im privaten Modus).
</p>
<ul v-else class="doc-list">
<li
v-for="d in library.state.docs"
:key="d.id"
class="doc-row"
:class="{ current: d.id === library.state.currentId }"
>
<input
v-if="renamingId === d.id"
ref="renameInput"
v-model="renameValue"
class="rename"
type="text"
@keydown.enter.prevent="commitRename"
@keydown.esc.stop.prevent="renamingId = null"
@blur="commitRename"
/>
<button v-else class="doc-open" :title="`${d.title} öffnen`" @click="openDoc(d)">
<span class="doc-title">{{ d.title }}</span>
<span class="doc-when">{{ d.id === library.state.currentId ? 'geöffnet · ' : '' }}{{ when(d.updatedAt) }}</span>
</button>
<span class="doc-actions">
<button class="mini" @click="startRename(d)">Umbenennen</button>
<button class="mini" @click="library.duplicate(d.id)">Duplizieren</button>
<button class="mini danger" @click="remove(d)">Löschen</button>
</span>
</li>
</ul>
<p class="hint">
Dokumente werden nur in diesem Browser gespeichert. Zum Sichern oder Weitergeben:
Datei › Speichern (.num).
</p>
<template #footer>
<button @click="emit('close')">Schließen</button>
<button class="primary" :disabled="!library.state.available" @click="createNew">+ Neues Dokument</button>
</template>
</BaseModal>
</template>
<style scoped>
.hint {
color: var(--text-dim);
font-size: 12.5px;
line-height: 1.5;
margin: 12px 0 0;
}
.doc-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
max-height: min(60vh, 480px);
overflow-y: auto;
}
.doc-row {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px 4px 4px;
border-radius: 6px;
background: var(--surface-2);
border: 1px solid transparent;
}
.doc-row.current {
border-color: var(--accent);
}
.doc-open {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
border: 0;
background: transparent;
padding: 4px 6px;
border-radius: 5px;
font: inherit;
color: var(--text);
text-align: left;
cursor: pointer;
}
.doc-open:hover .doc-title {
color: var(--accent);
}
.doc-title {
font-size: 13px;
font-weight: 600;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-when {
font-size: 11.5px;
color: var(--text-dim);
}
.rename {
flex: 1;
min-width: 0;
border: 1px solid var(--accent);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
font-size: 13px;
background: var(--surface);
color: var(--text);
}
.doc-actions {
display: flex;
gap: 6px;
flex: none;
}
@media (max-width: 560px) {
.doc-row {
flex-wrap: wrap;
}
.doc-actions {
width: 100%;
justify-content: flex-end;
}
}
.mini {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 12px;
cursor: pointer;
color: var(--text);
}
.mini:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini.danger:hover {
border-color: #dc2626;
color: #dc2626;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { IDBFactory } from 'fake-indexeddb';
import { DocumentLibrary, newDocId, type DocMeta } from '../library';
const meta = (id: string, title: string, updatedAt: number): DocMeta => ({
id,
title,
createdAt: 1,
updatedAt,
});
/** a fresh, empty in-memory IndexedDB per test */
const openLib = () => DocumentLibrary.open(new IDBFactory());
describe('DocumentLibrary', () => {
it('stores meta and content separately and reads them back', async () => {
const lib = await openLib();
await lib.put(meta('a', 'Budget', 10), '{"x":1}');
expect(await lib.getMeta('a')).toEqual(meta('a', 'Budget', 10));
expect(await lib.getContent('a')).toBe('{"x":1}');
expect(await lib.getContent('missing')).toBeUndefined();
});
it('lists documents most recently changed first', async () => {
const lib = await openLib();
await lib.put(meta('old', 'Alt', 10), '{}');
await lib.put(meta('new', 'Neu', 30), '{}');
await lib.put(meta('mid', 'Mitte', 20), '{}');
expect((await lib.list()).map((d) => d.id)).toEqual(['new', 'mid', 'old']);
});
it('patchMeta renames without touching content; unknown ids are ignored', async () => {
const lib = await openLib();
await lib.put(meta('a', 'Alt', 10), '{"keep":true}');
await lib.patchMeta('a', { title: 'Neu' });
await lib.patchMeta('nope', { title: 'X' });
expect((await lib.getMeta('a'))?.title).toBe('Neu');
expect(await lib.getContent('a')).toBe('{"keep":true}');
expect(await lib.list()).toHaveLength(1);
});
it('remove deletes meta and content', async () => {
const lib = await openLib();
await lib.put(meta('a', 'A', 10), '{}');
await lib.remove('a');
expect(await lib.getMeta('a')).toBeUndefined();
expect(await lib.getContent('a')).toBeUndefined();
expect(await lib.list()).toEqual([]);
});
it('keeps data across re-opening the same database', async () => {
const factory = new IDBFactory();
await (await DocumentLibrary.open(factory)).put(meta('a', 'A', 10), '{"v":2}');
expect(await (await DocumentLibrary.open(factory)).getContent('a')).toBe('{"v":2}');
});
});
describe('newDocId', () => {
it('produces distinct v4 UUIDs', () => {
const a = newDocId();
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(newDocId()).not.toBe(a);
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* The browser's document library: every numolus document the user has in
* this browser, stored in IndexedDB. Two object stores, so listing documents
* never has to read their (possibly large) contents:
*
* meta { id, title, createdAt, updatedAt } -- the document list
* content { id, json } -- the document itself
*
* `json` is the serialised DocumentState (the same JSON a .num file holds).
* Plain promise wrapper over the IndexedDB API, no library; `factory` is
* injectable so tests can run against an in-memory implementation.
*/
export interface DocMeta {
id: string;
title: string;
/** epoch ms */
createdAt: number;
/** epoch ms of the last *content* change (not cursor moves) */
updatedAt: number;
}
interface ContentRecord {
id: string;
json: string;
}
const DB_NAME = 'numolus';
const DB_VERSION = 1;
function req<T>(r: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
}
function done(tx: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new Error('Transaktion abgebrochen.'));
});
}
export class DocumentLibrary {
private constructor(private db: IDBDatabase) {}
static async open(factory: IDBFactory = indexedDB): Promise<DocumentLibrary> {
const r = factory.open(DB_NAME, DB_VERSION);
r.onupgradeneeded = () => {
const db = r.result;
if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta', { keyPath: 'id' });
if (!db.objectStoreNames.contains('content')) db.createObjectStore('content', { keyPath: 'id' });
};
const db = await req(r);
// another tab upgrading the schema later must not be blocked by us
db.onversionchange = () => db.close();
return new DocumentLibrary(db);
}
/** All documents, most recently changed first. */
async list(): Promise<DocMeta[]> {
const tx = this.db.transaction('meta', 'readonly');
const all = await req(tx.objectStore('meta').getAll() as IDBRequest<DocMeta[]>);
return all.sort((a, b) => b.updatedAt - a.updatedAt);
}
async getMeta(id: string): Promise<DocMeta | undefined> {
const tx = this.db.transaction('meta', 'readonly');
return req(tx.objectStore('meta').get(id) as IDBRequest<DocMeta | undefined>);
}
async getContent(id: string): Promise<string | undefined> {
const tx = this.db.transaction('content', 'readonly');
const rec = await req(tx.objectStore('content').get(id) as IDBRequest<ContentRecord | undefined>);
return rec?.json;
}
/** Create or overwrite a document -- meta and content in one transaction. */
async put(meta: DocMeta, json: string): Promise<void> {
const tx = this.db.transaction(['meta', 'content'], 'readwrite');
tx.objectStore('meta').put(meta);
tx.objectStore('content').put({ id: meta.id, json } satisfies ContentRecord);
await done(tx);
}
/** Update only the listing data (rename). No-op for an unknown id. */
async patchMeta(id: string, patch: Partial<Omit<DocMeta, 'id'>>): Promise<void> {
const tx = this.db.transaction('meta', 'readwrite');
const store = tx.objectStore('meta');
const cur = await req(store.get(id) as IDBRequest<DocMeta | undefined>);
if (cur) store.put({ ...cur, ...patch, id });
await done(tx);
}
async remove(id: string): Promise<void> {
const tx = this.db.transaction(['meta', 'content'], 'readwrite');
tx.objectStore('meta').delete(id);
tx.objectStore('content').delete(id);
await done(tx);
}
}
export function newDocId(): string {
// randomUUID exists only in secure contexts (https, localhost)
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
+11 -6
View File
@@ -44,16 +44,21 @@ const breathe = () => new Promise((r) => setTimeout(r, 0));
// ---- export ---------------------------------------------------------
export function exportJson(store: DocumentStore): void {
downloadText('dokument.json', serializeDocument(store.snapshot()), 'application/json');
/** A document title as a file name (without extension). */
export function fileBaseName(title: string): string {
return title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '-').trim() || 'dokument';
}
export function exportJson(store: DocumentStore, name = 'dokument'): void {
downloadText(`${fileBaseName(name)}.json`, serializeDocument(store.snapshot()), 'application/json');
}
/** Save as the native `.num` file (the JSON document, zipped -- see num.ts).
* The zip code loads on first use, like the xlsx support. */
export async function exportNum(store: DocumentStore): Promise<void> {
export async function exportNum(store: DocumentStore, name = 'dokument'): Promise<void> {
const { packNum, NUM_MIMETYPE } = await import('./num');
const bytes = packNum(serializeDocument(store.snapshot()));
downloadBlob('dokument.num', new Blob([bytes], { type: NUM_MIMETYPE }));
downloadBlob(`${fileBaseName(name)}.num`, new Blob([bytes], { type: NUM_MIMETYPE }));
}
/** The document JSON inside a `.num` file -- then imported like a `.json`. */
@@ -68,8 +73,8 @@ export function exportActiveTableCsv(store: DocumentStore, opts: CsvExportOption
downloadText(`${table.name}.csv`, tableToCsv(store, table.id, opts), 'text/csv;charset=utf-8');
}
export async function exportXlsx(store: DocumentStore): Promise<void> {
downloadBlob('arbeitsmappe.xlsx', await documentToXlsx(store));
export async function exportXlsx(store: DocumentStore, name = 'arbeitsmappe'): Promise<void> {
downloadBlob(`${fileBaseName(name)}.xlsx`, await documentToXlsx(store));
}
// ---- import: JSON ------------------------------------------------
+6 -1
View File
@@ -1,5 +1,10 @@
import { createApp } from 'vue';
import App from './App.vue';
import { useLibrary } from './store/library';
import './styles/main.css';
createApp(App).mount('#app');
// open the document (from IndexedDB) before the first render, so the app
// never flashes an empty document first
void useLibrary()
.init()
.finally(() => createApp(App).mount('#app'));
+43 -31
View File
@@ -81,11 +81,29 @@ import type {
} from './types';
import { defaultTableView, DEFAULT_ROW_H, MIN_GRID_SIZE, MAX_GRID_SIZE } from './types';
import { createBlankDocument, createSeedDocument } from '../data/seed';
import { validateDocument } from '../io/json';
const STORAGE_KEY = 'numolus:doc:v2';
/** Key used before the rename to numolus -- still read as a fallback. */
const LEGACY_STORAGE_KEY = 'numbers-study:doc:v2';
/** Where the document is persisted. Set by the document library
* (src/store/library.ts) to write into IndexedDB; unset (tests, or before
* the library has started) nothing is persisted. Called debounced with the
* live reactive document -- the handler serialises it itself. */
let persistHandler: ((doc: DocumentState) => void) | null = null;
export function setPersistHandler(fn: ((doc: DocumentState) => void) | null) {
persistHandler = fn;
}
/** 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
* when deciding whether a save changed anything other tabs care about. */
export const contentFingerprint = (d: DocumentState) =>
JSON.stringify({
sheets: d.sheets,
locale: d.locale,
csvDelimiter: d.csvDelimiter,
customFunctions: d.customFunctions,
namedRanges: d.namedRanges,
macros: d.macros,
});
const CASCADE = 28; // px offset for successive inserted objects
@@ -590,18 +608,9 @@ function rewriteRefsForRename(
return '=' + out;
}
/** A blank document until the library (src/store/library.ts) loads the real
* one from IndexedDB via replaceDocument() -- see main.ts. */
function loadInitial(): DocumentState {
try {
const raw =
localStorage.getItem(STORAGE_KEY) ?? localStorage.getItem(LEGACY_STORAGE_KEY);
if (raw) {
// validateDocument migrates v1, validates + *normalises* v2 (fills in
// `view` and other defaults for documents saved by an older build).
return validateDocument(JSON.parse(raw));
}
} catch {
/* missing / corrupt / stale-schema -> clean fallback */
}
return createBlankDocument();
}
@@ -654,13 +663,22 @@ function createStore() {
let persistTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleSave() {
clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(doc));
} catch {
/* quota / privacy mode -- ignore */
}
}, 250);
persistTimer = setTimeout(flushSave, 250);
}
/** Write a pending save right now (page hidden, switching documents). */
function flushSave() {
if (persistTimer === undefined) return;
clearTimeout(persistTimer);
persistTimer = undefined;
persistHandler?.(doc);
}
/** A change made here that hasn't been handed to persistence yet. */
const hasPendingSave = () => persistTimer !== undefined;
/** Drop a pending save -- the document was just loaded from where it
* would be saved to. */
function cancelSave() {
clearTimeout(persistTimer);
persistTimer = undefined;
}
// ---- undo / redo (debounced content snapshots) -----------------------
@@ -686,15 +704,6 @@ function createStore() {
const MAX_HISTORY = 100;
const undoStack: DocumentState[] = [];
const redoStack: DocumentState[] = [];
const contentFingerprint = (d: DocumentState) =>
JSON.stringify({
sheets: d.sheets,
locale: d.locale,
csvDelimiter: d.csvDelimiter,
customFunctions: d.customFunctions,
namedRanges: d.namedRanges,
macros: d.macros,
});
let historySnapshot = snapshot();
let historyFingerprint = contentFingerprint(historySnapshot);
let historyTimer: ReturnType<typeof setTimeout> | undefined;
@@ -3142,6 +3151,9 @@ function createStore() {
addTableFromCells,
replaceTableCells,
snapshot,
flushSave,
hasPendingSave,
cancelSave,
engine: () => wb,
};
}
+341
View File
@@ -0,0 +1,341 @@
/**
* Glue between the document store (one open document) and the document
* library in IndexedDB (all documents in this browser):
*
* - which document is open: `?doc=<id>` in the URL, so every browser tab can
* have its own document and a reload reopens the same one;
* - persistence: the store's debounced save lands here and is written into
* the library under the open document's id;
* - other tabs: a BroadcastChannel tells them when a document's content, the
* list or a title changed, so an open document list stays current and a
* document open in two tabs follows the other tab's saves. Two tabs editing
* the *same* document at the same moment still overwrite each other (last
* save wins), so a hint is shown when that situation arises.
*/
import { computed, reactive } from 'vue';
import { useDocument, setPersistHandler, contentFingerprint } from './document';
import type { DocumentState } from './types';
import { DocumentLibrary, newDocId, type DocMeta } from '../io/library';
import { validateDocument } from '../io/json';
import { createBlankDocument, createSeedDocument } from '../data/seed';
import { useToasts } from '../composables/useToasts';
/** 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'];
const CHANNEL = 'numolus:library';
const DEFAULT_TITLE = 'Unbenannt';
const APP_TITLE = 'numolus';
type Msg =
| { type: 'list' }
| { type: 'saved'; id: string }
| { type: 'deleted'; id: string }
| { type: 'hello'; id: string }
| { type: 'here'; id: string };
function createLibrary() {
const store = useDocument();
const toasts = useToasts();
const state = reactive({
ready: false,
/** false = IndexedDB unavailable (private mode in some browsers, ...) */
available: true,
docs: [] as DocMeta[],
currentId: null as string | null,
});
const current = computed(() => state.docs.find((d) => d.id === state.currentId) ?? null);
const currentTitle = computed(() => current.value?.title ?? DEFAULT_TITLE);
let lib: DocumentLibrary | null = null;
let channel: BroadcastChannel | null = null;
/** content fingerprint of what the library holds for the open document */
let savedContent = '';
/** `updatedAt` of the open document as this tab last wrote or read it --
* a differing value in the library means another tab saved since */
let knownUpdatedAt = 0;
/** writes run one after another, in order */
let writeQueue: Promise<void> = Promise.resolve();
/** documents we've already warned about being open in another tab */
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;
};
async function refreshList() {
if (lib) state.docs = await lib.list();
}
function uniqueTitle(base: string): string {
const taken = new Set(state.docs.map((d) => d.title.toLowerCase()));
if (!taken.has(base.toLowerCase())) return base;
for (let n = 2; ; n++) {
const t = `${base} ${n}`;
if (!taken.has(t.toLowerCase())) return t;
}
}
function setUrl(id: string, push: boolean) {
const url = new URL(location.href);
if (url.searchParams.get('doc') === id) return;
url.searchParams.set('doc', id);
if (push) history.pushState(null, '', url);
else history.replaceState(null, '', url);
}
function updateWindowTitle() {
document.title = `${currentTitle.value} – ${APP_TITLE}`;
}
// ---- persistence ---------------------------------------------------
function persist(doc: DocumentState) {
const id = state.currentId;
if (!lib || !id) return;
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 (id === state.currentId) knownUpdatedAt = updatedAt;
await refreshList();
post({ type: 'saved', id });
});
}
/** Show `json` in the store as the open document `id`. */
function show(id: string, json: string, updatedAt: number, keepView = false) {
const next = validateDocument(JSON.parse(json));
if (keepView) {
// another tab's save: take its content, keep this tab's cursor/sheet
if (next.sheets.some((s) => s.id === store.doc.activeSheetId)) {
next.activeSheetId = store.doc.activeSheetId;
next.activeObjectId = store.doc.activeObjectId;
next.selection = store.doc.selection;
}
}
state.currentId = id;
store.replaceDocument(next);
// it came from the library -- writing it straight back would only risk
// overwriting a newer save from another tab
store.cancelSave();
savedContent = contentFingerprint(store.doc);
knownUpdatedAt = updatedAt;
updateWindowTitle();
}
// ---- opening documents ---------------------------------------------
async function open(id: string, opts: { push?: boolean } = {}): Promise<boolean> {
if (!lib) return false;
if (id === state.currentId) return true;
store.flushSave();
await writeQueue;
const [meta, json] = await Promise.all([lib.getMeta(id), lib.getContent(id)]);
if (!meta || json === undefined) return false;
try {
show(id, json, meta.updatedAt);
} catch (err) {
toasts.error(`Dokument konnte nicht geladen werden: ${(err as Error).message}`);
return false;
}
setUrl(id, opts.push ?? true);
post({ type: 'hello', id });
return true;
}
/** Add a document to the library and open it. */
async function create(doc: DocumentState, title: string): Promise<string | null> {
if (!lib) {
// no library: at least show it
store.replaceDocument(doc);
return null;
}
const id = newDocId();
const now = Date.now();
await enqueue(() =>
lib!.put({ id, title: uniqueTitle(title.trim() || DEFAULT_TITLE), createdAt: now, updatedAt: now }, JSON.stringify(doc)),
);
await refreshList();
post({ type: 'list' });
await open(id);
return id;
}
const createBlank = () => create(createBlankDocument(), DEFAULT_TITLE);
const createSample = () => create(createSeedDocument(), 'Muster · Umsatzanalyse');
async function rename(id: string, title: string) {
const t = title.trim();
if (!lib || !t) return;
await enqueue(() => lib!.patchMeta(id, { title: t }));
await refreshList();
if (id === state.currentId) updateWindowTitle();
post({ type: 'list' });
}
async function duplicate(id: string) {
if (!lib) return;
if (id === state.currentId) store.flushSave();
await writeQueue;
const [meta, json] = await Promise.all([lib.getMeta(id), lib.getContent(id)]);
if (!meta || json === undefined) return;
const now = Date.now();
await enqueue(() =>
lib!.put({ id: newDocId(), title: uniqueTitle(`${meta.title} (Kopie)`), createdAt: now, updatedAt: now }, json),
);
await refreshList();
post({ type: 'list' });
}
async function remove(id: string) {
if (!lib) return;
await enqueue(() => lib!.remove(id));
await refreshList();
post({ type: 'deleted', id });
if (id === state.currentId) await openFallback();
}
/** Most recently changed document, or a new blank one. */
async function openFallback() {
state.currentId = null;
const next = state.docs[0];
if (!next || !(await open(next.id, { push: false }))) await createBlank();
}
// ---- other tabs ------------------------------------------------------
async function onMessage(m: Msg) {
if (m.type === 'list') {
await refreshList();
updateWindowTitle();
} else 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 === state.currentId && !localEdit && lib) {
const [meta, json] = await Promise.all([lib.getMeta(m.id), lib.getContent(m.id)]);
if (meta && json !== undefined && m.id === state.currentId) show(m.id, json, meta.updatedAt, true);
}
} else if (m.type === 'deleted') {
await refreshList();
if (m.id === state.currentId) {
toasts.info('Dieses Dokument wurde in einem anderen Tab gelöscht.');
await openFallback();
}
} else if (m.id === state.currentId) {
// hello / here: the same 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;
}
}
async function init() {
try {
lib = await DocumentLibrary.open();
} catch (err) {
console.error(err);
state.available = false;
state.ready = true;
toasts.error('Dokumente können in diesem Browser nicht gespeichert werden (IndexedDB nicht verfügbar).');
return;
}
await migrateLegacy();
await refreshList();
if (typeof BroadcastChannel !== 'undefined') {
channel = new BroadcastChannel(CHANNEL);
channel.onmessage = (e: MessageEvent<Msg>) => void onMessage(e.data);
}
setPersistHandler(persist);
const wanted = new URL(location.href).searchParams.get('doc');
if (wanted && !(await open(wanted, { push: false }))) {
toasts.error('Das angeforderte Dokument gibt es in diesem Browser nicht.');
}
if (!state.currentId) await openFallback();
window.addEventListener('popstate', () => {
const id = new URL(location.href).searchParams.get('doc');
if (id && id !== state.currentId) void open(id, { push: false });
});
// don't lose the last 250 ms of typing when the tab goes away
window.addEventListener('pagehide', () => store.flushSave());
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') store.flushSave();
});
state.ready = true;
}
return {
state,
current,
currentTitle,
init,
open,
create,
createBlank,
createSample,
rename,
duplicate,
remove,
};
}
let instance: ReturnType<typeof createLibrary> | null = null;
export function useLibrary() {
if (!instance) instance = createLibrary();
return instance;
}