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:
1 parent
b8ff3d764f
commit
2d2b321bad
5 files changed
+57
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user