diff --git a/bundled-addons/pdf-editor/addon.json b/bundled-addons/pdf-editor/addon.json
index af2dbb9..8b88ad5 100644
--- a/bundled-addons/pdf-editor/addon.json
+++ b/bundled-addons/pdf-editor/addon.json
@@ -1,8 +1,8 @@
{
"id": "pdf-editor",
"name": "PDF Editor",
- "version": "0.3.1",
- "description": "Open a PDF in a full tab: read it, mark it up with highlight, underline, strike-through, pen, shapes, arrows, text and a drawn signature, then move, resize, restyle or delete anything you placed. Reorder, rotate or drop pages, fill in form fields, redact for real, and save a new copy. Marks are written into the saved file as real PDF content. The original is never touched.",
+ "version": "0.4.0",
+ "description": "Open a PDF in a full tab: read it, mark it up with highlight, underline, strike-through, pen, shapes, arrows, text and a drawn signature, then move, resize, restyle or delete anything you placed. Reorder, rotate or drop pages, fill in form fields, redact for real, and save a new copy. Build a PDF from pictures, one page each, or join several PDFs into one. Marks are written into the saved file as real PDF content. The original is never touched.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
"main": "index.js",
@@ -11,7 +11,7 @@
"title": "PDF Editor",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
"items": [
- { "id": "open", "label": "Open a PDF…" }
+ { "id": "open", "label": "Open the editor" }
]
},
"context-menu-items": [
diff --git a/bundled-addons/pdf-editor/editor.css b/bundled-addons/pdf-editor/editor.css
index 79e1e57..aee789a 100644
--- a/bundled-addons/pdf-editor/editor.css
+++ b/bundled-addons/pdf-editor/editor.css
@@ -139,7 +139,24 @@ body[data-dragover="1"] .dropzone { display: flex; pointer-events: none;
body[data-dragover="1"] .dz-inner { border-color: rgb(from var(--acid) r g b / .8); color: var(--ink); }
.dz-inner svg { width: 48px; height: 48px; margin-bottom: 10px; }
.dz-title { font-size: 15px; color: var(--ink); font-weight: 600; }
-.dz-sub { margin-top: 5px; }
+.dz-sub { margin-top: 5px; max-width: 42ch; margin-left: auto; margin-right: auto; }
+
+/* Start-screen tiles. Hidden while a drag is in flight: during a drop the
+ drop zone is the whole target, and a row of buttons under the cursor
+ reads as somewhere to aim, which it is not. */
+.dz-actions { display: flex; gap: 10px; margin-top: 22px; justify-content: center;
+ flex-wrap: wrap; }
+body[data-dragover="1"] .dz-actions { visibility: hidden; }
+.tile { display: flex; flex-direction: column; align-items: flex-start; gap: 3px;
+ width: 176px; padding: 13px 14px; text-align: left; cursor: pointer;
+ background: var(--bg); color: var(--ink); border: 1px solid var(--line);
+ border-radius: 10px; font: inherit; }
+.tile:hover { border-color: rgb(from var(--acid) r g b / .55);
+ background: rgb(from var(--acid) r g b / .06); }
+.tile:focus-visible { outline: 2px solid var(--acid); outline-offset: 2px; }
+.tile svg { width: 22px; height: 22px; margin: 0 0 5px; color: var(--acid); }
+.tile-t { font-weight: 600; font-size: 13px; }
+.tile-s { font-size: 11.5px; color: var(--dim); line-height: 1.35; }
.linkish { background: none; border: none; padding: 0; font: inherit; cursor: pointer;
color: var(--acid); text-decoration: underline; }
diff --git a/bundled-addons/pdf-editor/editor.html b/bundled-addons/pdf-editor/editor.html
index 3ca06fc..dafbde7 100644
--- a/bundled-addons/pdf-editor/editor.html
+++ b/bundled-addons/pdf-editor/editor.html
@@ -141,7 +141,31 @@
Drop a PDF here
- or choose a file
+ Drop pictures instead and they become the pages. Drop several PDFs and they join up.
+
+
+
+
+
+ Open a PDF
+ Read it, mark it up, fill it in, sign it
+
+
+
+
+
+
+ Pictures to PDF
+ One page per picture, in the order you pick
+
+
+
+
+
+ Join PDFs
+ Several files, end to end, as one document
+
+
@@ -232,6 +256,33 @@
+
+
Pictures to PDF
+
+
+ Page
+
+ Shaped like the picture
+ A4
+ Letter
+
+
+ Border
+
+ None
+ Narrow
+ Wide
+
+
+
+
+
+
+ Cancel
+ Make the PDF
+
+
+
Save a copy
File name
@@ -276,6 +327,8 @@
+
+
diff --git a/bundled-addons/pdf-editor/editor.js b/bundled-addons/pdf-editor/editor.js
index 2e2818a..a7d5b03 100644
--- a/bundled-addons/pdf-editor/editor.js
+++ b/bundled-addons/pdf-editor/editor.js
@@ -23,6 +23,7 @@ import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
import { boundsOf, clampFont } from "./lib/shape.js";
import { reflow } from "./lib/reflow.js";
import { buildDocx } from "./lib/docx.js";
+import { imagesToPdf, mergePdfs } from "./lib/create.js";
const $ = (id) => document.getElementById(id);
const sm = window.silentmode || null;
@@ -60,7 +61,7 @@ const session = {
rail: null,
tools: null,
scratchId: null, // set when the document came from the add-on's scratch dir
- name: "document.pdf",
+ name: null, // null until something is loaded; the chrome says so
};
// ---- chrome -----------------------------------------------------------
@@ -264,10 +265,15 @@ async function teardown() {
}
// ---- open paths -------------------------------------------------------
+// A tab that can be given files three ways — the dock, a button, a drop — has
+// to decide what a handful of files MEANS before it can act. One PDF is a
+// document to edit. Several are a document to build. Pictures are pages.
+const isPdfFile = (f) => /\.pdf$/i.test(f.name) || f.type === "application/pdf";
+const isImageFile = (f) => /^image\//.test(f.type) || /\.(png|jpe?g|webp|gif|bmp|avif|tiff?)$/i.test(f.name);
+
async function openFile(file) {
if (!file) return;
- const looksPdf = /\.pdf$/i.test(file.name) || file.type === "application/pdf";
- if (!looksPdf) { toast(`${file.name} is not a PDF`, true); return; }
+ if (!isPdfFile(file)) { toast(`${file.name} is not a PDF`, true); return; }
if (!(await confirmDiscard())) return;
const buf = new Uint8Array(await file.arrayBuffer());
if (buf.subarray(0, 5).every((b, i) => b === "%PDF-".charCodeAt(i)) === false) {
@@ -277,14 +283,114 @@ async function openFile(file) {
await loadBytes(buf, file.name);
}
+async function openFiles(list) {
+ const files = [...(list || [])];
+ if (!files.length) return;
+ if (files.length === 1 && isPdfFile(files[0])) return openFile(files[0]);
+ if (files.every(isImageFile)) return fromImages(files);
+ if (files.every(isPdfFile)) return joinPdfs(files);
+ const odd = files.find((f) => !isPdfFile(f) && !isImageFile(f));
+ toast(odd ? `${odd.name} is neither a PDF nor a picture` : "Drop PDFs or pictures, not both at once", true);
+}
+
+/**
+ * Pictures in, one page each.
+ *
+ * The result is a document that exists only here until it is saved, so it is
+ * marked unsaved the moment it opens — otherwise closing the tab would throw
+ * away work that never touched a disk, without asking.
+ */
+async function fromImages(files) {
+ if (!(await confirmDiscard())) return;
+ $("images-count").textContent =
+ `${files.length} picture${files.length === 1 ? "" : "s"}, one per page, in the order your file browser listed them.`;
+ syncImagesDialog();
+ if (!(await openModal("modal-images"))) return;
+ const page = $("images-page").value;
+ const margin = Number($("images-margin").value) || 0;
+ setBusy(true, "Building…");
+ let out;
+ try {
+ out = await imagesToPdf(files, {
+ page, margin,
+ onProgress: (i, n) => setBusy(true, `Adding picture ${i} of ${n}…`),
+ });
+ } catch (e) {
+ setBusy(false);
+ status(`Could not build a PDF: ${e?.message || e}`, true);
+ toast(`Could not build a PDF: ${e?.message || e}`, true);
+ return;
+ }
+ if (!(await loadBytes(new Uint8Array(out.bytes), "pictures.pdf"))) return;
+ afterBuild(`${out.pages} page${out.pages === 1 ? "" : "s"} from your pictures`, out.failed);
+}
+
+/**
+ * Several PDFs, end to end. Order is the order they were handed over; the
+ * page rail is where it gets fixed, which is the honest answer to "can I
+ * reorder them" — yes, once they are one document.
+ */
+async function joinPdfs(files) {
+ if (!(await confirmDiscard())) return;
+ setBusy(true, "Joining…");
+ let out;
+ try {
+ out = await mergePdfs(files, { onProgress: (i, n) => setBusy(true, `Reading file ${i} of ${n}…`) });
+ } catch (e) {
+ setBusy(false);
+ status(`Could not join those files: ${e?.message || e}`, true);
+ toast(`Could not join those files: ${e?.message || e}`, true);
+ return;
+ }
+ if (!(await loadBytes(new Uint8Array(out.bytes), "joined.pdf"))) return;
+ const from = out.parts.map((p) => `${p.name} (${p.pages})`).join(", ");
+ afterBuild(`${out.pages} pages from ${out.parts.length} files · ${from}`, out.failed);
+ if (out.hadForms) {
+ toast("Form fields do not survive joining — the pages kept their look, not their fields", true);
+ }
+}
+
+// Shared tail for the two build paths: say where the document came from, say
+// that it is not on disk yet, and name anything that had to be skipped.
+function afterBuild(what, failed) {
+ session.model.markUnsaved();
+ paintChrome();
+ status(`${what} · not saved yet — use Save a copy`);
+ if (failed && failed.length) {
+ toast(`Skipped ${failed.length} file${failed.length === 1 ? "" : "s"}: ${failed.map((f) => f.name).join(", ")}`, true);
+ }
+}
+
+// A page shaped like its picture has no border to set, so the field goes away
+// rather than sitting there doing nothing.
+function syncImagesDialog() {
+ const fit = $("images-page").value === "fit";
+ $("images-margin-field").hidden = fit;
+ $("images-note").textContent = fit
+ ? "Each page takes the shape of its picture, edge to edge, sized so the long side matches A4."
+ : "Each picture is centred on the page, scaled to fit inside the border. Wide pictures turn the page on its side.";
+}
+$("images-page").addEventListener("change", syncImagesDialog);
+$("images-go").addEventListener("click", () => closeModal(true));
+$("images-cancel").addEventListener("click", () => closeModal(null));
+
$("file-input").addEventListener("change", (e) => {
const f = e.target.files?.[0];
e.target.value = "";
openFile(f);
});
+for (const [inputId, handler] of [["img-input", fromImages], ["merge-input", joinPdfs]]) {
+ $(inputId).addEventListener("change", (e) => {
+ const files = [...(e.target.files || [])];
+ e.target.value = "";
+ if (files.length) handler(files);
+ });
+}
const pick = () => $("file-input").click();
$("open").addEventListener("click", pick);
-$("open2").addEventListener("click", pick);
+$("start-open").addEventListener("click", pick);
+$("start-images").addEventListener("click", () => $("img-input").click());
+$("start-merge").addEventListener("click", () => $("merge-input").click());
// Drag and drop anywhere in the tab.
for (const type of ["dragenter", "dragover"]) {
@@ -303,7 +409,7 @@ window.addEventListener("drop", (e) => {
if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
e.preventDefault();
document.body.dataset.dragover = "";
- openFile(e.dataTransfer.files?.[0]);
+ openFiles(e.dataTransfer.files);
});
// ---- page operations --------------------------------------------------
@@ -1101,7 +1207,7 @@ window.addEventListener("keydown", (e) => {
} catch {}
const id = new URLSearchParams(location.search).get("doc");
- if (!id) { status("Drop a PDF here, or use Open."); paintChrome(); return; }
+ if (!id) { status("Drop a PDF or some pictures here, or pick one of the options."); paintChrome(); return; }
if (!sm?.invoke) { status("This page has to be opened from the PDF Editor add-on.", true); return; }
setBusy(true, "Fetching…");
try {
diff --git a/bundled-addons/pdf-editor/index.js b/bundled-addons/pdf-editor/index.js
index 8902f0d..815f3e2 100644
--- a/bundled-addons/pdf-editor/index.js
+++ b/bundled-addons/pdf-editor/index.js
@@ -97,55 +97,21 @@ module.exports = {
// ---- launch paths -------------------------------------------------
// Dock → the file picker, straight away.
//
- // This used to open an empty editor whose own drop zone then asked for a
- // file, which meant three clicks and two screens before the user reached
- // their own documents: dock, menu item, Open, and only then the file
- // browser. Asking here collapses it — the dock click IS the "open a file"
- // gesture, so answer it with a file browser.
+ // The dock click opens the editor, and stops there.
//
- // The dialog is native, raised from the add-on's Node side, because a
- // file:// page cannot open one without a user gesture of its own and a
- // freshly-opened tab has no gesture to spend.
- //
- // Cancelling still lands you in the editor, empty. Someone who changed
- // their mind about which file probably still wants the editor, and it is
- // where drag-and-drop works.
+ // It briefly raised a native file browser instead, to save the clicks
+ // between the dock and a document. That was right while the editor had
+ // exactly one thing to offer an empty tab. It stopped being right once
+ // the start screen grew: a file browser can only answer "which PDF",
+ // and the answer is now sometimes "none of them — build one from these
+ // photos" or "weld these four together".
api.onMessage("menu-select", async (payload) => {
const item = String(payload && payload.id || "");
if (item !== "open") throw new Error(`unknown menu item: ${item}`);
- const entry = await pickPdf();
- if (!entry) { api.openTab("editor.html"); api.log("picker cancelled — opened an empty editor"); return { ok: true, cancelled: true }; }
- api.openTab("editor.html", { query: { doc: entry.id } });
- api.log(`picked ${entry.name} (${entry.size} bytes) → ${entry.id}`);
- return { ok: true, id: entry.id };
+ api.openTab("editor.html");
+ return { ok: true };
});
- /**
- * Native open dialog, then the bytes in scratch. Returns null when the
- * user cancels or picks something that is not a PDF.
- */
- async function pickPdf() {
- const { dialog, BrowserWindow } = api.require("electron");
- // Parent it to Theseus so the dialog is attached to the window rather
- // than floating loose behind it.
- const parent = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] || null;
- const opts = {
- title: "Open a PDF",
- properties: ["openFile"],
- filters: [{ name: "PDF documents", extensions: ["pdf"] }, { name: "All files", extensions: ["*"] }],
- };
- const res = parent ? await dialog.showOpenDialog(parent, opts) : await dialog.showOpenDialog(opts);
- const file = !res.canceled && res.filePaths && res.filePaths[0];
- if (!file) return null;
- const stat = await fs.promises.stat(file);
- if (stat.size > MAX_BYTES) throw new Error(`${path.basename(file)} is ${(stat.size / 1048576).toFixed(0)} MB — over the ${MAX_BYTES / 1048576} MB cap`);
- const buf = await fs.promises.readFile(file);
- if (buf.subarray(0, 5).toString("latin1") !== "%PDF-") {
- throw new Error(`${path.basename(file)} does not start with a PDF header`);
- }
- return writeScratch(buf, safeName(path.basename(file)));
- }
-
// Right-click a link → fetch it here, hand the editor an id.
api.onMessage("context-menu", async (payload) => {
if (String(payload && payload.itemId || "") !== "open-link") return;
diff --git a/bundled-addons/pdf-editor/lib/create.js b/bundled-addons/pdf-editor/lib/create.js
new file mode 100644
index 0000000..a52bfe8
--- /dev/null
+++ b/bundled-addons/pdf-editor/lib/create.js
@@ -0,0 +1,133 @@
+// Making a PDF where there wasn't one: photos in, or several files welded
+// into one. Both paths hand back plain bytes, which the editor then opens as
+// if they had come off disk — so everything downstream (marks, page rail,
+// save, convert) works on them without knowing where they came from.
+
+import { PDFDocument } from "../vendor/pdf-lib/pdf-lib.esm.min.js";
+
+// Points, the PDF's own unit. A4 is the world's paper; Letter is North
+// America's. "fit" below means neither: the page takes the image's shape.
+export const PAGE_SIZES = {
+ a4: [595.28, 841.89],
+ letter: [612, 792],
+};
+// The long edge of a fitted page. A phone photo is 4000 px on its longest
+// side; read as points that would be a 55-inch page, technically valid and
+// useless to anyone who prints it. Mapping the long edge to A4's long edge
+// keeps the image's proportions and gives a page a printer understands.
+const FIT_LONG_EDGE = 841.89;
+
+const JPEG = [0xff, 0xd8, 0xff];
+const PNG = [0x89, 0x50, 0x4e, 0x47];
+const starts = (bytes, sig) => sig.every((b, i) => bytes[i] === b);
+
+/**
+ * pdf-lib embeds JPEG and PNG, and nothing else. Rather than refuse a WebP or
+ * a HEIC-turned-WebP screenshot, hand it to the browser — which can decode far
+ * more than pdf-lib can embed — and re-encode as PNG. Lossless, so a screenshot
+ * survives intact; a photo re-encoded this way grows, which is why anything
+ * already JPEG is passed through untouched.
+ */
+async function toEmbeddable(file) {
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ if (starts(bytes, JPEG)) return { bytes, kind: "jpg" };
+ if (starts(bytes, PNG)) return { bytes, kind: "png" };
+ let bitmap;
+ try { bitmap = await createImageBitmap(file); }
+ catch { throw new Error(`${file.name} is not an image this browser can read`); }
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
+ canvas.getContext("2d").drawImage(bitmap, 0, 0);
+ bitmap.close();
+ const blob = await canvas.convertToBlob({ type: "image/png" });
+ return { bytes: new Uint8Array(await blob.arrayBuffer()), kind: "png" };
+}
+
+/**
+ * One image per page, in the order given.
+ *
+ * `page` is "fit" (the page takes each image's shape), "a4" or "letter". For
+ * the two paper sizes the sheet turns landscape when the image is wider than
+ * it is tall, because a landscape photo on a portrait page is mostly margin.
+ * `margin` is in points and ignored by "fit" — the whole point of fitting the
+ * page to the image is that there is no border.
+ */
+export async function imagesToPdf(files, { page = "fit", margin = 18, onProgress } = {}) {
+ if (!files.length) throw new Error("no images given");
+ const pdf = await PDFDocument.create();
+ const failed = [];
+ let n = 0;
+ for (const file of files) {
+ onProgress?.(++n, files.length, file.name);
+ let img;
+ try {
+ const { bytes, kind } = await toEmbeddable(file);
+ img = kind === "jpg" ? await pdf.embedJpg(bytes) : await pdf.embedPng(bytes);
+ } catch (e) {
+ failed.push({ name: file.name, why: e?.message || String(e) });
+ continue;
+ }
+ if (page === "fit") {
+ const scale = FIT_LONG_EDGE / Math.max(img.width, img.height);
+ const w = img.width * scale, h = img.height * scale;
+ pdf.addPage([w, h]).drawImage(img, { x: 0, y: 0, width: w, height: h });
+ continue;
+ }
+ const [pw, ph] = PAGE_SIZES[page] || PAGE_SIZES.a4;
+ const landscape = img.width > img.height;
+ const sheet = pdf.addPage(landscape ? [ph, pw] : [pw, ph]);
+ const boxW = sheet.getWidth() - margin * 2;
+ const boxH = sheet.getHeight() - margin * 2;
+ // contain, never crop: the smaller ratio wins, and the leftover becomes
+ // an even border on the two sides that did not bind.
+ const scale = Math.min(boxW / img.width, boxH / img.height);
+ const w = img.width * scale, h = img.height * scale;
+ sheet.drawImage(img, {
+ x: (sheet.getWidth() - w) / 2,
+ y: (sheet.getHeight() - h) / 2,
+ width: w, height: h,
+ });
+ }
+ if (!pdf.getPageCount()) {
+ throw new Error(failed.length ? `none of those files could be read (${failed[0].why})` : "no pages were made");
+ }
+ return { bytes: await pdf.save(), pages: pdf.getPageCount(), failed };
+}
+
+/**
+ * Several PDFs, end to end, in the order given.
+ *
+ * Page content, annotations and links inside each document survive, because
+ * copyPages carries the whole page object across. What does NOT survive is
+ * anything that lives above the page: form fields, the outline, and
+ * attachments. Those belong to the document they came from, and merging four
+ * documents that each define a field called "name" has no correct answer.
+ * The editor says so plainly rather than silently dropping them.
+ */
+export async function mergePdfs(files, { onProgress } = {}) {
+ if (files.length < 2) throw new Error("pick at least two PDFs to merge");
+ const out = await PDFDocument.create();
+ const parts = [];
+ const failed = [];
+ let hadForms = false;
+ let n = 0;
+ for (const file of files) {
+ onProgress?.(++n, files.length, file.name);
+ try {
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ // An encrypted-but-openable PDF is the common case (print/copy flags,
+ // no password). ignoreEncryption lets those through; a real
+ // password-protected file still throws, and lands in `failed`.
+ const src = await PDFDocument.load(bytes, { ignoreEncryption: true });
+ try { if (src.getForm().getFields().length) hadForms = true; } catch {}
+ const copied = await out.copyPages(src, src.getPageIndices());
+ for (const p of copied) out.addPage(p);
+ parts.push({ name: file.name, pages: copied.length });
+ } catch (e) {
+ failed.push({ name: file.name, why: e?.message || String(e) });
+ }
+ }
+ if (!out.getPageCount()) {
+ throw new Error(failed.length ? `nothing could be read (${failed[0].why})` : "no pages were copied");
+ }
+ return { bytes: await out.save(), pages: out.getPageCount(), parts, failed, hadForms };
+}
diff --git a/bundled-addons/pdf-editor/lib/model.js b/bundled-addons/pdf-editor/lib/model.js
index 0f56012..5d3e673 100644
--- a/bundled-addons/pdf-editor/lib/model.js
+++ b/bundled-addons/pdf-editor/lib/model.js
@@ -127,6 +127,10 @@ export class DocModel {
return step.label;
}
markSaved() { this.savedMark = this._key(); this._emit("saved"); }
+ // A document this session BUILT — from pictures, or by joining files — has
+ // never been anywhere. It carries no marks, so the usual comparison calls
+ // it clean and closing the tab would take it away without a word.
+ markUnsaved() { this.savedMark = null; this._emit("dirty"); }
isDirty() { return this._key() !== this.savedMark; }
// ---- mutations ----------------------------------------------------