feat: pictures are autosaved to IndexedDB, not localStorage

A handful of photos is the whole ~5 MB quota, and past it the document
stopped saving. The autosaved JSON now keeps an `idb:<key>` per picture
and the bytes live in their own store; the key is a sampled content
hash, so a picture used twice is stored once and each save sweeps what
the document no longer names.

loadDocument is async as a result and Editor.svelte awaits it. Where
IndexedDB is unavailable the data-URIs stay inline exactly as before.
This commit is contained in:
Steffen Becker committed 2026-08-14 08:17:06 +02:00
1 parent d344494fcd
commit 6a313e5692
11 files changed
+217 -38

No files matched your search

+4 -2
View File
@@ -857,10 +857,12 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
waitForSettled();
});
onMount(() => {
onMount(async () => {
// Start with empty history lists — loaded content is not an undoable edit.
resetHistoryLog();
const saved = loadDocument();
// Awaited: the document's pictures live in IndexedDB, and the editor is built
// from the whole document or the first pagination pass measures the wrong one.
const saved = await loadDocument();
editor = new Editor({
element,
+2
View File
@@ -777,6 +777,8 @@ const de: Messages = {
'Das Dokument ist zu groß, um automatisch gespeichert zu werden (Speicherlimit des Browsers erreicht). Das passiert meist bei großen eingebetteten Bildern. Speichere es als .odt-Datei, um deine Arbeit zu sichern.',
documentNotLoaded:
'Das zuletzt bearbeitete Dokument hat den Start des Editors blockiert und wurde deshalb nicht geladen. Du arbeitest in einem neuen, leeren Dokument; das alte bleibt im Browser-Speicher unter „edentext-doc-broken" erhalten.',
picturesNotRestored: (n) =>
`${n} ${n === 1 ? 'Bild konnte' : 'Bilder konnten'} nicht aus dem Browser-Speicher wiederhergestellt werden und ${n === 1 ? 'wird' : 'werden'} als defekt angezeigt. Öffne die gespeicherte Datei des Dokuments, um ${n === 1 ? 'es' : 'sie'} zurückzuholen.`,
},
importWarn: {
imagesSkipped: 'Einige Bilder konnten nicht gelesen werden und wurden übersprungen',
+2
View File
@@ -781,6 +781,8 @@ const en = {
'The document is too large to save automatically (browser storage limit reached). This usually happens with large embedded images. Save it as an .odt file to keep your work.',
documentNotLoaded:
'The last document stopped the editor from starting, so it was not loaded. You are working in a new, empty document; the old one is kept in the browser storage under "edentext-doc-broken".',
picturesNotRestored: (n: number) =>
`${n} picture${n === 1 ? '' : 's'} could not be restored from the browser storage and ${n === 1 ? 'is' : 'are'} shown as broken. Open the document's saved file to get ${n === 1 ? 'it' : 'them'} back.`,
},
// Values mirror the canonical English messages the import parsers emit; the UI
// maps a parser message to its localized form at display time (importMessages.ts).
+1 -1
View File
@@ -24,7 +24,7 @@ Both margins and orientation are passed into the ODT export so the exported docu
All keys share the `edentext-` prefix — a namespace, not a description: on GitHub Pages every repo of an account shares one origin, so a generic prefix would collide.
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`). Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor: it moves to `edentext-doc-broken` and the app starts empty, so a reload escapes the freeze instead of repeating it.
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`). Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor: it moves to `edentext-doc-broken` and the app starts empty, so a reload escapes the freeze instead of repeating it. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:<key>` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, and each save sweeps the keys the document no longer names. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
- **Theme:** `edentext-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`.
- **Toolbar expanded:** `edentext-toolbar-expanded` — boolean string.
- **Chrome:** `edentext-chrome` — `'classic' | 'ribbon'`; absent = classic.
+10 -3
View File
@@ -1,4 +1,5 @@
import { t } from '../i18n/i18n.svelte';
import { stashImages, putImages, restoreImages } from './imageStore';
const STORAGE_KEY = 'edentext-doc';
// Set while a stored document is being handed to the editor, cleared once the editor
@@ -18,9 +19,13 @@ let quotaWarned = false;
export function saveDocument(json: object): void {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
timeout = setTimeout(async () => {
// Pictures go to IndexedDB and the JSON keeps a key; where that fails they stay
// inline, which is the only thing localStorage ever held.
const { json: slim, blobs } = stashImages(json);
const stashed = await putImages(blobs);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(json));
localStorage.setItem(STORAGE_KEY, JSON.stringify(stashed ? slim : json));
} catch (err) {
console.error('[autosave] Could not save the document:', err);
if (!quotaWarned) {
@@ -31,7 +36,7 @@ export function saveDocument(json: object): void {
}, DEBOUNCE_MS);
}
export function loadDocument(): object | null {
export async function loadDocument(): Promise<object | null> {
const raw = localStorage.getItem(STORAGE_KEY);
if (localStorage.getItem(BOOT_KEY)) {
localStorage.removeItem(BOOT_KEY);
@@ -51,6 +56,8 @@ export function loadDocument(): object | null {
return null;
}
localStorage.setItem(BOOT_KEY, '1');
const missing = await restoreImages(doc);
if (missing) requestAnimationFrame(() => alert(t().dialogs.picturesNotRestored(missing)));
return doc;
}
+7 -19
View File
@@ -1,4 +1,5 @@
import type { EmbeddedFont } from '../fonts/embeddedFonts';
import { openDb, idbRequest } from './idb';
// The current document's embedded fonts, persisted in IndexedDB (not localStorage, whose
// ~5 MB quota is already tight for images). One record, replaced on each open, so storage
@@ -7,27 +8,14 @@ const DB_NAME = 'edentext-fonts';
const STORE = 'fonts';
const KEY = 'current';
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function request<T>(db: IDBDatabase, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest): Promise<T> {
return new Promise((resolve, reject) => {
const req = run(db.transaction(STORE, mode).objectStore(STORE));
req.onsuccess = () => resolve(req.result as T);
req.onerror = () => reject(req.error);
});
}
const db_ = () => openDb(DB_NAME, STORE);
const request = <T>(db: IDBDatabase, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest) =>
idbRequest<T>(db, STORE, mode, run);
export async function saveEmbeddedFonts(fonts: EmbeddedFont[]): Promise<void> {
if (typeof indexedDB === 'undefined') return;
try {
const db = await openDb();
const db = await db_();
if (fonts.length) await request(db, 'readwrite', (s) => s.put(fonts, KEY));
else await request(db, 'readwrite', (s) => s.delete(KEY));
db.close();
@@ -37,7 +25,7 @@ export async function saveEmbeddedFonts(fonts: EmbeddedFont[]): Promise<void> {
export async function loadEmbeddedFonts(): Promise<EmbeddedFont[]> {
if (typeof indexedDB === 'undefined') return [];
try {
const db = await openDb();
const db = await db_();
const fonts = await request<EmbeddedFont[] | undefined>(db, 'readonly', (s) => s.get(KEY));
db.close();
return fonts ?? [];
@@ -47,7 +35,7 @@ export async function loadEmbeddedFonts(): Promise<EmbeddedFont[]> {
export async function clearEmbeddedFontStore(): Promise<void> {
if (typeof indexedDB === 'undefined') return;
try {
const db = await openDb();
const db = await db_();
await request(db, 'readwrite', (s) => s.delete(KEY));
db.close();
} catch { /* nothing to clear */ }
+22
View File
@@ -0,0 +1,22 @@
// The two-call IndexedDB wrapper both stores here need: one object store per database,
// opened per operation and closed again. No versioning beyond the first schema — a
// store that ever needs a second one gets its own database name instead.
export function openDb(name: string, store: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(name, 1);
req.onupgradeneeded = () => req.result.createObjectStore(store);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
export function idbRequest<T>(
db: IDBDatabase, store: string, mode: IDBTransactionMode, run: (s: IDBObjectStore) => IDBRequest,
): Promise<T> {
return new Promise((resolve, reject) => {
const req = run(db.transaction(store, mode).objectStore(store));
req.onsuccess = () => resolve(req.result as T);
req.onerror = () => reject(req.error);
});
}
+108
View File
@@ -0,0 +1,108 @@
import { openDb, idbRequest } from './idb';
// The document's pictures, out of the autosaved JSON and into IndexedDB. localStorage's
// ~5 MB quota is a handful of photos, and a document that exceeds it stops saving; the
// JSON keeps a short key in each `src` instead, and only that is small enough to fit.
// Where IndexedDB is unavailable the data-URIs stay inline, exactly as they were.
const DB_NAME = 'edentext-images';
const STORE = 'images';
export const IDB_SRC = 'idb:';
// Below this a picture is an icon, and one round trip through IndexedDB costs more
// than the bytes save.
const MIN_STASH_CHARS = 4096;
// Content key: FNV-1a over the head, the tail and the length, not the whole data-URI —
// a photo is megabytes, and this runs on every autosave. Two different pictures
// agreeing on all three is not something a document produces.
function keyOf(src: string): string {
const sample = src.length <= 8192 ? src : src.slice(0, 4096) + src.slice(-4096);
let h = 0x811c9dc5;
for (let i = 0; i < sample.length; i++) {
h ^= sample.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return `${(h >>> 0).toString(36)}${src.length.toString(36)}`;
}
type Json = { attrs?: Record<string, unknown>; content?: Json[] } & Record<string, unknown>;
// Every node with a `src`, in document order. Images ride paragraphs, cells and text
// boxes alike, so the walk is over the whole tree rather than a known depth.
function srcNodes(node: Json, out: Json[] = []): Json[] {
if (typeof node?.attrs?.src === 'string') out.push(node);
for (const child of node?.content ?? []) srcNodes(child, out);
return out;
}
/**
* The document with its large pictures replaced by `idb:` keys, plus those pictures.
* A structural copy: only the nodes on the path to a picture are rebuilt, so the
* caller's JSON is untouched.
*/
export function stashImages(json: object): { json: object; blobs: Map<string, string> } {
const blobs = new Map<string, string>();
const walk = (node: Json): Json => {
const src = node?.attrs?.src;
const content = node?.content?.map(walk);
let attrs = node?.attrs;
if (typeof src === 'string' && src.startsWith('data:') && src.length >= MIN_STASH_CHARS) {
const key = keyOf(src);
blobs.set(key, src);
attrs = { ...attrs, src: IDB_SRC + key };
}
return content || attrs !== node?.attrs ? { ...node, ...(attrs ? { attrs } : {}), ...(content ? { content } : {}) } : node;
};
return { json: walk(json as Json), blobs };
}
// Whether this session has anything in the store. Without it a document whose last
// picture was just deleted would skip the sweep below and leave its bytes behind.
let inUse = false;
/** Write the pictures and drop every key the document no longer references. */
export async function putImages(blobs: Map<string, string>): Promise<boolean> {
if (!blobs.size && !inUse) return true;
if (typeof indexedDB === 'undefined') return false;
inUse = blobs.size > 0;
try {
const db = await openDb(DB_NAME, STORE);
const known = await idbRequest<IDBValidKey[]>(db, STORE, 'readonly', (s) => s.getAllKeys());
for (const [key, src] of blobs) {
if (!known.includes(key)) await idbRequest(db, STORE, 'readwrite', (s) => s.put(src, key));
}
for (const key of known) {
if (!blobs.has(String(key))) await idbRequest(db, STORE, 'readwrite', (s) => s.delete(key));
}
db.close();
return true;
} catch {
return false;
}
}
/**
* Put the pictures back. Returns how many keys the store no longer had — their nodes
* keep the key as their `src`, which renders as a broken picture rather than quietly
* dropping the reader's content.
*/
export async function restoreImages(json: object): Promise<number> {
const nodes = srcNodes(json as Json).filter((n) => String(n.attrs!.src).startsWith(IDB_SRC));
if (!nodes.length) return 0;
inUse = true;
if (typeof indexedDB === 'undefined') return nodes.length;
let missing = 0;
try {
const db = await openDb(DB_NAME, STORE);
for (const node of nodes) {
const key = String(node.attrs!.src).slice(IDB_SRC.length);
const src = await idbRequest<string | undefined>(db, STORE, 'readonly', (s) => s.get(key));
if (src) node.attrs!.src = src;
else missing++;
}
db.close();
} catch {
return nodes.length;
}
return missing;
}