diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index bd1ec57..ea7a9fe 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -17,7 +17,7 @@ import ContextMenu from './ContextMenu.svelte'; import HeaderFooterLayer from './HeaderFooterLayer.svelte'; import Ruler from './Ruler.svelte'; - import { saveDocument, loadDocument } from '../storage/autosave'; + import { saveDocument, loadDocument, markDocumentLoaded } from '../storage/autosave'; import { applyMarginVars, cmToPx, DEFAULT_MARGINS, type PageMargins } from '../storage/pageMargins'; import { DEFAULT_TAB_INTERVAL_CM } from '../storage/tabInterval'; import { type Orientation } from '../storage/pageOrientation'; @@ -791,6 +791,9 @@ // also fires pm-pagecount shortly after with the precise document height. recomputeScaledSize(); requestAnimationFrame(recomputeScaledSize); + // Two frames in: the first pagination pass has run and painted. A document that + // hangs or throws on the way here leaves the boot flag set and is skipped next load. + requestAnimationFrame(() => requestAnimationFrame(markDocumentLoaded)); }); function onEditorScroll() { diff --git a/src/lib/i18n/locales/de.ts b/src/lib/i18n/locales/de.ts index 9796c54..60bf525 100644 --- a/src/lib/i18n/locales/de.ts +++ b/src/lib/i18n/locales/de.ts @@ -488,6 +488,8 @@ const de: Messages = { couldNotPrintPdf: 'Druck als PDF nicht möglich.', autosaveQuota: '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 „odf-editor-doc-broken" erhalten.', }, importWarn: { imagesSkipped: 'Einige Bilder konnten nicht gelesen werden und wurden übersprungen', diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 1a762f9..3cb21f0 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -492,6 +492,8 @@ const en = { couldNotPrintPdf: 'Could not print to PDF.', autosaveQuota: '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 "odf-editor-doc-broken".', }, // 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). diff --git a/src/lib/storage/CLAUDE.md b/src/lib/storage/CLAUDE.md index 6eb90f7..40e274e 100644 --- a/src/lib/storage/CLAUDE.md +++ b/src/lib/storage/CLAUDE.md @@ -10,7 +10,7 @@ Both margins and orientation are passed into the ODT export so the exported docu ## localStorage keys -- **Document:** `odf-editor-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`). +- **Document:** `odf-editor-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`). Loading it raises `odf-editor-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 `odf-editor-doc-broken` and the app starts empty, so a reload escapes the freeze instead of repeating it. - **Theme:** `odf-editor-theme` — `'light' | 'dark' | 'allBlack' | 'auto'`. - **Toolbar expanded:** `odf-editor-toolbar-expanded` — boolean string. - **Formatting marks:** `odf-editor-formatting-marks` — boolean string. diff --git a/src/lib/storage/autosave.ts b/src/lib/storage/autosave.ts index 4359284..62c5d62 100644 --- a/src/lib/storage/autosave.ts +++ b/src/lib/storage/autosave.ts @@ -1,6 +1,13 @@ import { t } from '../i18n/i18n.svelte'; const STORAGE_KEY = 'odf-editor-doc'; +// Set while a stored document is being handed to the editor, cleared once the editor +// has actually come up (markDocumentLoaded). Finding it still set at load time means +// the last attempt hung or threw — reloading would only freeze again. +const BOOT_KEY = 'odf-editor-doc-loading'; +// Where such a document is parked instead of being loaded, so nothing is lost and +// it can still be pulled out of localStorage. +const BROKEN_KEY = 'odf-editor-doc-broken'; const DEBOUNCE_MS = 1000; let timeout: ReturnType | null = null; @@ -26,12 +33,31 @@ export function saveDocument(json: object): void { export function loadDocument(): object | null { const raw = localStorage.getItem(STORAGE_KEY); + if (localStorage.getItem(BOOT_KEY)) { + localStorage.removeItem(BOOT_KEY); + if (raw) { + localStorage.setItem(BROKEN_KEY, raw); + localStorage.removeItem(STORAGE_KEY); + // After paint, so the message doesn't land on a blank app. + requestAnimationFrame(() => alert(t().dialogs.documentNotLoaded)); + } + return null; + } if (!raw) return null; + let doc: object; try { - return JSON.parse(raw); + doc = JSON.parse(raw); } catch { return null; } + localStorage.setItem(BOOT_KEY, '1'); + return doc; +} + +// Called once the editor is up and has laid out the document. Until then the boot +// flag stands, so a document that freezes the editor is skipped on the next load. +export function markDocumentLoaded(): void { + localStorage.removeItem(BOOT_KEY); } export function clearDocument(): void { diff --git a/tests/unit/autosave-recovery.test.ts b/tests/unit/autosave-recovery.test.ts new file mode 100644 index 0000000..d526c7b --- /dev/null +++ b/tests/unit/autosave-recovery.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { loadDocument, markDocumentLoaded, saveDocument } from '../../src/lib/storage/autosave'; + +// A document that freezes the editor would be reloaded from localStorage forever, so +// loading raises a boot flag that only a completed startup clears. +describe('autosave crash recovery', () => { + beforeEach(() => { + localStorage.clear(); + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { void cb; return 0; }); + vi.stubGlobal('alert', () => {}); + }); + + it('loads the document again after a startup that completed', () => { + localStorage.setItem('odf-editor-doc', '{"type":"doc"}'); + expect(loadDocument()).toEqual({ type: 'doc' }); + markDocumentLoaded(); + expect(loadDocument()).toEqual({ type: 'doc' }); + }); + + it('parks the document and starts empty when the last startup never finished', () => { + localStorage.setItem('odf-editor-doc', '{"type":"doc"}'); + loadDocument(); // raises the boot flag, then the "editor hangs" + expect(loadDocument()).toBeNull(); + expect(localStorage.getItem('odf-editor-doc-broken')).toBe('{"type":"doc"}'); + expect(localStorage.getItem('odf-editor-doc')).toBeNull(); + }); + + it('keeps the next document after a recovery', () => { + localStorage.setItem('odf-editor-doc', '{"type":"doc","content":[]}'); + loadDocument(); + expect(loadDocument()).toBeNull(); // recovered: boot flag cleared, doc parked + vi.useFakeTimers(); + saveDocument({ type: 'doc' }); + vi.runAllTimers(); + vi.useRealTimers(); + expect(loadDocument()).toEqual({ type: 'doc' }); + }); +});