189 lines
7.2 KiB
JavaScript
189 lines
7.2 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"); }
|
||
|
|
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;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|