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:
1 parent
d344494fcd
commit
6a313e5692
11 files changed
+217
-38
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user