// 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(); } })();