feat(pdf-editor): a mark you placed is something you can still work on
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.
2026-09-21 03:13:06 +02:00
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
})();
|