From 2d2b321bad2f96ff82e03f0a1fe5fe97abed2f4a Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Fri, 4 Sep 2026 00:10:58 +0200 Subject: [PATCH] feat: the document name carries a dot while the file is behind The dot shows in both chromes as soon as the text differs from what was last written to a file, and goes when a save, an open or a new document makes them agree again. Leaving the page warns only where the document has a file to lose the changes from. --- src/App.svelte | 42 +++++++++++++++++++++++++ src/lib/components/CLAUDE.md | 9 ++++++ src/lib/components/ribbon/Ribbon.svelte | 4 +++ src/lib/i18n/locales/de.ts | 1 + src/lib/i18n/locales/en.ts | 1 + 5 files changed, 57 insertions(+) diff --git a/src/App.svelte b/src/App.svelte index 53bbf9c..4a63c32 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -149,9 +149,33 @@ const { doc } = editor.state; if (doc === countedDoc) return; countedDoc = doc; + // Whatever the editor comes up with is what the file holds; every doc after that + // is an edit the file has not seen. Undoing back to it counts as a change too. + cleanDoc ??= doc; + dirty = doc !== cleanDoc; clearTimeout(countTimer); countTimer = setTimeout(() => { docStats = countText(doc, 0, doc.content.size); }, 300); }); + + // Changed since the last save into a file — the dot both word processors show. It + // follows the text only: a margin or a style change is not marked. + let dirty = $state(false); + let cleanDoc: PmNode | null = null; + // Whether there is a file to lose those changes from. A document that only ever + // lived in the browser is kept by the autosave, so leaving is not worth a warning. + let documentHasFile = $state(false); + + function markSaved(): void { + cleanDoc = editor?.state.doc ?? null; + dirty = false; + } + + $effect(() => { + if (!dirty || !documentHasFile) return; + const warn = (e: BeforeUnloadEvent) => e.preventDefault(); + addEventListener('beforeunload', warn); + return () => removeEventListener('beforeunload', warn); + }); let selStats = $derived.by(() => { if (tick < 0 || !editor) return null; const { from, to, empty } = editor.state.selection; @@ -607,6 +631,7 @@ saveDocProperties(docProps); fileHandle = null; documentFormat = 'odt'; + documentHasFile = false; docPassword = null; passwordLost = false; // Styles and note settings live in the document, so a new one starts from the built-ins @@ -623,6 +648,7 @@ documentEpoch++; resetHistory(); resetDocumentState(); + markSaved(); editor.commands.focus(); } @@ -645,6 +671,7 @@ } foldMarks = data.foldMarks === true; documentName = entry.name(); + markSaved(); editor.commands.focus(); } @@ -804,6 +831,10 @@ // and an unprotected file drops the previous document's. docPassword = password; passwordLost = false; + // The document as opened is what its file holds — a template's is the new + // document's own, which is nowhere yet. + documentHasFile = !isTemplate; + markSaved(); if (sourceName) recentFiles = await rememberRecentFile(sourceName, isTemplate ? null : handle); // Warn about fonts the document uses but the browser can't render, so text @@ -929,6 +960,8 @@ const save = documentFormat === 'docx' ? saveDocx : saveOdt; fileHandle = await save(bytes, name, fileHandle, docPassword); recentFiles = await rememberRecentFile(fileHandle?.name ?? name, fileHandle); + documentHasFile = true; + markSaved(); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; // A stored handle may have lost its permission or its file: prompt for a new one, @@ -953,6 +986,8 @@ fileHandle = handle; documentFormat = kind; recentFiles = await rememberRecentFile(handle?.name ?? suggested, handle); + documentHasFile = true; + markSaved(); } catch (err) { if ((err as DOMException)?.name === 'AbortError') return; console.error('[save] Failed to save file:', err); @@ -1241,6 +1276,7 @@ bind:chromeMode bind:documentName {documentFormat} + {dirty} bind:showFormattingMarks bind:showRuler bind:splitView @@ -1333,6 +1369,7 @@ onblur={() => (documentName = documentName.trim())} /> .{documentFormat} + {#if dirty}•{/if} @@ -1983,6 +2020,11 @@ display: none; } + .doc-dirty { + flex-shrink: 0; + color: var(--color-text-muted); + } + .doc-name-pencil { flex-shrink: 0; color: var(--color-text-muted); diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index eea186f..56edf95 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -212,6 +212,15 @@ margin with the zone's edge distance. The watermark is an `('ribbon'), documentName = $bindable(''), documentFormat = 'odt', + dirty = false, showFormattingMarks = $bindable(false), showRuler = $bindable(true), splitView = $bindable(false), @@ -78,6 +79,7 @@ chromeMode?: ChromeMode; documentName?: string; documentFormat?: 'odt' | 'docx'; + dirty?: boolean; showFormattingMarks?: boolean; showRuler?: boolean; splitView?: boolean; @@ -298,6 +300,7 @@ onblur={() => (documentName = documentName.trim())} /> .{documentFormat} + {#if dirty}•{/if}