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.
This commit is contained in:
Steffen Becker committed 2026-09-04 19:30:12 +02:00
1 parent b8ff3d764f
commit 2d2b321bad
5 files changed
+57

No files matched your search

+42
View File
@@ -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<TextStats | null>(() => {
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())}
/>
<span class="doc-name-ext">.{documentFormat}</span>
{#if dirty}<span class="doc-dirty" title={t().app.unsavedChanges}>•</span>{/if}
<svg class="doc-name-pencil" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M11.3 2.3a1 1 0 0 1 1.4 0l1 1a1 1 0 0 1 0 1.4l-7 7-2.8.9.9-2.8 7-7.5z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round" stroke-linecap="round"/>
</svg>
@@ -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);
+9
View File
@@ -212,6 +212,15 @@ margin with the zone's edge distance. The watermark is an `<svg><text textLength
fontwork shape **stretches** its text to its box instead of setting it at a size — a font
size alone makes a short word far too small and a long one overflow the page.
## The unsaved dot (`App.svelte`, both chromes)
A `•` beside the document name while the text differs from what was last written to a
file. It follows the **doc object**: the editor's first document is the clean one, every
later one is a change (an undo back to it included), and a save, an open or a new document
makes the current one clean again. Margins, styles or header/footer changes are not marked.
Leaving the page warns only when the document actually has a file — a browser-only document
is kept by the autosave, so there would be nothing to lose.
## Built-in templates (`TemplateGalleryDialog.svelte`, `lib/templates/`)
"New from template" in both chromes opens the gallery; a card applies its entry via
+4
View File
@@ -38,6 +38,7 @@
chromeMode = $bindable<ChromeMode>('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())}
/>
<span class="doc-name-ext">.{documentFormat}</span>
{#if dirty}<span class="doc-dirty" title={t().app.unsavedChanges}>•</span>{/if}
</div>
<!-- Word puts this chevron in the band's corner. It rides the strip so the band
@@ -605,6 +608,7 @@
.doc-name-input:focus { outline: none; border-color: var(--w-accent); color: var(--w-text); }
.doc-name-ext { margin-right: 6px; }
.doc-dirty { margin: 0 6px 0 -4px; color: var(--w-text-dim); }
.ribbon-body {
display: flex;
+1
View File
@@ -1040,6 +1040,7 @@ const de: Messages = {
open: 'Öffnen…',
save: '.odt speichern',
saveExport: 'Speichern / Exportieren',
unsavedChanges: 'Nicht gespeicherte Änderungen',
saveAsFormats: '.odt oder .docx',
odt: 'ODT',
openDocument: 'OpenDocument',
+1
View File
@@ -1046,6 +1046,7 @@ const en = {
open: 'Open…',
save: 'Save .odt',
saveExport: 'Save / Export',
unsavedChanges: 'Unsaved changes',
saveAsFormats: '.odt or .docx',
odt: 'ODT',
openDocument: 'OpenDocument',