// 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"); } 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; }); } }