theseus/bundled-addons/pdf-editor/editor.js
Local Dev 6b2c4b25c0 feat(pdf-editor): read, mark up and reshape a PDF without leaving the browser
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).
2026-09-20 20:58:21 +02:00

707 lines
27 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";
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(); },
openTextModal: 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;
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 {}
}
for (const o of document.querySelectorAll(".tool")) o.classList.toggle("active", o === b);
session.tools?.setTool(t);
status(TOOL_HINTS[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 it, Delete to remove it.",
highlight: "Drag across text to highlight it.",
pen: "Drag to draw.",
rect: "Drag to draw a rectangle.",
ellipse: "Drag to draw an ellipse.",
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);
});
}
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();
session.tools.selected = null;
session.strip.refreshAllOverlays(null);
paintChrome();
paintPageInfo();
}
$("zoom").addEventListener("change", (e) => {
const v = e.target.value;
session.strip?.setScaleMode(/^[\d.]+$/.test(v) ? parseFloat(v) : v);
paintPageInfo();
});
$("zoom-in").addEventListener("click", () => { session.strip?.stepScale(1); syncZoomSelect(); });
$("zoom-out").addEventListener("click", () => { session.strip?.stepScale(-1); syncZoomSelect(); });
function syncZoomSelect() {
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();
}, 140);
});
// ---- text stamp -------------------------------------------------------
async function onTextModal(at) {
$("text-body").value = "";
$("text-warn").hidden = true;
const p = openModal("modal-text");
setTimeout(() => $("text-body").focus(), 30);
const ok = await p;
if (!ok) return null;
const text = $("text-body").value;
const size = parseFloat($("text-size").value) || 12;
return session.tools.placeText(at, text, size);
}
$("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", p: "pen", r: "rect", o: "ellipse", 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() === "z") {
e.preventDefault();
if (e.shiftKey) session.model?.redo(); else session.model?.undo();
afterHistory();
return;
}
if (typing) return;
if (e.key === "Escape") {
if (session.tools?.tool !== "select") {
document.querySelector('.tool[data-tool="select"]')?.click();
e.preventDefault();
}
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
if (session.tools?.deleteSelected()) { paintChrome(); 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();
}
})();