Three complaints, one cause between the first two. A line of a PDF is rarely one run. pdf.js splits it wherever the file does — a font change, a kerning adjustment, a colour change — so a heading can be three spans and an invoice line ten. Replacing the span under the cursor covered a fragment and left the rest of the line standing, which is exactly what a replacement that looks like a copy laid over the original is. A run is now the whole visual line: the spans that share its baseline and sit close enough to be spacing rather than a second column, with the spaces the geometry implies put back between them. And that line is edited on the page. The dialog that used to hold a copy of the words is gone: the cover goes down first, carrying the line's own words at the page's own size and colour, and the caret opens on it. The cover keeps its words hidden while you type, so the original never shows through the thing covering it. Escape with nothing changed lifts the cover again and leaves the page as it was found — no mark, no undo step. The rotate grip was a square like the resize handles, wearing the open hand that means drag-the-page. It is a disc with a turning arrow now, and a cursor drawn to match, since no standard cursor means turn. The inline style that was defeating the stylesheet is gone with it. Ctrl and the wheel zoom, about the pointer rather than the top-left, so the words you were reading stay where they were. A plain wheel still scrolls.
1640 lines
68 KiB
JavaScript
1640 lines
68 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 { InlineText } from "./lib/inline.js";
|
|
import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
|
|
import { ASCENT, boundsOf, clampFont, translatePatch, unionOf } from "./lib/shape.js";
|
|
import { reflow } from "./lib/reflow.js";
|
|
import { buildDocx } from "./lib/docx.js";
|
|
import { imagesToPdf, mergePdfs } from "./lib/create.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: null, // null until something is loaded; the chrome says so
|
|
};
|
|
|
|
// ---- 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;
|
|
$("convert").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,
|
|
beginText,
|
|
editRun: onEditRun,
|
|
chooseSignature,
|
|
signature: () => savedSignature,
|
|
rotating: (deg) => status(`${Math.round(deg)}° — hold Shift to snap to 15°`),
|
|
},
|
|
});
|
|
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 -------------------------------------------------------
|
|
// A tab that can be given files three ways — the dock, a button, a drop — has
|
|
// to decide what a handful of files MEANS before it can act. One PDF is a
|
|
// document to edit. Several are a document to build. Pictures are pages.
|
|
const isPdfFile = (f) => /\.pdf$/i.test(f.name) || f.type === "application/pdf";
|
|
const isImageFile = (f) => /^image\//.test(f.type) || /\.(png|jpe?g|webp|gif|bmp|avif|tiff?)$/i.test(f.name);
|
|
|
|
async function openFile(file) {
|
|
if (!file) return;
|
|
if (!isPdfFile(file)) { 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);
|
|
}
|
|
|
|
async function openFiles(list) {
|
|
const files = [...(list || [])];
|
|
if (!files.length) return;
|
|
if (files.length === 1 && isPdfFile(files[0])) return openFile(files[0]);
|
|
if (files.every(isImageFile)) return fromImages(files);
|
|
if (files.every(isPdfFile)) return joinPdfs(files);
|
|
const odd = files.find((f) => !isPdfFile(f) && !isImageFile(f));
|
|
toast(odd ? `${odd.name} is neither a PDF nor a picture` : "Drop PDFs or pictures, not both at once", true);
|
|
}
|
|
|
|
/**
|
|
* Pictures in, one page each.
|
|
*
|
|
* The result is a document that exists only here until it is saved, so it is
|
|
* marked unsaved the moment it opens — otherwise closing the tab would throw
|
|
* away work that never touched a disk, without asking.
|
|
*/
|
|
// Building a document takes a while — reading a dozen photos, then opening
|
|
// the result — and a second one started meanwhile tears down the first
|
|
// halfway through. One at a time.
|
|
let building = false;
|
|
|
|
async function fromImages(files) {
|
|
if (building) { toast("Still building the last one — one at a time", true); return; }
|
|
if (!(await confirmDiscard())) return;
|
|
building = true;
|
|
try { await fromImagesInner(files); } finally { building = false; }
|
|
}
|
|
async function fromImagesInner(files) {
|
|
$("images-count").textContent =
|
|
`${files.length} picture${files.length === 1 ? "" : "s"}, one per page, in the order your file browser listed them.`;
|
|
syncImagesDialog();
|
|
if (!(await openModal("modal-images"))) return;
|
|
const page = $("images-page").value;
|
|
const margin = Number($("images-margin").value) || 0;
|
|
setBusy(true, "Building…");
|
|
let out;
|
|
try {
|
|
out = await imagesToPdf(files, {
|
|
page, margin,
|
|
onProgress: (i, n) => setBusy(true, `Adding picture ${i} of ${n}…`),
|
|
});
|
|
} catch (e) {
|
|
setBusy(false);
|
|
status(`Could not build a PDF: ${e?.message || e}`, true);
|
|
toast(`Could not build a PDF: ${e?.message || e}`, true);
|
|
return;
|
|
}
|
|
if (!(await loadBytes(new Uint8Array(out.bytes), "pictures.pdf"))) return;
|
|
afterBuild(`${out.pages} page${out.pages === 1 ? "" : "s"} from your pictures`, out.failed);
|
|
}
|
|
|
|
/**
|
|
* Several PDFs, end to end. Order is the order they were handed over; the
|
|
* page rail is where it gets fixed, which is the honest answer to "can I
|
|
* reorder them" — yes, once they are one document.
|
|
*/
|
|
async function joinPdfs(files) {
|
|
if (building) { toast("Still building the last one — one at a time", true); return; }
|
|
if (!(await confirmDiscard())) return;
|
|
building = true;
|
|
try { await joinPdfsInner(files); } finally { building = false; }
|
|
}
|
|
async function joinPdfsInner(files) {
|
|
setBusy(true, "Joining…");
|
|
let out;
|
|
try {
|
|
out = await mergePdfs(files, { onProgress: (i, n) => setBusy(true, `Reading file ${i} of ${n}…`) });
|
|
} catch (e) {
|
|
setBusy(false);
|
|
status(`Could not join those files: ${e?.message || e}`, true);
|
|
toast(`Could not join those files: ${e?.message || e}`, true);
|
|
return;
|
|
}
|
|
if (!(await loadBytes(new Uint8Array(out.bytes), "joined.pdf"))) return;
|
|
const from = out.parts.map((p) => `${p.name} (${p.pages})`).join(", ");
|
|
afterBuild(`${out.pages} pages from ${out.parts.length} files · ${from}`, out.failed);
|
|
if (out.hadForms) {
|
|
toast("Form fields do not survive joining — the pages kept their look, not their fields", true);
|
|
}
|
|
}
|
|
|
|
// Shared tail for the two build paths: say where the document came from, say
|
|
// that it is not on disk yet, and name anything that had to be skipped.
|
|
function afterBuild(what, failed) {
|
|
// A second build started while this one was still opening would have torn
|
|
// the first document down between the load returning and this running.
|
|
// `building` below makes that impossible; this stays because being wrong
|
|
// about it should not throw on a page the user is still looking at.
|
|
if (!session.model) return;
|
|
session.model.markUnsaved();
|
|
paintChrome();
|
|
status(`${what} · not saved yet — use Save a copy`);
|
|
if (failed && failed.length) {
|
|
toast(`Skipped ${failed.length} file${failed.length === 1 ? "" : "s"}: ${failed.map((f) => f.name).join(", ")}`, true);
|
|
}
|
|
}
|
|
|
|
// A page shaped like its picture has no border to set, so the field goes away
|
|
// rather than sitting there doing nothing.
|
|
function syncImagesDialog() {
|
|
const fit = $("images-page").value === "fit";
|
|
$("images-margin-field").hidden = fit;
|
|
$("images-note").textContent = fit
|
|
? "Each page takes the shape of its picture, edge to edge, sized so the long side matches A4."
|
|
: "Each picture is centred on the page, scaled to fit inside the border. Wide pictures turn the page on its side.";
|
|
}
|
|
$("images-page").addEventListener("change", syncImagesDialog);
|
|
$("images-go").addEventListener("click", () => closeModal(true));
|
|
$("images-cancel").addEventListener("click", () => closeModal(null));
|
|
|
|
$("file-input").addEventListener("change", (e) => {
|
|
const f = e.target.files?.[0];
|
|
e.target.value = "";
|
|
openFile(f);
|
|
});
|
|
for (const [inputId, handler] of [["img-input", fromImages], ["merge-input", joinPdfs]]) {
|
|
$(inputId).addEventListener("change", (e) => {
|
|
const files = [...(e.target.files || [])];
|
|
e.target.value = "";
|
|
if (files.length) handler(files);
|
|
});
|
|
}
|
|
const pick = () => $("file-input").click();
|
|
$("open").addEventListener("click", pick);
|
|
$("start-open").addEventListener("click", pick);
|
|
$("start-images").addEventListener("click", () => $("img-input").click());
|
|
$("start-merge").addEventListener("click", () => $("merge-input").click());
|
|
|
|
// 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 = "";
|
|
openFiles(e.dataTransfer.files);
|
|
});
|
|
|
|
// ---- 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;
|
|
// Which signature is settled when the tool is picked up, so placing it
|
|
// is one click per place — and so there is somewhere to add, rename or
|
|
// redraw one, which a tool that silently reused the last had nowhere for.
|
|
if (t === "signature") {
|
|
selectTool(t);
|
|
const sig = await chooseSignature();
|
|
if (!sig) { selectTool("select"); return; }
|
|
status(`Click to place "${sig.name || "your signature"}".`);
|
|
return;
|
|
}
|
|
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, or drag an area around several — Alt-drag to start one on top of text. Shift-click adds one, Ctrl+A takes the page.",
|
|
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 the document's own words to replace them, or anywhere else to type new text.",
|
|
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(); });
|
|
|
|
/**
|
|
* Ctrl/⌘ + wheel zooms, and keeps the page under the pointer under it.
|
|
*
|
|
* Plain wheel still scrolls, because that is what a wheel does in every
|
|
* reader. Without `passive: false` the preventDefault is ignored and the
|
|
* browser zooms the whole tab instead of the document — which is why this
|
|
* listener is on the container and not on the window.
|
|
*
|
|
* Zooming about the pointer rather than the top-left is the part that makes
|
|
* it usable: the words you were reading stay where they were instead of
|
|
* sliding off the screen as the page grows.
|
|
*/
|
|
$("viewerContainer").addEventListener("wheel", (e) => {
|
|
if (!(e.ctrlKey || e.metaKey) || !session.strip) return;
|
|
e.preventDefault();
|
|
const strip = session.strip;
|
|
const before = strip.scale;
|
|
strip.stepScale(e.deltaY < 0 ? 1 : -1);
|
|
const after = strip.scale;
|
|
if (after === before) return;
|
|
const c = $("viewerContainer");
|
|
const r = c.getBoundingClientRect();
|
|
const k = after / before;
|
|
// The point under the cursor, in content coordinates, has to land back
|
|
// under the cursor once everything has grown by k.
|
|
const px = c.scrollLeft + (e.clientX - r.left);
|
|
const py = c.scrollTop + (e.clientY - r.top);
|
|
c.scrollLeft = px * k - (e.clientX - r.left);
|
|
c.scrollTop = py * k - (e.clientY - r.top);
|
|
syncZoomSelect();
|
|
}, { passive: false });
|
|
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);
|
|
});
|
|
|
|
// ---- replacing the document's own text --------------------------------
|
|
//
|
|
// The run's size and position come from pdf.js's text layer; its colours come
|
|
// from the rendered page, because nothing in the text API reports them. Both
|
|
// are guesses made from what is actually on screen, which is the same thing
|
|
// the reader is looking at, so a wrong guess is visible immediately rather
|
|
// than only after saving.
|
|
|
|
function hex([r, g, b]) {
|
|
return "#" + [r, g, b].map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
/**
|
|
* The background and ink colours of one text run, read off the rendered page.
|
|
* Background is the most common colour in the run's box — type is a minority
|
|
* of the pixels even when it is dense. Ink is whatever sits furthest from it.
|
|
*/
|
|
function sampleRun(run) {
|
|
const fallback = { bg: "#ffffff", ink: "#000000", sampled: false };
|
|
try {
|
|
const view = session.strip?.views.get(run.uid);
|
|
const canvas = view?.pv?.div?.querySelector(".canvasWrapper canvas");
|
|
if (!canvas || !canvas.width) return fallback;
|
|
const cr = canvas.getBoundingClientRect();
|
|
if (!cr.width || !cr.height) return fallback;
|
|
const sx = canvas.width / cr.width, sy = canvas.height / cr.height;
|
|
const r = run.clientRect;
|
|
const x0 = Math.max(0, Math.floor((r.left - cr.left) * sx));
|
|
const y0 = Math.max(0, Math.floor((r.top - cr.top) * sy));
|
|
const w = Math.min(canvas.width - x0, Math.ceil(r.width * sx));
|
|
const h = Math.min(canvas.height - y0, Math.ceil(r.height * sy));
|
|
if (w < 2 || h < 2) return fallback;
|
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
const data = ctx.getImageData(x0, y0, w, h).data;
|
|
|
|
// Buckets of 8 per channel: exact equality would split an anti-aliased
|
|
// background into hundreds of near-identical shades and no single one
|
|
// would win. The bucket only decides WHICH pixels are the background —
|
|
// the colour itself is their average, because reconstructing it from the
|
|
// bucket index rounds white down to #f8f8f8, and a not-quite-white patch
|
|
// over a white page is a visible seam.
|
|
const buckets = new Map();
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const key = ((data[i] >> 3) << 10) | ((data[i + 1] >> 3) << 5) | (data[i + 2] >> 3);
|
|
let acc = buckets.get(key);
|
|
if (!acc) buckets.set(key, (acc = { n: 0, r: 0, g: 0, b: 0 }));
|
|
acc.n++; acc.r += data[i]; acc.g += data[i + 1]; acc.b += data[i + 2];
|
|
}
|
|
let win = null;
|
|
for (const acc of buckets.values()) if (!win || acc.n > win.n) win = acc;
|
|
if (!win) return fallback;
|
|
const bg = [win.r / win.n, win.g / win.n, win.b / win.n];
|
|
|
|
let ink = [0, 0, 0], far = -1;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const d = Math.abs(data[i] - bg[0]) + Math.abs(data[i + 1] - bg[1]) + Math.abs(data[i + 2] - bg[2]);
|
|
if (d > far) { far = d; ink = [data[i], data[i + 1], data[i + 2]]; }
|
|
}
|
|
// Nothing that reads as ink — a blank strip, or type too faint to trust.
|
|
if (far < 40) return { ...fallback, bg: hex(bg), sampled: true };
|
|
return { bg: hex(bg), ink: hex(ink), sampled: true };
|
|
} catch { return fallback; }
|
|
}
|
|
|
|
/**
|
|
* Edit a line of the document's own text, in place.
|
|
*
|
|
* This used to open a dialog holding a copy of the line. That is the wrong
|
|
* shape for the job twice over: you edit words while looking at a box
|
|
* instead of at the page, and what you are really doing — covering the
|
|
* original and drawing over it — is invisible until you commit, at which
|
|
* point a replacement that does not quite line up reads as a copy pasted on
|
|
* top of the original.
|
|
*
|
|
* So the cover goes down FIRST, carrying the line's own words in the page's
|
|
* own size and colour, and the caret opens on it. Nothing appears to happen
|
|
* except that the line becomes editable, which is what the user asked for.
|
|
* Escape with nothing changed takes the cover away again.
|
|
*/
|
|
function onEditRun(run) {
|
|
const look = sampleRun(run);
|
|
const a = session.tools.replaceRun(run, {
|
|
text: run.text,
|
|
size: run.size,
|
|
color: look.ink,
|
|
cover: look.bg,
|
|
});
|
|
if (!a) return null;
|
|
if (!look.sampled) toast("Could not read the page's colours; used black on white.", true);
|
|
beginText({ id: a.id, fresh: true });
|
|
status(`${run.size.toFixed(1)} pt from the document — the original stays in the file underneath. `
|
|
+ `Escape leaves it as it was.`);
|
|
return a;
|
|
}
|
|
|
|
// ---- 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) {
|
|
if (inline?.isOpen) return; // the caret owns the bar while it is up
|
|
const a = id && session.model ? session.model.annot(id) : null;
|
|
if (!a) { selbar.hidden = true; return; }
|
|
delete selbar.dataset.mode;
|
|
$("sb-dup").hidden = false;
|
|
$("sb-del").hidden = false;
|
|
|
|
// Several at once: offer only what is true of every one of them. A bar
|
|
// showing a size for a group that is half shapes would be lying about what
|
|
// pressing it does.
|
|
const picked = session.tools?.selectedAnnots?.() || [a];
|
|
if (picked.length > 1) {
|
|
const isText = (m) => m.kind === "text" || m.kind === "textedit";
|
|
const allText = picked.every(isText);
|
|
const allShapes = picked.every((m) => m.kind === "rect" || m.kind === "ellipse");
|
|
$("sb-edit").hidden = true; // one caret, one mark
|
|
$("sb-textsize").hidden = !allText;
|
|
$("sb-bold").hidden = !allText;
|
|
$("sb-italic").hidden = !allText;
|
|
$("sb-fill").hidden = !allShapes;
|
|
if (allText) {
|
|
const sizes = new Set(picked.map((m) => Math.round(m.size || 12)));
|
|
$("sb-size").textContent = sizes.size === 1 ? String([...sizes][0]) : "—";
|
|
$("sb-bold").classList.toggle("active", picked.every((m) => m.bold));
|
|
$("sb-italic").classList.toggle("active", picked.every((m) => m.italic));
|
|
}
|
|
if (allShapes) $("sb-fill").classList.toggle("active", picked.every((m) => m.fill));
|
|
selbar.dataset.mode = "group";
|
|
selbar.hidden = false;
|
|
positionSelectionBar();
|
|
status(`${picked.length} marks selected — drag to move them, Del to remove them`);
|
|
return;
|
|
}
|
|
// A replaced run is text too. Leaving it out meant the one mark made of
|
|
// words offered no way to change them — the exact gap this bar exists for.
|
|
const isText = a.kind === "text" || a.kind === "textedit";
|
|
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; }
|
|
// Over the whole group when there is one, so the bar does not cling to
|
|
// whichever mark happens to be the anchor.
|
|
const picked = (t.selectedAnnots?.() || [a]).filter((x) => x.page === a.page);
|
|
const b = picked.length > 1 ? unionOf(picked) : boundsOf(a);
|
|
if (!b) { selbar.hidden = true; return; }
|
|
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;
|
|
// A mark with a rotate grip has something of its own directly above it.
|
|
// Without this the bar lands on the grip and eats the click — which is
|
|
// exactly what it did, silently: the grip was drawn, was in the right
|
|
// place, and could not be grabbed.
|
|
const clear = picked.length === 1 && a.kind === "signature" ? 34 : 0;
|
|
// Above the mark by preference; below when there is no room up there.
|
|
let y = top - box.height - GAP - clear;
|
|
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", () => {
|
|
// The caret is positioned inside the page, so scrolling carries it along;
|
|
// the bar floats over the viewport and has to be told.
|
|
if (inline?.isOpen) positionInlineBar(); else positionSelectionBar();
|
|
}, { passive: true });
|
|
|
|
$("sb-edit").addEventListener("click", () => {
|
|
const id = session.tools?.selected;
|
|
if (!id) return;
|
|
const a = session.model?.annot(id);
|
|
if (a?.kind === "text" || a?.kind === "textedit") beginText({ id });
|
|
});
|
|
// Pressing a style button must not take the caret away: focus leaving the
|
|
// editor is how a click elsewhere finishes the edit, and these are not
|
|
// elsewhere.
|
|
for (const b of selbar.querySelectorAll("button")) {
|
|
b.addEventListener("pointerdown", (e) => { if (inline?.isOpen) e.preventDefault(); });
|
|
}
|
|
$("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));
|
|
|
|
const SIZE_STEPS = [6, 8, 9, 10, 11, 12, 14, 16, 18, 24, 30, 36, 48, 60, 72, 96];
|
|
|
|
function toggleTextStyle(which) {
|
|
// While a caret is open the bar is driving the caret, not a mark.
|
|
if (inline?.isOpen) {
|
|
inline.setStyle({ [which]: !inline.state[which] });
|
|
paintInlineBar();
|
|
return;
|
|
}
|
|
const a = session.model?.annot(session.tools?.selected);
|
|
if (!a || (a.kind !== "text" && a.kind !== "textedit")) return;
|
|
delete a._w; // the glyphs change width; re-measure
|
|
session.tools.patchSelected({ [which]: !a[which] });
|
|
}
|
|
function stepTextSize(dir) {
|
|
const nextSize = (at) => dir > 0
|
|
? SIZE_STEPS.find((v) => v > at + 0.01)
|
|
: [...SIZE_STEPS].reverse().find((v) => v < at - 0.01);
|
|
if (inline?.isOpen) {
|
|
const next = nextSize(inline.state.size);
|
|
if (next == null) return;
|
|
inline.setStyle({ size: clampFont(next) });
|
|
paintInlineBar();
|
|
return;
|
|
}
|
|
const a = session.model?.annot(session.tools?.selected);
|
|
if (!a || (a.kind !== "text" && a.kind !== "textedit")) return;
|
|
const next = nextSize(a.size || 12);
|
|
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.
|
|
*/
|
|
// ---- typing on the page -----------------------------------------------
|
|
let inline = null;
|
|
function inlineEditor() {
|
|
if (!inline) {
|
|
inline = new InlineText({
|
|
strip: session.strip,
|
|
onCommit: (s) => finishInlineText(s),
|
|
onCancel: (s) => {
|
|
// A mark being re-edited was hidden, not removed; put it back as it
|
|
// was. A brand-new one was never added, so there is nothing to undo.
|
|
const a = s.id ? session.model?.annot(s.id) : null;
|
|
if (a) delete a._editing;
|
|
// ...except a cover laid down just to open this caret. Escaping out
|
|
// of it should leave the page exactly as it was found, not leave a
|
|
// replacement of a line by itself.
|
|
if (a && s.fresh) {
|
|
session.tools.select(a.id);
|
|
session.tools.deleteSelected();
|
|
paintChrome();
|
|
return;
|
|
}
|
|
if (a) session.strip?.refreshOverlay(s.uid, s.id);
|
|
paintSelectionBar(session.tools?.selected || null);
|
|
status("");
|
|
},
|
|
onLayout: () => positionInlineBar(),
|
|
});
|
|
}
|
|
inline.strip = session.strip; // a new document brings a new strip
|
|
return inline;
|
|
}
|
|
|
|
/**
|
|
* Start typing at a point on the page, or inside an existing stamp.
|
|
* @param {{uid?:string, x?:number, y?:number, id?:string}} where
|
|
*/
|
|
function beginText(where) {
|
|
if (!session.model) return;
|
|
const ed = inlineEditor();
|
|
const existing = where.id ? session.model.annot(where.id) : null;
|
|
if (where.id && !existing) return;
|
|
// A stamp carries the top of its block; a replaced line carries its
|
|
// baseline, because that is what it has to keep sharing with the line it
|
|
// sits on. The caret wants the top either way.
|
|
const isRun = existing?.kind === "textedit";
|
|
const seed = existing
|
|
? { uid: existing.page, id: existing.id,
|
|
x: existing.x,
|
|
y: isRun ? existing.baseline + (existing.size || 12) * ASCENT : existing.y,
|
|
text: existing.text, size: existing.size || 12,
|
|
bold: !!existing.bold, italic: !!existing.italic, color: existing.color,
|
|
fresh: !!where.fresh, was: existing.text }
|
|
: { uid: where.uid, x: where.x, y: where.y, text: "",
|
|
size: lastTextSize, bold: lastTextBold, italic: lastTextItalic,
|
|
color: session.tools.ink };
|
|
// While it is being typed, the stamp itself is hidden: seeing the old words
|
|
// underneath the new ones reads as a rendering fault. A replaced line keeps
|
|
// its cover — that is what is holding the original out of sight.
|
|
if (existing) { existing._editing = true; session.strip.refreshOverlay(existing.page, existing.id); }
|
|
if (!ed.start(seed)) { if (existing) delete existing._editing; return; }
|
|
paintInlineBar();
|
|
if (!isRun) {
|
|
status(existing ? "Editing text — Escape to leave it as it was." : "Type. Click elsewhere when you are done.");
|
|
}
|
|
}
|
|
|
|
function finishInlineText(s) {
|
|
const model = session.model;
|
|
if (!model) return;
|
|
lastTextSize = s.size; lastTextBold = s.bold; lastTextItalic = s.italic;
|
|
const existing = s.id ? model.annot(s.id) : null;
|
|
if (existing) delete existing._editing;
|
|
const text = String(s.text || "");
|
|
// A cover laid down only to open this caret, closed without a word being
|
|
// changed, is not an edit and should not be one on the undo stack.
|
|
if (existing && s.fresh && text === s.was) {
|
|
session.tools.select(existing.id);
|
|
session.tools.deleteSelected();
|
|
paintChrome();
|
|
status("");
|
|
return;
|
|
}
|
|
if (existing) {
|
|
// updateText deletes the mark when the text is emptied, which is the
|
|
// right reading of "select it all and press delete".
|
|
session.tools.updateText(s.id, { text, size: s.size, bold: s.bold, italic: s.italic });
|
|
warnUnencodable(text);
|
|
} else if (text.trim()) {
|
|
session.tools.placeText({ uid: s.uid, x: s.x, y: s.y },
|
|
{ text, size: s.size, bold: s.bold, italic: s.italic });
|
|
warnUnencodable(text);
|
|
} else {
|
|
// Typed nothing. Nothing was ever added, so nothing has to be taken away.
|
|
session.strip?.refreshOverlay(s.uid, session.tools?.selected || null);
|
|
}
|
|
paintSelectionBar(session.tools?.selected || null);
|
|
status("");
|
|
}
|
|
|
|
// ---- copy and paste ---------------------------------------------------
|
|
// A clipboard of this editor's own, not the system one. What is on the board
|
|
// is a set of marks — geometry, colour, the words in a stamp — and there is
|
|
// no sensible thing to put on the system clipboard that another application
|
|
// could paste. Copying between two PDFs open in two tabs is the obvious
|
|
// next want, and is exactly what this does not do yet.
|
|
let clipboard = [];
|
|
|
|
function copySelection(cut = false) {
|
|
const picked = session.tools?.selectedAnnots?.() || [];
|
|
if (!picked.length) { toast("Nothing selected to copy"); return; }
|
|
clipboard = picked.map((a) => {
|
|
const copy = structuredClone(a);
|
|
delete copy.id; delete copy._w; delete copy._editing;
|
|
return copy;
|
|
});
|
|
if (cut) session.tools.deleteSelected();
|
|
paintChrome();
|
|
toast(`${clipboard.length} mark${clipboard.length === 1 ? "" : "s"} ${cut ? "cut" : "copied"}`);
|
|
}
|
|
|
|
function pasteMarks() {
|
|
if (!session.model) return;
|
|
if (!clipboard.length) { toast("Nothing to paste"); return; }
|
|
// Onto the page being looked at, nudged clear of the original so the copy
|
|
// is visibly a second thing rather than a perfect overlap you cannot grab.
|
|
const uid = session.strip?.current || session.model.visible()[0];
|
|
if (!uid) return;
|
|
const off = 14;
|
|
const made = [];
|
|
session.model.edit(`paste ${clipboard.length} marks`, () => {
|
|
for (const a of clipboard) {
|
|
const copy = { ...structuredClone(a), page: uid };
|
|
Object.assign(copy, translatePatch(copy, off, -off));
|
|
made.push(session.model.pushAnnot(copy));
|
|
}
|
|
return true;
|
|
});
|
|
selectTool("select");
|
|
session.tools.selectMany(made.map((m) => m.id));
|
|
paintChrome();
|
|
toast(`${made.length} mark${made.length === 1 ? "" : "s"} pasted`);
|
|
}
|
|
|
|
/** Select every mark on the page the user is looking at. */
|
|
function selectAllOnPage() {
|
|
const t = session.tools, m = session.model, strip = session.strip;
|
|
if (!t || !m || !strip) return;
|
|
const uid = strip.current || m.visible()[0];
|
|
if (!uid) return;
|
|
const ids = m.annotsFor(uid).map((a) => a.id);
|
|
if (!ids.length) { toast("Nothing on this page to select"); return; }
|
|
selectTool("select");
|
|
t.selectMany(ids);
|
|
toast(`${ids.length} mark${ids.length === 1 ? "" : "s"} selected`);
|
|
}
|
|
|
|
/** The style buttons, while a caret is open rather than a mark selected. */
|
|
function paintInlineBar() {
|
|
const st = inline?.state;
|
|
if (!st) { selbar.hidden = true; return; }
|
|
selbar.dataset.mode = "inline";
|
|
$("sb-edit").hidden = true;
|
|
$("sb-textsize").hidden = false;
|
|
$("sb-bold").hidden = false;
|
|
$("sb-italic").hidden = false;
|
|
$("sb-fill").hidden = true;
|
|
$("sb-dup").hidden = true;
|
|
$("sb-del").hidden = true;
|
|
$("sb-size").textContent = String(Math.round(st.size));
|
|
$("sb-bold").classList.toggle("active", !!st.bold);
|
|
$("sb-italic").classList.toggle("active", !!st.italic);
|
|
selbar.hidden = false;
|
|
positionInlineBar();
|
|
}
|
|
function positionInlineBar() {
|
|
const r = inline?.rect();
|
|
if (!r || selbar.hidden) return;
|
|
const box = selbar.getBoundingClientRect();
|
|
const cr = $("viewerContainer").getBoundingClientRect();
|
|
const GAP = 10;
|
|
let top = r.top - box.height - GAP;
|
|
if (top < cr.top + 4) top = r.bottom + GAP;
|
|
const left = Math.min(Math.max(r.left, cr.left + 4), cr.right - box.width - 4);
|
|
selbar.style.left = `${Math.round(left)}px`;
|
|
selbar.style.top = `${Math.round(top)}px`;
|
|
}
|
|
|
|
let lastTextSize = 12, lastTextBold = false, lastTextItalic = false;
|
|
|
|
// Typing happens on the page now, so the dialog that used to hold a copy of
|
|
// the words is gone. What it did that the caret does not is warn about
|
|
// characters the built-in font cannot write — so the caret says it on the way
|
|
// out instead, once, rather than as you type.
|
|
function warnUnencodable(text) {
|
|
const n = unencodableStampsIn(text);
|
|
if (n) {
|
|
toast(`${n} character${n === 1 ? "" : "s"} cannot be written with the built-in font `
|
|
+ `and will be saved as "?".`, true);
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
|
|
// ---- signatures -------------------------------------------------------
|
|
// A library, not a single slot. One signature was enough until you needed
|
|
// initials as well as a full name, or your own next to someone else's — at
|
|
// which point the only way to change the one you had was to have no way at
|
|
// all, because every click placed it again.
|
|
let signatures = []; // [{ id, name, strokes, aspect }]
|
|
let savedSignature = null; // the one the armed tool will place
|
|
let pad = null;
|
|
|
|
async function persistSignatures() {
|
|
try { await sm?.storage?.set("signatures", signatures); } catch {}
|
|
}
|
|
function sigThumb(sig) {
|
|
// The strokes are normalised 0..1 with y downwards, which is already an SVG
|
|
// viewBox; nothing has to be scaled to draw a preview.
|
|
const paths = (sig.strokes || []).map((s) =>
|
|
`<path d="${s.map(([x, y], i) => `${i ? "L" : "M"}${(x * 100).toFixed(1)} ${(y * 100 * (sig.aspect || 0.34)).toFixed(1)}`).join(" ")}"/>`
|
|
).join("");
|
|
return `<svg viewBox="0 0 100 ${(100 * (sig.aspect || 0.34)).toFixed(1)}" fill="none"
|
|
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"
|
|
preserveAspectRatio="xMidYMid meet">${paths}</svg>`;
|
|
}
|
|
function paintSignatureList() {
|
|
const list = $("sign-list");
|
|
list.textContent = "";
|
|
$("sign-empty").hidden = signatures.length > 0;
|
|
for (const sig of signatures) {
|
|
const li = document.createElement("li");
|
|
li.className = "sigrow";
|
|
li.innerHTML = `<span class="sigpreview">${sigThumb(sig)}</span>
|
|
<span class="signame"></span>
|
|
<span class="sigacts">
|
|
<button class="btn primary" data-act="use">Use</button>
|
|
<button class="btn" data-act="edit">Redraw</button>
|
|
<button class="btn" data-act="rename">Rename</button>
|
|
<button class="btn" data-act="delete">Delete</button>
|
|
</span>`;
|
|
li.querySelector(".signame").textContent = sig.name || "Signature";
|
|
li.querySelector('[data-act="use"]').addEventListener("click", () => closeModal(sig));
|
|
li.querySelector('[data-act="edit"]').addEventListener("click", () => showSignaturePad(sig));
|
|
li.querySelector('[data-act="rename"]').addEventListener("click", async () => {
|
|
const name = window.prompt("Name this signature", sig.name || "Signature");
|
|
if (name === null) return;
|
|
sig.name = name.trim().slice(0, 60) || "Signature";
|
|
await persistSignatures();
|
|
paintSignatureList();
|
|
});
|
|
li.querySelector('[data-act="delete"]').addEventListener("click", async () => {
|
|
if (!window.confirm(`Delete "${sig.name || "Signature"}"?`)) return;
|
|
signatures = signatures.filter((s) => s.id !== sig.id);
|
|
if (savedSignature?.id === sig.id) savedSignature = null;
|
|
await persistSignatures();
|
|
paintSignatureList();
|
|
});
|
|
list.append(li);
|
|
}
|
|
}
|
|
/** Swap the modal over to the pad, either blank or loaded with one to redraw. */
|
|
function showSignaturePad(existing = null) {
|
|
if (!pad) {
|
|
pad = new SignaturePad($("sign-pad"));
|
|
pad.onChange((has) => { $("sign-ok").disabled = !has; });
|
|
for (const b of $("sign-colors").querySelectorAll("[data-ink]")) {
|
|
b.addEventListener("click", () => {
|
|
pad.setColor(b.dataset.ink);
|
|
for (const o of $("sign-colors").querySelectorAll("[data-ink]")) {
|
|
o.classList.toggle("active", o === b);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
pad.clear();
|
|
padEditing = existing;
|
|
// Redrawing keeps the ink it was signed in.
|
|
const ink = existing?.color || "#101418";
|
|
pad.setColor(ink);
|
|
for (const o of $("sign-colors").querySelectorAll("[data-ink]")) {
|
|
o.classList.toggle("active", o.dataset.ink === ink);
|
|
}
|
|
if (existing) pad.load(existing.strokes, existing.aspect);
|
|
$("sign-title").textContent = existing ? "Redraw this signature" : "Draw a signature";
|
|
$("sign-name").value = existing?.name || "";
|
|
$("sign-library").hidden = true;
|
|
$("sign-draw").hidden = false;
|
|
$("sign-ok").disabled = pad.isEmpty;
|
|
}
|
|
function showSignatureLibrary() {
|
|
padEditing = null;
|
|
$("sign-title").textContent = "Your signatures";
|
|
$("sign-draw").hidden = true;
|
|
$("sign-library").hidden = false;
|
|
paintSignatureList();
|
|
}
|
|
let padEditing = null;
|
|
|
|
/**
|
|
* Open the library and hand back whichever signature was chosen, or null.
|
|
* With nothing saved there is nothing to choose from, so it opens on the pad.
|
|
*/
|
|
async function chooseSignature() {
|
|
if (signatures.length) showSignatureLibrary(); else showSignaturePad(null);
|
|
const chosen = await openModal("modal-sign");
|
|
if (!chosen) return null;
|
|
savedSignature = chosen;
|
|
return chosen;
|
|
}
|
|
$("sign-new").addEventListener("click", () => showSignaturePad(null));
|
|
$("sign-close").addEventListener("click", () => closeModal(null));
|
|
$("sign-cancel").addEventListener("click", () => {
|
|
// "Back" when there is a library to go back to; otherwise it is the only
|
|
// screen, so it closes.
|
|
if (signatures.length) showSignatureLibrary(); else closeModal(null);
|
|
});
|
|
$("sign-clear").addEventListener("click", () => pad?.clear());
|
|
$("sign-ok").addEventListener("click", async () => {
|
|
const res = pad?.result();
|
|
if (!res) return;
|
|
const name = $("sign-name").value.trim().slice(0, 60) ||
|
|
padEditing?.name || `Signature ${signatures.length + 1}`;
|
|
let sig;
|
|
if (padEditing) {
|
|
sig = Object.assign(padEditing, { ...res, name });
|
|
} else {
|
|
sig = { id: `sig-${Date.now().toString(36)}`, name, ...res };
|
|
signatures.push(sig);
|
|
}
|
|
await persistSignatures();
|
|
closeModal(sig);
|
|
});
|
|
|
|
// ---- 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 replaced = replacedRuns(m);
|
|
if (replaced) lines.push([`${replaced} text run${replaced === 1 ? "" : "s"} replaced in place — the original text stays in the file underneath`, false]);
|
|
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));
|
|
|
|
// ---- convert to Word --------------------------------------------------
|
|
//
|
|
// Named a conversion, not an edit, because that is what it is. A PDF holds
|
|
// glyphs with coordinates; Word wants paragraphs. Everything between is
|
|
// inferred from geometry, so the layout is rebuilt rather than carried over,
|
|
// and the dialog says so before anything is written.
|
|
|
|
/** Text items and page geometry, in the order the pages currently read. */
|
|
async function gatherPages() {
|
|
const m = session.model, strip = session.strip;
|
|
const pages = [];
|
|
const visible = m.visible();
|
|
for (let i = 0; i < visible.length; i++) {
|
|
const uid = visible[i];
|
|
const view = strip.views.get(uid);
|
|
if (!view) continue;
|
|
if (!view.textContent) {
|
|
try { view.textContent = await view.pdfPage.getTextContent(); }
|
|
catch { view.textContent = { items: [], styles: {} }; }
|
|
}
|
|
const tc = view.textContent;
|
|
// pdf.js names fonts by an internal id. The real name — the only place a
|
|
// PDF records bold or italic — is on the font object when it has loaded,
|
|
// with the style block as a weaker fallback.
|
|
const fontMap = new Map();
|
|
for (const id of Object.keys(tc.styles || {})) {
|
|
let name = tc.styles[id]?.fontFamily || id;
|
|
try {
|
|
if (view.pdfPage.commonObjs.has(id)) name = view.pdfPage.commonObjs.get(id)?.name || name;
|
|
} catch {}
|
|
fontMap.set(id, name);
|
|
}
|
|
const info = m.size(uid);
|
|
pages.push({
|
|
index: i,
|
|
label: i + 1,
|
|
items: tc.items || [],
|
|
styles: tc.styles || {},
|
|
fontMap,
|
|
widthPt: info.width,
|
|
heightPt: info.height,
|
|
replacements: m.annotsFor(uid)
|
|
.filter((a) => a.kind === "textedit")
|
|
.map((a) => ({ x: a.x, y: a.y, w: a.w, h: a.h, text: a.text })),
|
|
});
|
|
}
|
|
return pages;
|
|
}
|
|
|
|
function defaultDocxName() {
|
|
const base = String(session.name || "document.pdf").replace(/\.pdf$/i, "");
|
|
return `${base}.docx`;
|
|
}
|
|
|
|
async function convertToWord() {
|
|
const m = session.model;
|
|
if (!m) return;
|
|
setBusy(true, "Reading the text…");
|
|
let result;
|
|
try {
|
|
result = reflow(await gatherPages());
|
|
} catch (e) {
|
|
setBusy(false);
|
|
console.error("convert failed:", e);
|
|
status(`Could not read this document's text: ${e?.message || e}`, true);
|
|
toast(`Could not read this document's text: ${e?.message || e}`, true);
|
|
return;
|
|
}
|
|
setBusy(false);
|
|
|
|
const { paragraphs, warnings, stats } = result;
|
|
$("convert-name").value = defaultDocxName();
|
|
const list = $("convert-summary");
|
|
list.textContent = "";
|
|
const add = (text, warn) => {
|
|
const li = document.createElement("li");
|
|
li.textContent = text;
|
|
if (warn) li.className = "warn";
|
|
list.append(li);
|
|
};
|
|
if (!stats.paragraphs) {
|
|
add("No text was found in this document at all. If it looks like pages of writing, "
|
|
+ "they are images of writing, and turning those into text needs character recognition, "
|
|
+ "which this editor does not do.", true);
|
|
} else {
|
|
add(`${stats.paragraphs} paragraph${stats.paragraphs === 1 ? "" : "s"} across `
|
|
+ `${stats.pages} page${stats.pages === 1 ? "" : "s"}, body text read as ${stats.bodySize} pt`);
|
|
if (stats.headings) add(`${stats.headings} of them look like headings and will carry Word's heading styles`);
|
|
}
|
|
for (const w of warnings) add(w, true);
|
|
add("Bold, italic, size and colour carry over. The layout is rebuilt, so line breaks and "
|
|
+ "page breaks will not land where they do in the PDF. Tables become plain paragraphs, "
|
|
+ "and images, forms and your annotations are not included.");
|
|
|
|
$("convert-go").disabled = !stats.paragraphs;
|
|
const go = await openModal("modal-convert");
|
|
$("convert-go").disabled = false;
|
|
if (!go) return;
|
|
|
|
let filename = String($("convert-name").value || "").trim() || defaultDocxName();
|
|
if (!/\.docx$/i.test(filename)) filename += ".docx";
|
|
|
|
setBusy(true, "Writing…");
|
|
try {
|
|
const first = (await gatherPages())[0];
|
|
const bytes = buildDocx({
|
|
paragraphs,
|
|
page: { widthPt: first?.widthPt || 612, heightPt: first?.heightPt || 792 },
|
|
defaults: { size: stats.bodySize || 11, family: "Calibri" },
|
|
});
|
|
const blob = new Blob([bytes], {
|
|
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = $("download-link");
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 8000);
|
|
const kb = (bytes.length / 1024).toFixed(0);
|
|
status(`Converted to ${filename} · ${kb} KB · ${stats.paragraphs} paragraphs`);
|
|
toast(`Converted to ${filename} (${kb} KB)`);
|
|
} catch (e) {
|
|
console.error("docx write failed:", e);
|
|
status(`Could not write the Word file: ${e?.message || e}`, true);
|
|
toast(`Could not write the Word file: ${e?.message || e}`, true);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
$("convert").addEventListener("click", convertToWord);
|
|
$("convert-go").addEventListener("click", () => closeModal(true));
|
|
$("convert-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;
|
|
// Everything you added to the page you are looking at. Not the whole
|
|
// document: a group can only be moved within one page, so selecting across
|
|
// pages would offer a group half of which cannot come along.
|
|
if (meta && e.key.toLowerCase() === "a") {
|
|
e.preventDefault();
|
|
selectAllOnPage();
|
|
return;
|
|
}
|
|
if (meta && e.key.toLowerCase() === "c") { e.preventDefault(); copySelection(); return; }
|
|
if (meta && e.key.toLowerCase() === "x") { e.preventDefault(); copySelection(true); return; }
|
|
if (meta && e.key.toLowerCase() === "v") { e.preventDefault(); pasteMarks(); 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;
|
|
if (session.tools?.selection?.size > 1) return; // one caret, one mark
|
|
const a = sel && session.model?.annot(sel);
|
|
if (a?.kind === "text") { beginText({ id: sel }); e.preventDefault(); return; }
|
|
if (a?.kind === "textedit") { beginText({ id: sel }); 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 saved = await sm?.storage?.get("signatures", null);
|
|
if (Array.isArray(saved)) signatures = saved.filter((s) => s && Array.isArray(s.strokes));
|
|
// One signature used to live under its own key. Carry it into the
|
|
// library rather than leaving someone's only signature behind.
|
|
const legacy = await sm?.storage?.get("signature", null);
|
|
if (!signatures.length && legacy && Array.isArray(legacy.strokes)) {
|
|
signatures = [{ id: "sig-legacy", name: "Signature", ...legacy }];
|
|
await persistSignatures();
|
|
}
|
|
savedSignature = signatures[0] || null;
|
|
} catch {}
|
|
|
|
const id = new URLSearchParams(location.search).get("doc");
|
|
if (!id) { status("Drop a PDF or some pictures here, or pick one of the options."); 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();
|
|
}
|
|
})();
|