Clicking the dock raised a native file browser, which was the right answer while the editor had exactly one thing to offer an empty tab. It is the wrong answer now: a file browser can only ask which PDF, and the answer is sometimes none of them. So the dock opens the editor, and the empty editor says what it can do. The drop zone stays, and learns to read what it is given — pictures become pages, several PDFs become one document. Beside it sit the three ways in as buttons. Not included: compress, which cannot be done honestly without re-encoding the images, and split, which is the page rail plus Save a copy. A document built from pictures or joins has never been on disk, so it is marked unsaved from the moment it opens — otherwise closing the tab would bin it without asking. An empty editor also stops claiming to hold a file called document.pdf.
192 lines
7.5 KiB
JavaScript
192 lines
7.5 KiB
JavaScript
// The editable state of one open document, and the undo stack over it.
|
|
//
|
|
// Two rules hold this together:
|
|
//
|
|
// 1. Every mark is stored in PDF user space — points, origin bottom-left,
|
|
// the page's own unrotated coordinate system. Not screen pixels. That
|
|
// is the one space shared by pdf.js (viewport.convertToPdfPoint) and
|
|
// pdf-lib (page.drawX), so a mark survives zooming, rotating and
|
|
// reordering without a conversion table, and save-time needs no
|
|
// knowledge of how the page happened to be displayed.
|
|
//
|
|
// 2. Marks are keyed to a page's UID, never to its position. Reordering
|
|
// rewrites `order`; it does not touch a single annotation. A UID also
|
|
// stays valid while a page is deleted, so undeleting restores that
|
|
// page's marks with it.
|
|
//
|
|
// Undo is snapshot-based, like the screenshot editor's. The alternative —
|
|
// paired do/undo commands — needs a correct inverse for each of nine mark
|
|
// types plus reorder, rotate and delete, and one wrong inverse corrupts the
|
|
// document silently. A snapshot of this state is a few KB even with long pen
|
|
// paths, so there is nothing to win by being clever.
|
|
|
|
const UNDO_MAX = 60;
|
|
|
|
let seq = 0;
|
|
export function nextId(prefix = "m") { return `${prefix}${++seq}-${Math.random().toString(36).slice(2, 7)}`; }
|
|
|
|
export class DocModel {
|
|
constructor({ name, pageCount, pageInfo }) {
|
|
this.name = name;
|
|
// pageInfo[i] = { width, height, rotate } straight from pdf.js, in the
|
|
// source document's order. Immutable — it describes the file, not our
|
|
// edits.
|
|
this.pageInfo = pageInfo;
|
|
this.pages = new Map();
|
|
this.order = [];
|
|
for (let i = 0; i < pageCount; i++) {
|
|
const uid = `p${i}`;
|
|
this.pages.set(uid, { uid, src: i, rotate: 0, deleted: false });
|
|
this.order.push(uid);
|
|
}
|
|
this.annots = [];
|
|
this._undo = [];
|
|
this._redo = [];
|
|
this._onChange = [];
|
|
this.savedMark = this._key(); // snapshot identity at last save, for the dirty flag
|
|
}
|
|
|
|
onChange(fn) { this._onChange.push(fn); }
|
|
_emit(what) { for (const fn of this._onChange) { try { fn(what); } catch (e) { console.warn("model listener threw:", e); } } }
|
|
|
|
// ---- derived views ------------------------------------------------
|
|
/** UIDs in display order, deleted pages left out. */
|
|
visible() { return this.order.filter((uid) => !this.pages.get(uid).deleted); }
|
|
page(uid) { return this.pages.get(uid); }
|
|
/** Total rotation to display a page at: the file's own /Rotate plus ours. */
|
|
displayRotation(uid) {
|
|
const p = this.pages.get(uid);
|
|
return (((this.pageInfo[p.src].rotate + p.rotate) % 360) + 360) % 360;
|
|
}
|
|
/** Our added rotation only — what PDFPageView.update({rotation}) wants. */
|
|
extraRotation(uid) { return ((this.pages.get(uid).rotate % 360) + 360) % 360; }
|
|
size(uid) { const p = this.pages.get(uid); return this.pageInfo[p.src]; }
|
|
annotsFor(uid) { return this.annots.filter((a) => a.page === uid); }
|
|
annot(id) { return this.annots.find((a) => a.id === id) || null; }
|
|
deletedCount() { return this.order.filter((uid) => this.pages.get(uid).deleted).length; }
|
|
|
|
hasRedactions() { return this.annots.some((a) => a.kind === "redact"); }
|
|
redactedPages() {
|
|
return new Set(this.annots.filter((a) => a.kind === "redact").map((a) => a.page));
|
|
}
|
|
/** True when the page list differs from the file's, so save must rebuild it. */
|
|
pagesChanged() {
|
|
if (this.deletedCount() > 0) return true;
|
|
return this.order.some((uid, i) => this.pages.get(uid).src !== i);
|
|
}
|
|
rotationsChanged() { return this.order.some((uid) => this.pages.get(uid).rotate % 360 !== 0); }
|
|
|
|
// ---- history ------------------------------------------------------
|
|
_snapshot() {
|
|
return {
|
|
annots: structuredClone(this.annots),
|
|
order: this.order.slice(),
|
|
pages: this.order.map((uid) => { const p = this.pages.get(uid); return [uid, p.rotate, p.deleted]; }),
|
|
};
|
|
}
|
|
_restore(s) {
|
|
this.annots = structuredClone(s.annots);
|
|
this.order = s.order.slice();
|
|
for (const [uid, rotate, deleted] of s.pages) {
|
|
const p = this.pages.get(uid);
|
|
if (p) { p.rotate = rotate; p.deleted = deleted; }
|
|
}
|
|
}
|
|
// A cheap identity for "has anything changed since the last save".
|
|
_key() {
|
|
return JSON.stringify([this.order, this.order.map((u) => [this.pages.get(u).rotate, this.pages.get(u).deleted]), this.annots.length,
|
|
this.annots.map((a) => a.id)]);
|
|
}
|
|
|
|
/** Wrap a mutation so it lands on the undo stack as one step. */
|
|
edit(label, fn) {
|
|
const before = this._snapshot();
|
|
const r = fn();
|
|
this._undo.push({ label, before });
|
|
if (this._undo.length > UNDO_MAX) this._undo.shift();
|
|
this._redo = [];
|
|
this._emit(label);
|
|
return r;
|
|
}
|
|
canUndo() { return this._undo.length > 0; }
|
|
canRedo() { return this._redo.length > 0; }
|
|
undo() {
|
|
const step = this._undo.pop();
|
|
if (!step) return null;
|
|
this._redo.push({ label: step.label, before: this._snapshot() });
|
|
this._restore(step.before);
|
|
this._emit("undo:" + step.label);
|
|
return step.label;
|
|
}
|
|
redo() {
|
|
const step = this._redo.pop();
|
|
if (!step) return null;
|
|
this._undo.push({ label: step.label, before: this._snapshot() });
|
|
this._restore(step.before);
|
|
this._emit("redo:" + step.label);
|
|
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 ----------------------------------------------------
|
|
addAnnot(a) {
|
|
return this.edit(a.kind, () => {
|
|
const full = { id: nextId("a"), ...a };
|
|
this.annots.push(full);
|
|
return full;
|
|
});
|
|
}
|
|
removeAnnot(id) {
|
|
const a = this.annot(id);
|
|
if (!a) return false;
|
|
return this.edit("delete " + a.kind, () => {
|
|
this.annots = this.annots.filter((x) => x.id !== id);
|
|
return true;
|
|
});
|
|
}
|
|
/** Shift a mark by a user-space delta. `patch` is applied to the mark. */
|
|
moveAnnot(id, patch) {
|
|
const a = this.annot(id);
|
|
if (!a) return false;
|
|
return this.edit("move " + a.kind, () => { Object.assign(a, patch); return true; });
|
|
}
|
|
|
|
rotatePage(uid, deltaDeg) {
|
|
return this.edit("rotate", () => {
|
|
const p = this.pages.get(uid);
|
|
p.rotate = (((p.rotate + deltaDeg) % 360) + 360) % 360;
|
|
return p.rotate;
|
|
});
|
|
}
|
|
setDeleted(uid, deleted) {
|
|
return this.edit(deleted ? "delete page" : "restore page", () => {
|
|
this.pages.get(uid).deleted = !!deleted;
|
|
// Never leave nothing behind — a zero-page PDF cannot be written.
|
|
if (this.visible().length === 0) { this.pages.get(uid).deleted = false; return false; }
|
|
return true;
|
|
});
|
|
}
|
|
restoreAllPages() {
|
|
return this.edit("restore pages", () => {
|
|
for (const uid of this.order) this.pages.get(uid).deleted = false;
|
|
return true;
|
|
});
|
|
}
|
|
/** Move `uid` so it sits immediately before `beforeUid` (or last if null). */
|
|
reorder(uid, beforeUid) {
|
|
if (uid === beforeUid) return false;
|
|
return this.edit("reorder", () => {
|
|
const from = this.order.indexOf(uid);
|
|
if (from < 0) return false;
|
|
this.order.splice(from, 1);
|
|
const at = beforeUid == null ? this.order.length : this.order.indexOf(beforeUid);
|
|
this.order.splice(at < 0 ? this.order.length : at, 0, uid);
|
|
return true;
|
|
});
|
|
}
|
|
}
|