theseus/bundled-addons/pdf-editor/lib/model.js
Local Dev 62460cd3aa feat(pdf-editor): select an area, and treat what it caught as one thing
Drag on empty page and a rubber band gathers every mark it sweeps over.
Touching counts rather than enclosing: a band you have to draw right
around a long arrow is a band you draw twice. Shift-click adds or removes
one, Ctrl+A takes the page.

What the group can then do is move, restyle, duplicate and delete, each as
a single undo step — six marks deleted is one thing the user did, so it
has to be one thing to undo. The selection bar offers only what is true of
every member: a group of shapes gets Fill, a group of stamps gets size and
weight, a mixed group gets neither, and none of them gets Edit, which
needs one mark to put a caret in.

No resize handles on a group. Stretching a mixed selection means deciding
what a stretch does to text, which scales by font size, and to a line,
which has no box at all; until there is an answer worth defending,
offering the grip would promise something this cannot keep.

Each member is outlined as well as the group, because a band that caught
one more mark than you meant is worth seeing before you press Delete.

A group lives on one page. A mark carries its page, and PDF user space
means nothing across two of them, so a band selects within the page it
was drawn on.

One trap found on the way: starting the band on a press means calling
preventDefault, which is also what gives a form field its caret — so the
band now keeps its hands off the annotation layer, and typing into a PDF
form still works.
2026-09-27 13:45:04 +02:00

227 lines
8.7 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, () => this.pushAnnot(a));
}
/**
* Add a mark WITHOUT journalling it.
*
* Only for callers already inside an `edit()` — duplicating six marks is
* one action and has to be one step, not six.
*/
pushAnnot(a) {
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; });
}
// ---- several at once ----------------------------------------------
// One undo step for the whole group. Selecting six marks and pressing Del
// is one thing the user did, so it has to be one thing to undo.
removeAnnots(ids) {
const set = new Set(ids);
const hit = this.annots.filter((a) => set.has(a.id));
if (!hit.length) return false;
return this.edit(`delete ${hit.length} marks`, () => {
this.annots = this.annots.filter((a) => !set.has(a.id));
return true;
});
}
/**
* Apply a patch to several marks.
*
* `patch` may be a plain object, or a function of the mark — a move needs
* the second form, because every mark shifts from its own position.
*/
moveAnnots(ids, patch) {
const set = new Set(ids);
const hit = this.annots.filter((a) => set.has(a.id));
if (!hit.length) return false;
return this.edit(`change ${hit.length} marks`, () => {
for (const a of hit) Object.assign(a, typeof patch === "function" ? patch(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;
});
}
}