A PDF that needs a signature, a highlight or a page removed currently sends the user out to a desktop application or, worse, to a web service that wants the document uploaded first. Both are poor answers for a browser whose point is that nothing has to leave the machine. This is a full-tab editor that opens a PDF, marks it up, fills its forms and saves a new copy, entirely locally. Two engines, vendored rather than installed, because an add-on ships as a self-contained folder over the signed update channel and nothing runs a package manager on the way: pdf.js reads and renders, pdf-lib writes. They share no state. Everything in between lives in PDF user space — points, origin bottom-left — which is the one coordinate vocabulary both speak, so a mark survives zooming, rotating and reordering with no conversion table and save-time needs to know nothing about how a page happened to be displayed. The page strip is built from pdf.js's PDFPageView components rather than its PDFViewer, which renders pages in the file's own order and cannot hide, reorder or individually rotate one — three of the features here. Text layers are ours and stay attached for every page, drawn or not, because Theseus's find bar is Chromium's findInPage over the live DOM and a torn-down text layer is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100% is 3.4 MB of bitmap. Redaction is the part worth being careful about. A black box over text hides nothing — the text stays in the content stream and comes straight out of a copy-paste — so the editor says so in a modal before the tool can be used, and on save rebuilds each redacted page as an image, which genuinely removes it. Pages that were not redacted are untouched. Form widgets and links are kept, since they were never the leak. Saving never writes over the original: every save reloads the source bytes and replays the session onto a fresh copy, so a botched save cannot poison the next one. Out of scope for this first version: editing the text that is already in the document, and writing XFA forms back (pdf-lib cannot, so those are fill-and- print only, and the editor says so on open).
287 lines
11 KiB
JavaScript
287 lines
11 KiB
JavaScript
// Pointer behaviour for every tool.
|
|
//
|
|
// The editor is modal: one tool is armed, and what a drag does depends only on
|
|
// which. The alternative — inferring intent from what is under the cursor — is
|
|
// what makes PDF annotators infuriating, because a drag meant to draw a box
|
|
// selects a paragraph instead. Modality is enforced in CSS (see the
|
|
// body[data-mode] rules): with a drawing tool armed, the text and form layers
|
|
// stop taking pointer events entirely, so a drag cannot be stolen.
|
|
//
|
|
// Highlight is the deliberate exception. It works BY selecting text — the
|
|
// text layer stays live, the browser does the selection, and on release we
|
|
// convert the selection's client rects into user space. Rebuilding text
|
|
// selection from scratch over pdf.js's span soup would be worse at it.
|
|
|
|
import { boundsOf, normBox, translatePatch } from "./shape.js";
|
|
|
|
const MIN_DRAG = 3; // CSS px before a press counts as a drag
|
|
const SIG_WIDTH_PT = 170; // default placed width of a signature
|
|
|
|
export class Tools {
|
|
constructor({ strip, model, host }) {
|
|
this.strip = strip;
|
|
this.model = model;
|
|
this.host = host; // editor.js: {toast, openTextModal, openSignaturePad, signature(), onChange}
|
|
this.tool = "select";
|
|
this.color = "#ffd400";
|
|
this.width = 2.5;
|
|
this.selected = null;
|
|
this.drag = null;
|
|
// Opening a second document builds a second Tools over the same container.
|
|
// Without a way to unbind, the first one stays subscribed, keeps pointing
|
|
// at a torn-down strip, and throws on the next click — so every listener
|
|
// goes on one signal and destroy() drops the lot.
|
|
this._abort = new AbortController();
|
|
const opts = { signal: this._abort.signal };
|
|
const c = strip.container;
|
|
c.addEventListener("pointerdown", (e) => this.onDown(e), opts);
|
|
c.addEventListener("pointermove", (e) => this.onMove(e), opts);
|
|
window.addEventListener("pointerup", (e) => this.onUp(e), opts);
|
|
}
|
|
|
|
destroy() {
|
|
this._abort.abort();
|
|
this.drag = null;
|
|
this.selected = null;
|
|
}
|
|
|
|
get mode() {
|
|
if (this.tool === "select") return "select";
|
|
if (this.tool === "highlight") return "highlight";
|
|
if (this.tool === "text" || this.tool === "signature") return "stamp";
|
|
return "draw";
|
|
}
|
|
|
|
setTool(t) {
|
|
this.tool = t;
|
|
this.selected = null;
|
|
document.body.dataset.mode = this.mode;
|
|
this.strip.refreshAllOverlays(null);
|
|
this.host.onChange?.();
|
|
}
|
|
setColor(c) { this.color = c; if (this.selected) this._recolorSelected(c); }
|
|
setWidth(w) { this.width = w; }
|
|
|
|
_recolorSelected(c) {
|
|
const a = this.model.annot(this.selected);
|
|
if (!a || a.kind === "redact") return;
|
|
this.model.moveAnnot(a.id, { color: c });
|
|
this.strip.refreshAllOverlays(this.selected);
|
|
}
|
|
|
|
deleteSelected() {
|
|
if (!this.selected) return false;
|
|
const ok = this.model.removeAnnot(this.selected);
|
|
this.selected = null;
|
|
this.strip.refreshAllOverlays(null);
|
|
return ok;
|
|
}
|
|
|
|
// ---- pointer ------------------------------------------------------
|
|
onDown(e) {
|
|
if (e.button !== 0) return;
|
|
const hit = this.strip.pageAt(e.clientX, e.clientY);
|
|
if (!hit) return;
|
|
const ov = this.strip.overlayFor(hit.uid);
|
|
if (!ov || !ov.viewport) return;
|
|
|
|
if (this.tool === "highlight") { this._selDirty = false; return; } // let the browser select
|
|
|
|
const [x, y] = ov.toPdf(hit.vx, hit.vy);
|
|
|
|
if (this.tool === "select") {
|
|
const id = e.target?.closest?.(".mark")?.dataset?.id || null;
|
|
this.selected = id;
|
|
this.strip.refreshAllOverlays(id);
|
|
if (id) {
|
|
const a = this.model.annot(id);
|
|
this.drag = { kind: "move", uid: hit.uid, id, x0: x, y0: y, orig: structuredClone(a), moved: false };
|
|
e.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (this.tool === "text") {
|
|
this.host.openTextModal({ uid: hit.uid, x, y });
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (this.tool === "signature") {
|
|
this._placeSignature(hit.uid, x, y);
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
// Drawing tools: start a drag with a live preview.
|
|
this.drag = { kind: this.tool, uid: hit.uid, x0: x, y0: y, x, y,
|
|
pts: this.tool === "pen" ? [[x, y]] : null,
|
|
cx: e.clientX, cy: e.clientY, moved: false };
|
|
e.preventDefault();
|
|
}
|
|
|
|
onMove(e) {
|
|
const d = this.drag;
|
|
if (!d) return;
|
|
const ov = this.strip.overlayFor(d.uid);
|
|
if (!ov || !ov.viewport) return;
|
|
const view = this.strip.views.get(d.uid);
|
|
if (!view) { this.drag = null; return; }
|
|
const rect = view.pv.div.getBoundingClientRect();
|
|
// A drag that wanders off the page stays on it. Marks outside the page
|
|
// box are invisible in any PDF reader, so letting one be drawn there
|
|
// would only produce something that disappears on save.
|
|
const cx = Math.min(Math.max(e.clientX, rect.left), rect.right);
|
|
const cy = Math.min(Math.max(e.clientY, rect.top), rect.bottom);
|
|
const [x, y] = ov.toPdf(cx - rect.left, cy - rect.top);
|
|
d.x = x; d.y = y;
|
|
if (!d.moved && Math.hypot(e.clientX - (d.cx ?? e.clientX), e.clientY - (d.cy ?? e.clientY)) > MIN_DRAG) d.moved = true;
|
|
|
|
if (d.kind === "move") {
|
|
const a = this.model.annot(d.id);
|
|
if (!a) return;
|
|
const patch = translatePatch(d.orig, x - d.x0, y - d.y0);
|
|
Object.assign(a, patch); // live, un-journalled; committed on up
|
|
d.moved = true;
|
|
this.strip.refreshOverlay(d.uid, d.id);
|
|
return;
|
|
}
|
|
if (d.kind === "pen") { d.pts.push([x, y]); }
|
|
this.strip.setPreview(d.uid, this._previewAnnot(d));
|
|
}
|
|
|
|
onUp(e) {
|
|
if (this.tool === "highlight") { this._commitHighlight(); return; }
|
|
const d = this.drag;
|
|
this.drag = null;
|
|
if (!d) return;
|
|
this.strip.setPreview(null, null);
|
|
|
|
if (d.kind === "move") {
|
|
const a = this.model.annot(d.id);
|
|
if (!a || !d.moved) { this.strip.refreshOverlay(d.uid, this.selected); return; }
|
|
// Put the mark back where it started, then move it through the model so
|
|
// the whole gesture lands on the undo stack as one step.
|
|
const moved = structuredClone(a);
|
|
Object.assign(a, d.orig);
|
|
const patch = {};
|
|
for (const k of Object.keys(moved)) if (k !== "id" && k !== "page" && k !== "kind") patch[k] = moved[k];
|
|
this.model.moveAnnot(d.id, patch);
|
|
this.strip.refreshAllOverlays(d.id);
|
|
return;
|
|
}
|
|
|
|
const a = this._previewAnnot(d, true);
|
|
if (a) {
|
|
const made = this.model.addAnnot(a);
|
|
this.selected = this.tool === "select" ? made.id : null;
|
|
}
|
|
this.strip.refreshAllOverlays(this.selected);
|
|
this.host.onChange?.();
|
|
}
|
|
|
|
// The mark a drag currently describes. `final` rejects degenerate ones so a
|
|
// stray click does not leave an invisible zero-size rectangle behind.
|
|
_previewAnnot(d, final = false) {
|
|
const common = { page: d.uid, color: this.color, width: this.width };
|
|
switch (d.kind) {
|
|
case "pen": {
|
|
if (final && d.pts.length < 2) return null;
|
|
return { kind: "pen", ...common, pts: d.pts.slice() };
|
|
}
|
|
case "arrow": {
|
|
if (final && !d.moved) return null;
|
|
return { kind: "arrow", ...common, x1: d.x0, y1: d.y0, x2: d.x, y2: d.y };
|
|
}
|
|
case "rect": case "ellipse": case "redact": {
|
|
const b = normBox(d.x0, d.y0, d.x, d.y);
|
|
if (final && (b.w < 2 || b.h < 2)) return null;
|
|
if (d.kind === "redact") return { kind: "redact", page: d.uid, ...b };
|
|
return { kind: d.kind, ...common, ...b };
|
|
}
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
// ---- highlight ----------------------------------------------------
|
|
// Take the live selection's client rects, drop them onto whichever page
|
|
// each one sits on, and convert to user space. A selection spanning a page
|
|
// break therefore produces one highlight mark per page, which is also what
|
|
// the PDF format wants.
|
|
_commitHighlight() {
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
|
|
const byPage = new Map();
|
|
for (let i = 0; i < sel.rangeCount; i++) {
|
|
for (const cr of sel.getRangeAt(i).getClientRects()) {
|
|
if (cr.width < 0.5 || cr.height < 0.5) continue;
|
|
const hit = this.strip.pageAt(cr.left + cr.width / 2, cr.top + cr.height / 2);
|
|
if (!hit) continue;
|
|
const ov = this.strip.overlayFor(hit.uid);
|
|
if (!ov || !ov.viewport) continue;
|
|
const r = this.strip.views.get(hit.uid).pv.div.getBoundingClientRect();
|
|
const [ax, ay] = ov.toPdf(cr.left - r.left, cr.top - r.top);
|
|
const [bx, by] = ov.toPdf(cr.right - r.left, cr.bottom - r.top);
|
|
const box = normBox(ax, ay, bx, by);
|
|
if (box.w < 0.5 || box.h < 0.5) continue;
|
|
if (!byPage.has(hit.uid)) byPage.set(hit.uid, []);
|
|
byPage.get(hit.uid).push(box);
|
|
}
|
|
}
|
|
if (!byPage.size) return;
|
|
for (const [uid, rects] of byPage) {
|
|
this.model.addAnnot({ kind: "highlight", page: uid, color: this.color, opacity: 0.38, rects: mergeRows(rects) });
|
|
}
|
|
sel.removeAllRanges();
|
|
this.strip.refreshAllOverlays(null);
|
|
this.host.onChange?.();
|
|
}
|
|
|
|
// ---- stamps -------------------------------------------------------
|
|
placeText({ uid, x, y }, text, size) {
|
|
if (!String(text || "").trim()) return null;
|
|
const a = this.model.addAnnot({ kind: "text", page: uid, color: this.color, size, x, y, text });
|
|
this.strip.refreshAllOverlays(a.id);
|
|
this.selected = a.id;
|
|
this.host.onChange?.();
|
|
return a;
|
|
}
|
|
|
|
async _placeSignature(uid, x, y) {
|
|
let sig = this.host.signature();
|
|
if (!sig) {
|
|
sig = await this.host.openSignaturePad();
|
|
if (!sig) return;
|
|
}
|
|
const w = SIG_WIDTH_PT;
|
|
const h = w * (sig.aspect || 0.34);
|
|
const a = this.model.addAnnot({
|
|
kind: "signature", page: uid, color: this.color === "#ffd400" ? "#1a1f2b" : this.color,
|
|
width: 1.6, x, y: y - h, w, h, strokes: sig.strokes,
|
|
});
|
|
this.selected = a.id;
|
|
this.strip.refreshAllOverlays(a.id);
|
|
this.host.onChange?.();
|
|
}
|
|
|
|
/** Bounds of the current selection, for the status line. */
|
|
selectedBounds() {
|
|
const a = this.model.annot(this.selected);
|
|
return a ? boundsOf(a) : null;
|
|
}
|
|
}
|
|
|
|
// Client rects for a text selection come one per text run, so a single
|
|
// highlighted line arrives as a dozen slivers. Merging the ones that share a
|
|
// baseline turns them back into one band per line — fewer objects in the saved
|
|
// file, and no seams where two slivers overlap at 38% opacity.
|
|
function mergeRows(rects) {
|
|
const rows = [];
|
|
for (const r of rects.slice().sort((a, b) => b.y - a.y || a.x - b.x)) {
|
|
const row = rows.find((q) => Math.abs((q.y + q.h / 2) - (r.y + r.h / 2)) < Math.min(q.h, r.h) * 0.5);
|
|
if (!row) { rows.push({ ...r }); continue; }
|
|
const x0 = Math.min(row.x, r.x), x1 = Math.max(row.x + row.w, r.x + r.w);
|
|
const y0 = Math.min(row.y, r.y), y1 = Math.max(row.y + row.h, r.y + r.h);
|
|
row.x = x0; row.w = x1 - x0; row.y = y0; row.h = y1 - y0;
|
|
}
|
|
return rows;
|
|
}
|