Everything the editor put on a page was final. A text stamp could not be corrected without deleting it and typing it again, nothing could be resized, and the only way to remove a mark was a Delete key nobody had been told about — the selection drew a dashed box and offered no action at all. Placing a stamp also left its tool armed, so the next click stamped a second copy. Marks are now editable objects. Selecting one gives it grab handles and a small bar pinned above it: delete and duplicate for anything, and for text an edit button, a size stepper and bold and italic. Double-clicking text reopens it for rewriting in place rather than adding a second one. Placing a text stamp or a signature drops straight back to the select tool with the new mark live, which is both what people expect and what puts it immediately within reach of a nudge. Resizing is one function over every mark type rather than a special case per kind: a handle drag produces a new bounding box, and the mark is mapped from its old box into that one. Text scales by font size instead of stretching its glyphs, signatures keep their aspect on a corner, and lines offer their two endpoints instead of a box that would let you stretch them in ways you never aimed at. A whole gesture lands on the undo stack as one step. Selecting a thin mark used to mean clicking its outline exactly — about one screen pixel. Each stroked mark now carries an invisible fat copy of itself purely to catch the pointer. New marks to go with it: underline and strike-through, which share the highlight's text-selection geometry and differ only in where the rule sits; a plain line; and a fill toggle for rectangles and ellipses. Bold and italic mean three more Helvetica variants embedded at save time, since a PDF treats them as separate fonts rather than as a style. Double-click is detected from the pointer stream rather than from a dblclick listener, because selecting a mark calls preventDefault() on the pointerdown and that suppresses the compatibility mouse events the browser would have synthesised the dblclick from.
867 lines
35 KiB
JavaScript
867 lines
35 KiB
JavaScript
// PDF Editor — the page. A full Theseus tab, opened through the "open-tab"
|
|
// capability, talking to the add-on's Node half over window.silentmode.
|
|
//
|
|
// Boot order matters: pdf.js's viewer components are published as a bundle
|
|
// that reads its API off globalThis.pdfjsLib rather than importing it, so the
|
|
// core has to be imported and parked on the global BEFORE the components
|
|
// bundle is pulled in.
|
|
//
|
|
// Find is deliberately absent from this file. Theseus captures Ctrl+F at the
|
|
// app level (main.js's before-input-event handler) and drives Chromium's own
|
|
// findInPage over the tab — so the browser's find bar already searches this
|
|
// document, with the count and next/previous the user knows from every other
|
|
// page. A second find UI inside the tab would be the worse one. What this file
|
|
// owes that arrangement is a text layer for every page, drawn or not, which is
|
|
// why lib/view.js keeps its own (see the comment there).
|
|
|
|
import { DocModel } from "./lib/model.js";
|
|
import { PageStrip } from "./lib/view.js";
|
|
import { Rail } from "./lib/rail.js";
|
|
import { Tools } from "./lib/tools.js";
|
|
import { SignaturePad } from "./lib/signature.js";
|
|
import { buildPdf, unencodableStamps } from "./lib/save.js";
|
|
import { boundsOf, clampFont } from "./lib/shape.js";
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const sm = window.silentmode || null;
|
|
|
|
const FLATTEN_DPI = 150; // raster resolution when rebuilding a redacted page
|
|
|
|
// ---- boot pdf.js ------------------------------------------------------
|
|
let pdfjsLib = null;
|
|
async function bootPdfjs() {
|
|
if (pdfjsLib) return pdfjsLib;
|
|
const core = await import("./vendor/pdfjs/pdf.min.mjs");
|
|
core.GlobalWorkerOptions.workerSrc = new URL("./vendor/pdfjs/pdf.worker.min.mjs", import.meta.url).href;
|
|
// pdf_viewer.mjs is built with the core as an external and reads it off the
|
|
// global at module-evaluation time, so this assignment has to happen first.
|
|
globalThis.pdfjsLib = core;
|
|
const viewer = await import("./vendor/pdfjs/web/pdf_viewer.mjs");
|
|
// A module namespace object is frozen, so the components go on a wrapper
|
|
// rather than onto `core` itself.
|
|
pdfjsLib = {
|
|
getDocument: core.getDocument,
|
|
TextLayer: core.TextLayer,
|
|
setLayerDimensions: core.setLayerDimensions,
|
|
AnnotationMode: core.AnnotationMode,
|
|
viewer,
|
|
};
|
|
return pdfjsLib;
|
|
}
|
|
|
|
// ---- session ----------------------------------------------------------
|
|
const session = {
|
|
bytes: null, // Uint8Array, the untouched original
|
|
pdf: null, // pdf.js PDFDocumentProxy
|
|
model: null,
|
|
strip: null,
|
|
rail: null,
|
|
tools: null,
|
|
scratchId: null, // set when the document came from the add-on's scratch dir
|
|
name: "document.pdf",
|
|
};
|
|
|
|
// ---- chrome -----------------------------------------------------------
|
|
function toast(msg, err) {
|
|
const t = $("toast");
|
|
t.textContent = msg;
|
|
t.classList.toggle("err", !!err);
|
|
t.classList.add("on");
|
|
clearTimeout(toast._t);
|
|
toast._t = setTimeout(() => t.classList.remove("on"), err ? 4200 : 2000);
|
|
}
|
|
function status(msg, err) {
|
|
const s = $("status");
|
|
s.textContent = msg;
|
|
s.classList.toggle("err", !!err);
|
|
}
|
|
function setBusy(on, why) {
|
|
document.body.dataset.busy = on ? "1" : "";
|
|
if (on && why) status(why);
|
|
}
|
|
|
|
function paintChrome() {
|
|
const m = session.model;
|
|
$("save").disabled = !m;
|
|
$("undo").disabled = !m || !m.canUndo();
|
|
$("redo").disabled = !m || !m.canRedo();
|
|
const nameEl = $("docname");
|
|
nameEl.textContent = session.name || "No document";
|
|
nameEl.classList.toggle("dirty", !!m && m.isDirty());
|
|
document.body.dataset.empty = m ? "" : "1";
|
|
}
|
|
|
|
function paintPageInfo() {
|
|
const m = session.model, strip = session.strip;
|
|
if (!m || !strip) { $("pageinfo").textContent = ""; return; }
|
|
const vis = m.visible();
|
|
const at = strip.current ? vis.indexOf(strip.current) + 1 : 1;
|
|
const pct = Math.round(strip.scale * 100);
|
|
$("pageinfo").textContent = `Page ${at} of ${vis.length} · ${pct}%`;
|
|
}
|
|
|
|
// ---- modals -----------------------------------------------------------
|
|
let modalResolve = null;
|
|
function openModal(id) {
|
|
for (const m of document.querySelectorAll(".modal")) m.hidden = true;
|
|
$(id).hidden = false;
|
|
$("scrim").hidden = false;
|
|
return new Promise((res) => { modalResolve = res; });
|
|
}
|
|
function closeModal(value) {
|
|
$("scrim").hidden = true;
|
|
for (const m of document.querySelectorAll(".modal")) m.hidden = true;
|
|
const r = modalResolve; modalResolve = null;
|
|
r?.(value);
|
|
}
|
|
$("scrim").addEventListener("pointerdown", (e) => { if (e.target === $("scrim")) closeModal(null); });
|
|
|
|
// ---- loading ----------------------------------------------------------
|
|
async function loadBytes(bytes, name, scratchId = null) {
|
|
if (session.pdf) await teardown();
|
|
// pdf.js TRANSFERS the buffer it is given to its worker, which detaches our
|
|
// view of it — and pdf-lib needs those same bytes at save time. Keep the
|
|
// original and hand the worker a copy.
|
|
session.bytes = bytes;
|
|
session.name = name;
|
|
session.scratchId = scratchId;
|
|
setBusy(true, "Opening…");
|
|
const lib = await bootPdfjs();
|
|
const task = lib.getDocument({
|
|
data: bytes.slice(),
|
|
standardFontDataUrl: "vendor/pdfjs/standard_fonts/",
|
|
wasmUrl: "vendor/pdfjs/wasm/",
|
|
iccUrl: "vendor/pdfjs/iccs/",
|
|
// XFA forms render best-effort. pdf-lib cannot write XFA back, so a
|
|
// dynamic XFA form is view-and-print only here; see the warning below.
|
|
enableXfa: true,
|
|
enableScripting: false,
|
|
isEvalSupported: false,
|
|
});
|
|
let pdf;
|
|
try { pdf = await task.promise; }
|
|
catch (e) {
|
|
setBusy(false);
|
|
status(`Could not open ${name}: ${e?.message || e}`, true);
|
|
toast(`Could not open that PDF: ${e?.message || e}`, true);
|
|
return false;
|
|
}
|
|
session.pdf = pdf;
|
|
|
|
const pageInfo = [];
|
|
for (let i = 1; i <= pdf.numPages; i++) {
|
|
const p = await pdf.getPage(i);
|
|
const vp = p.getViewport({ scale: 1, rotation: 0 });
|
|
pageInfo.push({ width: vp.width, height: vp.height, rotate: p.rotate });
|
|
}
|
|
session.model = new DocModel({ name, pageCount: pdf.numPages, pageInfo });
|
|
|
|
const eventBus = new lib.viewer.EventBus();
|
|
const linkService = new lib.viewer.PDFLinkService({
|
|
eventBus, externalLinkTarget: lib.viewer.LinkTarget.BLANK,
|
|
});
|
|
const layerProperties = {
|
|
annotationEditorUIManager: null,
|
|
annotationStorage: pdf.annotationStorage,
|
|
downloadManager: null,
|
|
enableScripting: false,
|
|
fieldObjectsPromise: pdf.getFieldObjects().catch(() => null),
|
|
findController: null,
|
|
hasJSActionsPromise: pdf.hasJSActions().catch(() => false),
|
|
linkService,
|
|
};
|
|
|
|
session.strip = new PageStrip({
|
|
container: $("viewerContainer"), viewer: $("viewer"),
|
|
pdfjsLib: lib, pdf, model: session.model, eventBus, layerProperties,
|
|
});
|
|
await session.strip.build();
|
|
|
|
// A shim is all PDFLinkService needs to make a PDF's own table-of-contents
|
|
// links jump to the right page.
|
|
linkService.setViewer(linkShim(session.strip, session.model));
|
|
linkService.setDocument(pdf, null);
|
|
|
|
session.rail = new Rail({
|
|
list: $("rail-list"), countEl: $("rail-count"),
|
|
model: session.model, strip: session.strip,
|
|
host: { rotate: onRotate, toggleDelete: onToggleDelete, reorder: onReorder },
|
|
});
|
|
session.rail.build();
|
|
|
|
session.tools = new Tools({
|
|
strip: session.strip, model: session.model,
|
|
host: {
|
|
toast,
|
|
onChange: () => { paintChrome(); },
|
|
selectTool,
|
|
onSelectionChange: paintSelectionBar,
|
|
editText: onTextModal,
|
|
openSignaturePad: onSignaturePad,
|
|
signature: () => savedSignature,
|
|
},
|
|
});
|
|
session.tools.setTool("select");
|
|
|
|
session.strip.onCurrentChange((uid) => { session.rail.markCurrent(uid); paintPageInfo(); });
|
|
session.model.onChange(() => { paintChrome(); });
|
|
|
|
setBusy(false);
|
|
const forms = await countFields(pdf);
|
|
const notes = [`${pdf.numPages} page${pdf.numPages === 1 ? "" : "s"}`];
|
|
if (forms.fields) notes.push(`${forms.fields} form field${forms.fields === 1 ? "" : "s"}`);
|
|
if (forms.xfa) notes.push("XFA form — fields can be filled here but cannot be saved back");
|
|
status(notes.join(" · "), !!forms.xfa);
|
|
paintChrome();
|
|
paintPageInfo();
|
|
return true;
|
|
}
|
|
|
|
async function countFields(pdf) {
|
|
let fields = 0, xfa = false;
|
|
try {
|
|
const fo = await pdf.getFieldObjects();
|
|
if (fo) for (const v of fo.values()) fields += v.length;
|
|
} catch {}
|
|
try { xfa = !!(await pdf.getMetadata())?.info?.IsXFAPresent; } catch {}
|
|
return { fields, xfa };
|
|
}
|
|
|
|
function linkShim(strip, model) {
|
|
return {
|
|
get pagesCount() { return model.visible().length; },
|
|
get currentPageNumber() { return Math.max(1, model.visible().indexOf(strip.current) + 1); },
|
|
set currentPageNumber(n) { const uid = model.visible()[n - 1]; if (uid) strip.scrollTo(uid); },
|
|
get currentScaleValue() { return strip.scaleMode; },
|
|
set currentScaleValue(v) { strip.setScaleMode(Number.isFinite(+v) ? +v : v); },
|
|
get currentScale() { return strip.scale; },
|
|
get isInPresentationMode() { return false; },
|
|
scrollPageIntoView({ pageNumber }) {
|
|
// pageNumber is a SOURCE page number; map it through our order so a link
|
|
// still lands on the right page after a reorder.
|
|
const uid = model.order.find((u) => model.page(u).src === pageNumber - 1);
|
|
if (uid && !model.page(uid).deleted) strip.scrollTo(uid);
|
|
},
|
|
get pageViewsReady() { return true; },
|
|
getPageView(i) { const uid = model.visible()[i]; return uid ? strip.views.get(uid)?.pv : null; },
|
|
};
|
|
}
|
|
|
|
async function teardown() {
|
|
// Tools first: it is the one holding listeners on the container, and a stale
|
|
// one would go on handling clicks against a strip that no longer exists.
|
|
try { session.tools?.destroy(); } catch {}
|
|
try { session.strip?.destroy(); } catch {}
|
|
try { session.rail?.destroy(); } catch {}
|
|
try { await session.pdf?.destroy(); } catch {}
|
|
session.pdf = null; session.model = null; session.strip = null;
|
|
session.rail = null; session.tools = null;
|
|
$("viewer").textContent = "";
|
|
}
|
|
|
|
// ---- open paths -------------------------------------------------------
|
|
async function openFile(file) {
|
|
if (!file) return;
|
|
const looksPdf = /\.pdf$/i.test(file.name) || file.type === "application/pdf";
|
|
if (!looksPdf) { toast(`${file.name} is not a PDF`, true); return; }
|
|
if (!(await confirmDiscard())) return;
|
|
const buf = new Uint8Array(await file.arrayBuffer());
|
|
if (buf.subarray(0, 5).every((b, i) => b === "%PDF-".charCodeAt(i)) === false) {
|
|
toast("That file does not start with a PDF header", true);
|
|
return;
|
|
}
|
|
await loadBytes(buf, file.name);
|
|
}
|
|
|
|
$("file-input").addEventListener("change", (e) => {
|
|
const f = e.target.files?.[0];
|
|
e.target.value = "";
|
|
openFile(f);
|
|
});
|
|
const pick = () => $("file-input").click();
|
|
$("open").addEventListener("click", pick);
|
|
$("open2").addEventListener("click", pick);
|
|
|
|
// Drag and drop anywhere in the tab.
|
|
for (const type of ["dragenter", "dragover"]) {
|
|
window.addEventListener(type, (e) => {
|
|
if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "copy";
|
|
document.body.dataset.dragover = "1";
|
|
});
|
|
}
|
|
window.addEventListener("dragleave", (e) => {
|
|
if (e.relatedTarget) return; // still inside the window
|
|
document.body.dataset.dragover = "";
|
|
});
|
|
window.addEventListener("drop", (e) => {
|
|
if (![...(e.dataTransfer?.types || [])].includes("Files")) return;
|
|
e.preventDefault();
|
|
document.body.dataset.dragover = "";
|
|
openFile(e.dataTransfer.files?.[0]);
|
|
});
|
|
|
|
// ---- page operations --------------------------------------------------
|
|
async function onRotate(uid) {
|
|
session.model.rotatePage(uid, 90);
|
|
session.strip.layout();
|
|
await session.strip.redraw(uid);
|
|
session.rail.refresh(uid);
|
|
paintChrome();
|
|
}
|
|
function onToggleDelete(uid) {
|
|
const was = session.model.page(uid).deleted;
|
|
const ok = session.model.setDeleted(uid, !was);
|
|
if (!ok && !was) { toast("A PDF needs at least one page", true); return; }
|
|
session.strip.layout();
|
|
session.rail.build();
|
|
paintChrome();
|
|
paintPageInfo();
|
|
}
|
|
function onReorder(uid, beforeUid) {
|
|
session.model.reorder(uid, beforeUid);
|
|
session.strip.layout();
|
|
session.rail.build();
|
|
paintChrome();
|
|
paintPageInfo();
|
|
}
|
|
$("restore-pages").addEventListener("click", () => {
|
|
session.model.restoreAllPages();
|
|
session.strip.layout();
|
|
session.rail.build();
|
|
paintChrome();
|
|
});
|
|
|
|
// ---- toolbar ----------------------------------------------------------
|
|
let redactAcknowledged = false;
|
|
let flattenRedactions = true;
|
|
|
|
/**
|
|
* Arm a tool and sync the toolbar. Tools calls this itself after placing a
|
|
* stamp, so the button highlight and the armed tool cannot drift apart.
|
|
*/
|
|
function selectTool(t) {
|
|
for (const o of document.querySelectorAll(".tool")) o.classList.toggle("active", o.dataset.tool === t);
|
|
session.tools?.setTool(t);
|
|
status(TOOL_HINTS[t] || "");
|
|
}
|
|
|
|
for (const b of document.querySelectorAll(".tool[data-tool]")) {
|
|
b.addEventListener("click", async () => {
|
|
const t = b.dataset.tool;
|
|
if (t === "redact" && !redactAcknowledged) {
|
|
const ok = await openModal("modal-redact");
|
|
if (!ok) return;
|
|
redactAcknowledged = true;
|
|
flattenRedactions = $("redact-flatten").checked;
|
|
try { await sm?.storage?.set("redactAcknowledged", true); } catch {}
|
|
}
|
|
selectTool(t);
|
|
});
|
|
}
|
|
$("redact-ok").addEventListener("click", () => closeModal(true));
|
|
$("redact-cancel").addEventListener("click", () => closeModal(null));
|
|
|
|
const TOOL_HINTS = {
|
|
select: "Click a mark to select it. Drag to move, drag a handle to resize, double-click text to edit.",
|
|
highlight: "Drag across text to highlight it.",
|
|
underline: "Drag across text to underline it.",
|
|
strikeout: "Drag across text to strike it through.",
|
|
pen: "Drag to draw.",
|
|
rect: "Drag to draw a rectangle.",
|
|
ellipse: "Drag to draw an ellipse.",
|
|
line: "Drag to draw a line.",
|
|
arrow: "Drag from tail to head.",
|
|
text: "Click where the text should start.",
|
|
signature: "Click to place your signature.",
|
|
redact: "Drag a box over what has to go. Redacted pages are rebuilt as images on save.",
|
|
};
|
|
|
|
for (const s of document.querySelectorAll(".swatch")) {
|
|
s.addEventListener("click", () => {
|
|
for (const o of document.querySelectorAll(".swatch")) o.classList.toggle("active", o === s);
|
|
session.tools?.setColor(s.dataset.color);
|
|
});
|
|
}
|
|
const fillBtn = $("toggle-fill");
|
|
if (fillBtn) fillBtn.addEventListener("click", () => {
|
|
const on = !fillBtn.classList.contains("active");
|
|
fillBtn.classList.toggle("active", on);
|
|
session.tools?.setFill(on);
|
|
});
|
|
|
|
for (const w of document.querySelectorAll(".width")) {
|
|
w.addEventListener("click", () => {
|
|
for (const o of document.querySelectorAll(".width")) o.classList.toggle("active", o === w);
|
|
session.tools?.setWidth(parseFloat(w.dataset.width));
|
|
});
|
|
}
|
|
|
|
$("undo").addEventListener("click", () => { session.model?.undo(); afterHistory(); });
|
|
$("redo").addEventListener("click", () => { session.model?.redo(); afterHistory(); });
|
|
function afterHistory() {
|
|
if (!session.model) return;
|
|
session.strip.layout();
|
|
session.rail.build();
|
|
// Through select() rather than by assignment, so the selection bar hears
|
|
// about it and does not hang over a mark that undo just removed.
|
|
session.tools.select(null);
|
|
paintChrome();
|
|
paintPageInfo();
|
|
}
|
|
|
|
$("zoom").addEventListener("change", (e) => {
|
|
const v = e.target.value;
|
|
session.strip?.setScaleMode(/^[\d.]+$/.test(v) ? parseFloat(v) : v);
|
|
paintPageInfo();
|
|
positionSelectionBar();
|
|
});
|
|
$("zoom-in").addEventListener("click", () => { session.strip?.stepScale(1); syncZoomSelect(); });
|
|
$("zoom-out").addEventListener("click", () => { session.strip?.stepScale(-1); syncZoomSelect(); });
|
|
function syncZoomSelect() {
|
|
positionSelectionBar();
|
|
if (!session.strip) return;
|
|
const exact = String(session.strip.scale);
|
|
const opt = [...$("zoom").options].find((o) => o.value === exact);
|
|
$("zoom").value = opt ? exact : $("zoom").value;
|
|
paintPageInfo();
|
|
}
|
|
$("toggle-rail").addEventListener("click", () => {
|
|
const off = document.body.dataset.rail === "off";
|
|
document.body.dataset.rail = off ? "" : "off";
|
|
if (off) session.rail?.build();
|
|
session.strip?.computeScale();
|
|
session.strip?.layout();
|
|
paintPageInfo();
|
|
});
|
|
|
|
let resizeT = null;
|
|
window.addEventListener("resize", () => {
|
|
clearTimeout(resizeT);
|
|
resizeT = setTimeout(() => {
|
|
if (!session.strip) return;
|
|
if (typeof session.strip.scaleMode === "string") { session.strip.computeScale(); session.strip.layout(); }
|
|
paintPageInfo();
|
|
positionSelectionBar();
|
|
}, 140);
|
|
});
|
|
|
|
// ---- selection bar ----------------------------------------------------
|
|
//
|
|
// A floating strip pinned to whatever is selected, carrying the actions that
|
|
// only make sense for that mark: edit and type controls for text, fill for a
|
|
// shape, and duplicate/delete for anything. Position is fixed rather than
|
|
// parented into the page, so it is never clipped by a page edge and never has
|
|
// to be re-homed when the selection moves to another page.
|
|
//
|
|
// It exists because Delete-on-the-keyboard was the only way to remove a mark,
|
|
// which is undiscoverable: nothing on screen said a selected mark could go.
|
|
|
|
const selbar = $("selbar");
|
|
|
|
function paintSelectionBar(id) {
|
|
const a = id && session.model ? session.model.annot(id) : null;
|
|
if (!a) { selbar.hidden = true; return; }
|
|
const isText = a.kind === "text";
|
|
const isShape = a.kind === "rect" || a.kind === "ellipse";
|
|
$("sb-edit").hidden = !isText;
|
|
$("sb-textsize").hidden = !isText;
|
|
$("sb-bold").hidden = !isText;
|
|
$("sb-italic").hidden = !isText;
|
|
$("sb-fill").hidden = !isShape;
|
|
if (isText) {
|
|
$("sb-size").textContent = String(Math.round(a.size || 12));
|
|
$("sb-bold").classList.toggle("active", !!a.bold);
|
|
$("sb-italic").classList.toggle("active", !!a.italic);
|
|
}
|
|
if (isShape) $("sb-fill").classList.toggle("active", !!a.fill);
|
|
selbar.hidden = false;
|
|
positionSelectionBar();
|
|
}
|
|
|
|
function positionSelectionBar() {
|
|
const t = session.tools, m = session.model, strip = session.strip;
|
|
const a = t && m ? m.annot(t.selected) : null;
|
|
if (!a || selbar.hidden) return;
|
|
const ov = strip?.overlayFor(a.page);
|
|
const view = strip?.views.get(a.page);
|
|
if (!ov?.viewport || !view) { selbar.hidden = true; return; }
|
|
const b = boundsOf(a);
|
|
const [ax, ay] = ov.toView(b.x, b.y + b.h);
|
|
const [bx, by] = ov.toView(b.x + b.w, b.y);
|
|
const pr = view.pv.div.getBoundingClientRect();
|
|
const left = pr.left + Math.min(ax, bx);
|
|
const right = pr.left + Math.max(ax, bx);
|
|
const top = pr.top + Math.min(ay, by);
|
|
const bottom = pr.top + Math.max(ay, by);
|
|
|
|
const box = selbar.getBoundingClientRect();
|
|
const cr = $("viewerContainer").getBoundingClientRect();
|
|
const GAP = 10;
|
|
// Above the mark by preference; below when there is no room up there.
|
|
let y = top - box.height - GAP;
|
|
if (y < cr.top + 4) y = Math.min(bottom + GAP, cr.bottom - box.height - 4);
|
|
let x = (left + right) / 2 - box.width / 2;
|
|
x = Math.min(Math.max(x, cr.left + 4), cr.right - box.width - 4);
|
|
selbar.style.left = `${Math.round(x)}px`;
|
|
selbar.style.top = `${Math.round(y)}px`;
|
|
// A selection scrolled out of the viewport takes its bar with it.
|
|
selbar.classList.toggle("offscreen", bottom < cr.top || top > cr.bottom);
|
|
}
|
|
|
|
// The bar would jitter under the cursor mid-gesture, so it steps aside while
|
|
// a drag is in flight and comes back where the mark ended up.
|
|
$("viewerContainer").addEventListener("pointerdown", () => { document.body.dataset.dragging = "1"; });
|
|
window.addEventListener("pointerup", () => {
|
|
document.body.dataset.dragging = "";
|
|
positionSelectionBar();
|
|
});
|
|
$("viewerContainer").addEventListener("scroll", () => positionSelectionBar(), { passive: true });
|
|
|
|
$("sb-edit").addEventListener("click", () => {
|
|
const id = session.tools?.selected;
|
|
if (id) onTextModal(id, null);
|
|
});
|
|
$("sb-del").addEventListener("click", () => { session.tools?.deleteSelected(); paintChrome(); });
|
|
$("sb-dup").addEventListener("click", () => { session.tools?.duplicateSelected(); paintChrome(); });
|
|
$("sb-bold").addEventListener("click", () => toggleTextStyle("bold"));
|
|
$("sb-italic").addEventListener("click", () => toggleTextStyle("italic"));
|
|
$("sb-fill").addEventListener("click", () => {
|
|
const a = session.model?.annot(session.tools?.selected);
|
|
if (a) session.tools.setFill(!a.fill);
|
|
});
|
|
$("sb-smaller").addEventListener("click", () => stepTextSize(-1));
|
|
$("sb-bigger").addEventListener("click", () => stepTextSize(1));
|
|
|
|
function toggleTextStyle(which) {
|
|
const a = session.model?.annot(session.tools?.selected);
|
|
if (!a || a.kind !== "text") return;
|
|
delete a._w; // the glyphs change width; re-measure
|
|
session.tools.patchSelected({ [which]: !a[which] });
|
|
}
|
|
function stepTextSize(dir) {
|
|
const a = session.model?.annot(session.tools?.selected);
|
|
if (!a || a.kind !== "text") return;
|
|
const steps = [6, 8, 9, 10, 11, 12, 14, 16, 18, 24, 30, 36, 48, 60, 72, 96];
|
|
const at = a.size || 12;
|
|
const next = dir > 0 ? steps.find((v) => v > at + 0.01) : [...steps].reverse().find((v) => v < at - 0.01);
|
|
if (next == null) return;
|
|
delete a._w;
|
|
session.tools.patchSelected({ size: clampFont(next) });
|
|
}
|
|
|
|
// ---- text stamp -------------------------------------------------------
|
|
/**
|
|
* One dialog for both jobs. `id` set means rewrite that mark in place, which
|
|
* is what a double-click on placed text opens; `at` set means place a new one.
|
|
*/
|
|
async function onTextModal(id, at) {
|
|
const existing = id ? session.model?.annot(id) : null;
|
|
$("modal-text-title").textContent = existing ? "Edit text" : "Text stamp";
|
|
$("text-body").value = existing ? existing.text : "";
|
|
$("text-size").value = String(existing ? existing.size || 12 : 12);
|
|
$("text-bold").classList.toggle("active", !!existing?.bold);
|
|
$("text-italic").classList.toggle("active", !!existing?.italic);
|
|
$("text-warn").hidden = true;
|
|
const p = openModal("modal-text");
|
|
setTimeout(() => { $("text-body").focus(); $("text-body").select(); }, 30);
|
|
const ok = await p;
|
|
if (!ok) return null;
|
|
const props = {
|
|
text: $("text-body").value,
|
|
size: parseFloat($("text-size").value) || 12,
|
|
bold: $("text-bold").classList.contains("active"),
|
|
italic: $("text-italic").classList.contains("active"),
|
|
};
|
|
return existing ? session.tools.updateText(id, props) : session.tools.placeText(at, props);
|
|
}
|
|
for (const b of [$("text-bold"), $("text-italic")]) {
|
|
b?.addEventListener("click", () => b.classList.toggle("active"));
|
|
}
|
|
$("text-ok").addEventListener("click", () => closeModal(true));
|
|
$("text-cancel").addEventListener("click", () => closeModal(null));
|
|
$("text-body").addEventListener("input", () => {
|
|
const n = unencodableStampsIn($("text-body").value);
|
|
const w = $("text-warn");
|
|
w.hidden = n === 0;
|
|
w.classList.add("warn");
|
|
if (n) w.textContent = `${n} character${n === 1 ? "" : "s"} cannot be written with the built-in font and will be saved as "?".`;
|
|
});
|
|
function unencodableStampsIn(text) {
|
|
// Same WinAnsi test the writer uses, applied live so the user finds out
|
|
// before placing the stamp rather than after saving.
|
|
let n = 0;
|
|
for (const ch of String(text)) {
|
|
const code = ch.codePointAt(0);
|
|
const ok = (code >= 0x20 && code <= 0x7e) || (code >= 0xa0 && code <= 0xff) ||
|
|
[0x2018, 0x2019, 0x201c, 0x201d, 0x2013, 0x2014, 0x2022, 0x20ac, 0x2026, 0x2122].includes(code) ||
|
|
ch === "\n";
|
|
if (!ok) n++;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// ---- signature --------------------------------------------------------
|
|
let savedSignature = null;
|
|
let pad = null;
|
|
async function onSignaturePad() {
|
|
if (!pad) {
|
|
pad = new SignaturePad($("sign-pad"));
|
|
pad.onChange((has) => { $("sign-ok").disabled = !has; });
|
|
}
|
|
pad.clear();
|
|
const ok = await openModal("modal-sign");
|
|
if (!ok) return null;
|
|
const res = pad.result();
|
|
if (!res) return null;
|
|
savedSignature = res;
|
|
try { await sm?.storage?.set("signature", res); } catch {}
|
|
return res;
|
|
}
|
|
$("sign-ok").addEventListener("click", () => closeModal(true));
|
|
$("sign-cancel").addEventListener("click", () => closeModal(null));
|
|
$("sign-clear").addEventListener("click", () => pad?.clear());
|
|
|
|
// ---- save -------------------------------------------------------------
|
|
function defaultSaveName() {
|
|
const base = String(session.name || "document.pdf").replace(/\.pdf$/i, "");
|
|
return `${base}-edited.pdf`;
|
|
}
|
|
|
|
/**
|
|
* Everything the user typed into a form field, keyed by field NAME.
|
|
*
|
|
* Driven from the document's field list rather than from the storage's
|
|
* contents. pdf.js keys its annotation storage by annotation id ("11R"), which
|
|
* means nothing to pdf-lib — pdf-lib addresses fields by name — so the field
|
|
* list is needed for the mapping either way, and iterating it also keeps
|
|
* pdf.js's own editor bookkeeping out of the results for free.
|
|
*/
|
|
async function harvestFormValues(pdf) {
|
|
const out = [];
|
|
let fields = null;
|
|
try { fields = await pdf.getFieldObjects(); } catch { return out; }
|
|
if (!fields) return out;
|
|
const storage = pdf.annotationStorage;
|
|
for (const [name, objs] of fields) {
|
|
for (const o of objs || []) {
|
|
// `has` distinguishes a field the user touched from one they left alone;
|
|
// writing back every field would rewrite appearances across the whole
|
|
// document for no reason.
|
|
if (!o?.id || !storage.has(o.id)) continue;
|
|
const entry = storage.getRawValue(o.id);
|
|
if (!entry || typeof entry !== "object" || !("value" in entry)) continue;
|
|
out.push({ name, value: entry.value });
|
|
break; // a field's kids (radio buttons) share one value
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Render one page to a PNG at FLATTEN_DPI, unrotated, for the flatten pass. */
|
|
async function rasterizePage(uid) {
|
|
const entry = session.strip.views.get(uid);
|
|
const scale = FLATTEN_DPI / 72;
|
|
// rotation 0, so the image lands in the page's own unrotated coordinate
|
|
// space — the page's /Rotate then presents it exactly as before.
|
|
const vp = entry.pdfPage.getViewport({ scale, rotation: 0 });
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = Math.max(1, Math.ceil(vp.width));
|
|
canvas.height = Math.max(1, Math.ceil(vp.height));
|
|
const ctx = canvas.getContext("2d", { alpha: false });
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
await entry.pdfPage.render({ canvasContext: ctx, viewport: vp, intent: "print" }).promise;
|
|
const blob = await new Promise((res, rej) =>
|
|
canvas.toBlob((b) => b ? res(b) : rej(new Error("toBlob returned null")), "image/png"));
|
|
return new Uint8Array(await blob.arrayBuffer());
|
|
}
|
|
|
|
async function doSave() {
|
|
const m = session.model;
|
|
if (!m) return;
|
|
$("save-name").value = defaultSaveName();
|
|
const summary = $("save-summary");
|
|
summary.textContent = "";
|
|
const lines = [];
|
|
if (m.annots.length) lines.push([`${m.annots.length} mark${m.annots.length === 1 ? "" : "s"} written into the file`, false]);
|
|
const formValues = await harvestFormValues(session.pdf);
|
|
if (formValues.length) lines.push([`${formValues.length} form field${formValues.length === 1 ? "" : "s"} filled in`, false]);
|
|
if (m.deletedCount()) lines.push([`${m.deletedCount()} page${m.deletedCount() === 1 ? "" : "s"} removed`, false]);
|
|
if (m.pagesChanged() && !m.deletedCount()) lines.push(["pages reordered", false]);
|
|
if (m.rotationsChanged()) lines.push(["page rotation changed", false]);
|
|
const red = m.redactedPages();
|
|
if (red.size) {
|
|
lines.push([flattenRedactions
|
|
? `${red.size} redacted page${red.size === 1 ? "" : "s"} rebuilt as an image, so the text under the boxes is gone`
|
|
: `${red.size} redacted page${red.size === 1 ? "" : "s"} — the text under the boxes stays extractable`,
|
|
!flattenRedactions]);
|
|
}
|
|
const bad = unencodableStamps(m);
|
|
if (bad) lines.push([`${bad} text stamp${bad === 1 ? "" : "s"} contain characters the built-in font cannot write`, true]);
|
|
if (!lines.length) lines.push(["no changes — this saves a copy of the original", false]);
|
|
for (const [text, warn] of lines) {
|
|
const li = document.createElement("li");
|
|
li.textContent = text;
|
|
if (warn) li.className = "warn";
|
|
summary.append(li);
|
|
}
|
|
|
|
const go = await openModal("modal-save");
|
|
if (!go) return;
|
|
let filename = String($("save-name").value || "").trim() || defaultSaveName();
|
|
if (!/\.pdf$/i.test(filename)) filename += ".pdf";
|
|
|
|
setBusy(true, "Writing…");
|
|
try {
|
|
const { bytes, report } = await buildPdf({
|
|
originalBytes: session.bytes,
|
|
model: m,
|
|
formValues,
|
|
flatten: flattenRedactions,
|
|
rasterize: rasterizePage,
|
|
});
|
|
const blob = new Blob([bytes], { type: "application/pdf" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = $("download-link");
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 8000);
|
|
m.markSaved();
|
|
paintChrome();
|
|
const kb = (bytes.length / 1024).toFixed(0);
|
|
status(`Saved ${filename} · ${kb} KB${report.flattened ? ` · ${report.flattened} page(s) rebuilt as images` : ""}`);
|
|
toast(`Saved ${filename} (${kb} KB)`);
|
|
for (const w of report.warn) toast(w, true);
|
|
} catch (e) {
|
|
console.error("save failed:", e);
|
|
status(`Save failed: ${e?.message || e}`, true);
|
|
toast(`Save failed: ${e?.message || e}`, true);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
$("save").addEventListener("click", doSave);
|
|
$("save-go").addEventListener("click", () => closeModal(true));
|
|
$("save-cancel").addEventListener("click", () => closeModal(null));
|
|
|
|
// ---- discard / close --------------------------------------------------
|
|
async function confirmDiscard() {
|
|
if (!session.model || !session.model.isDirty()) return true;
|
|
// Deliberately the browser's own confirm(): it is modal against the tab, it
|
|
// cannot be dismissed by a stray click the way the in-page scrim can, and
|
|
// losing an hour of markup to a misplaced click is not a risk worth a
|
|
// prettier dialog.
|
|
return window.confirm("This document has unsaved changes. Discard them?");
|
|
}
|
|
$("discard").addEventListener("click", async () => {
|
|
if (!(await confirmDiscard())) return;
|
|
if (session.scratchId && sm?.invoke) {
|
|
try { await sm.invoke("release", { id: session.scratchId }); } catch {}
|
|
}
|
|
try { sm?.closeTab?.(); } catch {}
|
|
});
|
|
// Belt and braces for a tab closed from the tab strip rather than our button.
|
|
window.addEventListener("beforeunload", (e) => {
|
|
if (session.model?.isDirty()) { e.preventDefault(); e.returnValue = ""; }
|
|
});
|
|
|
|
// A link inside a PDF must not navigate this tab — that would drop the whole
|
|
// editing session on the floor. Hand it to the add-on, which opens a new tab.
|
|
$("viewerContainer").addEventListener("click", (e) => {
|
|
const a = e.target?.closest?.("a[href]");
|
|
if (!a) return;
|
|
const href = a.getAttribute("href") || "";
|
|
if (!/^https?:/i.test(href)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
sm?.invoke?.("openExternal", { url: href })
|
|
.catch((err) => toast(`Could not open that link: ${err?.message || err}`, true));
|
|
}, true);
|
|
|
|
// ---- keyboard ---------------------------------------------------------
|
|
// Ctrl+F, Ctrl+B and Ctrl +/-/0 never reach this page — Theseus claims them
|
|
// app-wide for find, the add-on sidebar and tab zoom. PDF zoom therefore lives
|
|
// on bare +/-/0, which is what desktop readers use anyway.
|
|
const TOOL_KEYS = {
|
|
v: "select", h: "highlight", u: "underline", k: "strikeout", p: "pen",
|
|
r: "rect", o: "ellipse", l: "line", a: "arrow", t: "text", g: "signature",
|
|
};
|
|
window.addEventListener("keydown", (e) => {
|
|
// A modal owns the keyboard while it is up: Escape cancels, and Enter
|
|
// commits unless the focus is in the textarea, where it means a newline.
|
|
if (!$("scrim").hidden) {
|
|
if (e.key === "Escape") { closeModal(null); e.preventDefault(); return; }
|
|
if (e.key === "Enter" && e.target?.tagName !== "TEXTAREA" && !e.shiftKey) {
|
|
const go = document.querySelector(".modal:not([hidden]) .btn.primary:not(:disabled)");
|
|
if (go) { go.click(); e.preventDefault(); }
|
|
}
|
|
return;
|
|
}
|
|
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target?.tagName || "") || e.target?.isContentEditable;
|
|
const meta = e.ctrlKey || e.metaKey;
|
|
|
|
if (meta && e.key.toLowerCase() === "s") { e.preventDefault(); doSave(); return; }
|
|
if (meta && e.key.toLowerCase() === "o") { e.preventDefault(); pick(); return; }
|
|
if (meta && e.key.toLowerCase() === "d") {
|
|
e.preventDefault();
|
|
if (session.tools?.duplicateSelected()) paintChrome();
|
|
return;
|
|
}
|
|
if (meta && e.key.toLowerCase() === "z") {
|
|
e.preventDefault();
|
|
if (e.shiftKey) session.model?.redo(); else session.model?.undo();
|
|
afterHistory();
|
|
return;
|
|
}
|
|
if (typing) return;
|
|
|
|
if (e.key === "Escape") {
|
|
// Step back one level at a time: drop the selection first, and only then
|
|
// disarm the tool. Doing both at once loses whichever the user meant.
|
|
if (session.tools?.selected) { session.tools.select(null); e.preventDefault(); return; }
|
|
if (session.tools?.tool !== "select") { selectTool("select"); e.preventDefault(); }
|
|
return;
|
|
}
|
|
if (e.key === "Delete" || e.key === "Backspace") {
|
|
if (session.tools?.deleteSelected()) { paintChrome(); e.preventDefault(); }
|
|
return;
|
|
}
|
|
if (e.key === "Enter") {
|
|
const sel = session.tools?.selected;
|
|
const a = sel && session.model?.annot(sel);
|
|
if (a?.kind === "text") { onTextModal(sel, null); e.preventDefault(); }
|
|
return;
|
|
}
|
|
if (e.key === "+" || e.key === "=") { session.strip?.stepScale(1); syncZoomSelect(); e.preventDefault(); return; }
|
|
if (e.key === "-") { session.strip?.stepScale(-1); syncZoomSelect(); e.preventDefault(); return; }
|
|
if (e.key === "0") { $("zoom").value = "page-width"; session.strip?.setScaleMode("page-width"); paintPageInfo(); e.preventDefault(); return; }
|
|
|
|
const t = TOOL_KEYS[e.key.toLowerCase()];
|
|
if (t) { document.querySelector(`.tool[data-tool="${t}"]`)?.click(); e.preventDefault(); }
|
|
});
|
|
|
|
// ---- start ------------------------------------------------------------
|
|
(async function start() {
|
|
try {
|
|
redactAcknowledged = (await sm?.storage?.get("redactAcknowledged", false)) === true;
|
|
const sig = await sm?.storage?.get("signature", null);
|
|
if (sig && Array.isArray(sig.strokes)) savedSignature = sig;
|
|
} catch {}
|
|
|
|
const id = new URLSearchParams(location.search).get("doc");
|
|
if (!id) { status("Drop a PDF here, or use Open."); paintChrome(); return; }
|
|
if (!sm?.invoke) { status("This page has to be opened from the PDF Editor add-on.", true); return; }
|
|
setBusy(true, "Fetching…");
|
|
try {
|
|
const res = await sm.invoke("getBytes", { id });
|
|
const bytes = res.data instanceof Uint8Array ? res.data : new Uint8Array(res.data);
|
|
await loadBytes(bytes, res.name || "document.pdf", id);
|
|
} catch (e) {
|
|
setBusy(false);
|
|
status(`Could not load that document: ${e?.message || e}`, true);
|
|
paintChrome();
|
|
}
|
|
})();
|