A PDF that needs a signature, a highlight or a page removed currently sends the user out to a desktop application or, worse, to a web service that wants the document uploaded first. Both are poor answers for a browser whose point is that nothing has to leave the machine. This is a full-tab editor that opens a PDF, marks it up, fills its forms and saves a new copy, entirely locally. Two engines, vendored rather than installed, because an add-on ships as a self-contained folder over the signed update channel and nothing runs a package manager on the way: pdf.js reads and renders, pdf-lib writes. They share no state. Everything in between lives in PDF user space — points, origin bottom-left — which is the one coordinate vocabulary both speak, so a mark survives zooming, rotating and reordering with no conversion table and save-time needs to know nothing about how a page happened to be displayed. The page strip is built from pdf.js's PDFPageView components rather than its PDFViewer, which renders pages in the file's own order and cannot hide, reorder or individually rotate one — three of the features here. Text layers are ours and stay attached for every page, drawn or not, because Theseus's find bar is Chromium's findInPage over the live DOM and a torn-down text layer is a page Ctrl+F cannot see. Canvases are virtualised; a letter page at 100% is 3.4 MB of bitmap. Redaction is the part worth being careful about. A black box over text hides nothing — the text stays in the content stream and comes straight out of a copy-paste — so the editor says so in a modal before the tool can be used, and on save rebuilds each redacted page as an image, which genuinely removes it. Pages that were not redacted are untouched. Form widgets and links are kept, since they were never the leak. Saving never writes over the original: every save reloads the source bytes and replays the session onto a fresh copy, so a botched save cannot poison the next one. Out of scope for this first version: editing the text that is already in the document, and writing XFA forms back (pdf-lib cannot, so those are fill-and- print only, and the editor says so on open).
441 lines
18 KiB
JavaScript
441 lines
18 KiB
JavaScript
// The scrolling page strip.
|
|
//
|
|
// Built out of pdf.js's PDFPageView components rather than its PDFViewer.
|
|
// PDFViewer renders `pdfDocument.numPages` pages in the file's own order and
|
|
// offers no way to hide, reorder or individually rotate one — which is three
|
|
// of this editor's features. Owning the strip costs us a scroll observer and
|
|
// a scale calculation; it buys pages that move the instant the user drags a
|
|
// thumbnail, with no round-trip through the file.
|
|
//
|
|
// Two layers are ours rather than PDFPageView's:
|
|
//
|
|
// * The text layer. PDFPageView builds one, but tears it down whenever the
|
|
// page's canvas is discarded — and Theseus's find bar is Chromium's
|
|
// findInPage over the live DOM, so a torn-down text layer is a page that
|
|
// Ctrl+F cannot see. We build our own from cached text content and keep it
|
|
// attached for every page, drawn or not, so find covers the whole
|
|
// document rather than the dozen pages currently rasterised.
|
|
//
|
|
// * The overlay (lib/overlay.js), for the same reason plus hit-testing.
|
|
//
|
|
// Canvases ARE virtualised: a letter page at 100% is ~3.4 MB of bitmap, so a
|
|
// few hundred of them is gigabytes. Only pages near the viewport hold one.
|
|
|
|
import { Overlay } from "./overlay.js";
|
|
|
|
const GUTTER = 28; // px of breathing room either side at fit-width
|
|
const DRAW_MARGIN = "180% 0px"; // how far outside the viewport to keep canvases
|
|
const MAX_PUMP_PASSES = 12; // retry sweeps before the pump gives up
|
|
const MAX_DRAW_RETRIES = 6; // per-page redraw attempts before we stop asking
|
|
|
|
export class PageStrip {
|
|
constructor({ container, viewer, pdfjsLib, pdf, model, eventBus, layerProperties }) {
|
|
this.container = container; // the scrolling element
|
|
this.viewer = viewer; // #viewer, holds the .page divs
|
|
this.lib = pdfjsLib;
|
|
this.pdf = pdf;
|
|
this.model = model;
|
|
this.eventBus = eventBus;
|
|
this.layerProperties = layerProperties;
|
|
this.views = new Map(); // uid -> {pv, overlay, textDiv, textContent, stale}
|
|
this.scaleMode = "page-width"; // "page-width" | "page-fit" | number
|
|
this.scale = 1;
|
|
this.current = null; // uid of the page nearest the top of the view
|
|
this._onCurrent = [];
|
|
this._io = null;
|
|
this._visIo = null;
|
|
// A hidden tab produces no frames: requestAnimationFrame never runs, the
|
|
// IntersectionObserver never reports, and pdf.js's own render never
|
|
// publishes its canvas. Nothing can be done about that while hidden — and
|
|
// nothing needs to be, since nobody is looking — but the moment the tab
|
|
// comes back we have to re-measure and rasterise, or it stays blank.
|
|
this._onVisibility = () => {
|
|
if (document.hidden) return;
|
|
this._markVisibleByGeometry();
|
|
this._pump();
|
|
};
|
|
document.addEventListener("visibilitychange", this._onVisibility);
|
|
}
|
|
|
|
onCurrentChange(fn) { this._onCurrent.push(fn); }
|
|
|
|
// ---- construction -------------------------------------------------
|
|
async build() {
|
|
const { PDFPageView } = this.lib.viewer;
|
|
for (const uid of this.model.order) {
|
|
const p = this.model.page(uid);
|
|
const pdfPage = await this.pdf.getPage(p.src + 1);
|
|
const defaultViewport = pdfPage.getViewport({ scale: 1 });
|
|
const pv = new PDFPageView({
|
|
container: null, // we place the div ourselves
|
|
id: p.src + 1,
|
|
scale: 1,
|
|
defaultViewport,
|
|
eventBus: this.eventBus,
|
|
layerProperties: this.layerProperties,
|
|
// TextLayerMode is internal to the components bundle and not exported,
|
|
// hence the literal; 0 is DISABLE. We supply our own text layer, for
|
|
// the find-bar reason at the top of this file.
|
|
textLayerMode: 0,
|
|
annotationMode: this.lib.AnnotationMode.ENABLE_FORMS,
|
|
imageResourcesPath: "vendor/pdfjs/web/images/",
|
|
enableAutoLinking: false,
|
|
});
|
|
pv.setPdfPage(pdfPage);
|
|
const entry = { pv, pdfPage, overlay: null, textDiv: null, textContent: null,
|
|
drawn: false, drawing: false, visible: false };
|
|
this.views.set(uid, entry);
|
|
}
|
|
this.computeScale();
|
|
this.layout();
|
|
// Start at the top. Without this the container keeps whatever scroll
|
|
// offset the previous document left behind — and because the observer
|
|
// only rasterises what is near the viewport, a short document opened
|
|
// after a long one can land entirely out of view and never draw at all.
|
|
// Page divs also change height as they take their real dimensions, and
|
|
// Chromium's scroll anchoring happily follows that, so reset again once
|
|
// the layout has settled.
|
|
this.container.scrollTop = 0;
|
|
this._observe();
|
|
this._trackCurrent();
|
|
this._markVisibleByGeometry();
|
|
this._pump();
|
|
// Page divs settle into their real heights a frame later; re-measure then
|
|
// so the second screenful is queued without waiting for a scroll.
|
|
requestAnimationFrame(() => {
|
|
this.container.scrollTop = 0;
|
|
this._markVisibleByGeometry();
|
|
this._pump();
|
|
});
|
|
}
|
|
|
|
/** Viewport for a page at the current scale and rotation. */
|
|
viewportOf(uid) {
|
|
const e = this.views.get(uid);
|
|
if (!e) return null;
|
|
return e.pdfPage.getViewport({
|
|
scale: this.scale,
|
|
rotation: this.model.displayRotation(uid),
|
|
});
|
|
}
|
|
|
|
// ---- scale --------------------------------------------------------
|
|
computeScale() {
|
|
const uid = this.current || this.model.visible()[0];
|
|
if (!uid) { this.scale = 1; return; }
|
|
const e = this.views.get(uid);
|
|
const base = e.pdfPage.getViewport({ scale: 1, rotation: this.model.displayRotation(uid) });
|
|
if (typeof this.scaleMode === "number") { this.scale = this.scaleMode; return; }
|
|
const availW = Math.max(120, this.container.clientWidth - GUTTER * 2);
|
|
if (this.scaleMode === "page-width") {
|
|
this.scale = availW / base.width;
|
|
} else {
|
|
const availH = Math.max(120, this.container.clientHeight - GUTTER);
|
|
this.scale = Math.min(availW / base.width, availH / base.height);
|
|
}
|
|
// pdf.js clamps its own viewer to this band; going outside it produces
|
|
// canvases the compositor refuses or a page too small to read.
|
|
this.scale = Math.min(10, Math.max(0.1, this.scale));
|
|
}
|
|
|
|
setScaleMode(mode) {
|
|
this.scaleMode = mode;
|
|
this.computeScale();
|
|
this.layout();
|
|
}
|
|
/** Step the numeric zoom, switching out of a fit mode if we are in one. */
|
|
stepScale(dir) {
|
|
const steps = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4, 6];
|
|
const at = this.scale;
|
|
const next = dir > 0 ? steps.find((s) => s > at + 0.001) : [...steps].reverse().find((s) => s < at - 0.001);
|
|
if (next) this.setScaleMode(next);
|
|
}
|
|
|
|
// ---- layout -------------------------------------------------------
|
|
/** Re-place every page div in display order and re-size all layers. */
|
|
layout() {
|
|
this.viewer.style.setProperty("--scale-factor", String(this.scale));
|
|
const visible = this.model.visible();
|
|
// Detach deleted pages, then append in order. appendChild on an element
|
|
// already in the tree moves it, so this is also the reorder path.
|
|
for (const [uid, e] of this.views) {
|
|
if (!visible.includes(uid)) e.pv.div.remove();
|
|
}
|
|
visible.forEach((uid, i) => {
|
|
const e = this.views.get(uid);
|
|
this.viewer.append(e.pv.div);
|
|
e.pv.updatePageNumber(i + 1);
|
|
e.pv.div.dataset.uid = uid;
|
|
});
|
|
for (const uid of visible) this.applyPageGeometry(uid);
|
|
this._observe();
|
|
this._markVisibleByGeometry();
|
|
this._pump();
|
|
}
|
|
|
|
/** Push the current scale + rotation into one page's view and its layers. */
|
|
applyPageGeometry(uid) {
|
|
const e = this.views.get(uid);
|
|
if (!e) return;
|
|
const rotation = this.model.extraRotation(uid);
|
|
// A window resize recomputes a fit scale and lands on a value a hair off
|
|
// the old one. Re-rendering every page for a difference nobody can see
|
|
// would also cancel whatever was mid-render, so ignore the noise.
|
|
const scaleChanged = Math.abs(e.pv.scale - this.scale) > 0.0005;
|
|
if (scaleChanged || e.pv.rotation !== rotation) {
|
|
e.pv.update({ scale: this.scale, rotation });
|
|
e.drawn = false; // update() resets the canvas
|
|
// update() wipes the div's children, ours included.
|
|
if (e.textDiv) { e.textDiv.remove(); e.textDiv = null; }
|
|
if (e.overlay) { e.overlay.destroy(); e.overlay = null; }
|
|
}
|
|
this.ensureOverlay(uid);
|
|
this.refreshOverlay(uid);
|
|
if (!e.textDiv) this.buildTextLayer(uid);
|
|
}
|
|
|
|
ensureOverlay(uid) {
|
|
const e = this.views.get(uid);
|
|
if (!e) return null;
|
|
if (!e.overlay) e.overlay = new Overlay(e.pv.div);
|
|
return e.overlay;
|
|
}
|
|
refreshOverlay(uid, selectedId = this._selectedId) {
|
|
const e = this.views.get(uid);
|
|
if (!e || !e.overlay) return;
|
|
const viewport = this.viewportOf(uid);
|
|
if (!viewport) return;
|
|
this._selectedId = selectedId;
|
|
let marks = this.model.annotsFor(uid);
|
|
// The in-progress drag is drawn from the same code as a committed mark, so
|
|
// what the user sees mid-drag is exactly what lands when they release.
|
|
if (this._preview && this._preview.uid === uid && this._preview.annot) {
|
|
marks = marks.concat([{ id: "__preview", ...this._preview.annot }]);
|
|
}
|
|
e.overlay.render(viewport, marks, selectedId);
|
|
}
|
|
|
|
/** Show a not-yet-committed mark on one page. Pass (null, null) to clear. */
|
|
setPreview(uid, annot) {
|
|
const prev = this._preview?.uid;
|
|
this._preview = uid ? { uid, annot } : null;
|
|
if (prev && prev !== uid) this.refreshOverlay(prev);
|
|
if (uid) this.refreshOverlay(uid);
|
|
}
|
|
refreshAllOverlays(selectedId) {
|
|
this._selectedId = selectedId;
|
|
for (const uid of this.model.visible()) this.refreshOverlay(uid, selectedId);
|
|
}
|
|
overlayFor(uid) { return this.views.get(uid)?.overlay || null; }
|
|
|
|
// ---- text layer ---------------------------------------------------
|
|
// Built from cached text content so a re-layout is DOM work only — no
|
|
// second trip to the worker. Kept attached for every page regardless of
|
|
// whether its canvas is currently alive.
|
|
async buildTextLayer(uid) {
|
|
const e = this.views.get(uid);
|
|
if (!e || e.textDiv) return;
|
|
const div = document.createElement("div");
|
|
div.className = "textLayer";
|
|
e.textDiv = div;
|
|
e.pv.div.append(div);
|
|
const viewport = this.viewportOf(uid);
|
|
this.lib.setLayerDimensions(div, viewport);
|
|
try {
|
|
if (!e.textContent) e.textContent = await e.pdfPage.getTextContent();
|
|
const tl = new this.lib.TextLayer({ textContentSource: e.textContent, container: div, viewport });
|
|
await tl.render();
|
|
} catch (err) {
|
|
// A page with no extractable text is normal (a scan). Anything else is
|
|
// worth a line in the console but must not stop the page rendering.
|
|
if (err?.name !== "AbortException") console.warn(`text layer for ${uid}:`, err?.message || err);
|
|
}
|
|
}
|
|
|
|
// ---- canvas virtualisation ----------------------------------------
|
|
//
|
|
// The observer only records what the user can see; the pump decides what to
|
|
// rasterise. Keeping those apart is what makes this survive a relayout: a
|
|
// render cancelled because the scale changed mid-flight simply leaves the
|
|
// page wanted-but-not-drawn, and the next pump picks it up. An earlier
|
|
// version drew straight from the observer callback, and a resize during
|
|
// startup — Theseus restoring its window bounds — could cancel the first
|
|
// render of every page, after which nothing ever asked again and the user
|
|
// sat looking at blank pages.
|
|
_observe() {
|
|
this._io?.disconnect();
|
|
this._io = new IntersectionObserver((entries) => {
|
|
for (const ent of entries) {
|
|
const uid = ent.target.dataset.uid;
|
|
const e = uid && this.views.get(uid);
|
|
if (!e) continue;
|
|
e.visible = ent.isIntersecting;
|
|
}
|
|
this._pump();
|
|
}, { root: this.container, rootMargin: DRAW_MARGIN });
|
|
for (const uid of this.model.visible()) {
|
|
const e = this.views.get(uid);
|
|
if (e) this._io.observe(e.pv.div);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Work out what is on screen by measuring, instead of waiting to be told.
|
|
*
|
|
* An IntersectionObserver only reports from the browser's rendering step, so
|
|
* a tab that has not produced a frame yet never gets its first callback —
|
|
* and an editor whose whole render schedule hangs off that observer then
|
|
* shows a document of blank pages until something else forces a paint. That
|
|
* is not hypothetical: it was intermittent on every cold open of a tab. The
|
|
* observer stays, because it is the efficient way to follow scrolling, but
|
|
* the first paint no longer depends on it.
|
|
*/
|
|
_markVisibleByGeometry() {
|
|
const root = this.container.getBoundingClientRect();
|
|
if (!root.height) return;
|
|
const margin = root.height * 1.8; // matches DRAW_MARGIN
|
|
for (const uid of this.model.visible()) {
|
|
const e = this.views.get(uid);
|
|
if (!e) continue;
|
|
const r = e.pv.div.getBoundingClientRect();
|
|
e.visible = r.bottom >= root.top - margin && r.top <= root.bottom + margin;
|
|
}
|
|
}
|
|
|
|
/** Bring every page's canvas into line with whether it is currently wanted. */
|
|
_pump() {
|
|
if (this._dead) return;
|
|
if (this._pumping) { this._pumpAgain = true; return; }
|
|
this._pumping = true;
|
|
queueMicrotask(async () => {
|
|
try {
|
|
let passes = 0;
|
|
this._pumpAgain = false;
|
|
do {
|
|
for (const uid of this.model.visible()) {
|
|
if (this._dead) return;
|
|
const e = this.views.get(uid);
|
|
if (!e) continue;
|
|
if (e.visible && !e.drawn) await this._draw(uid);
|
|
else if (!e.visible && e.drawn) this._undraw(uid);
|
|
}
|
|
if (!this._pumpAgain) break;
|
|
this._pumpAgain = false;
|
|
// Hand the frame back between passes. A retry that fails the same
|
|
// way every time would otherwise spin as fast as pdf.js can reject,
|
|
// freezing the tab instead of just failing one page. setTimeout, not
|
|
// rAF: a tab that is not painting never runs a rAF callback, which is
|
|
// exactly the situation a retry may be recovering from.
|
|
if (++passes < MAX_PUMP_PASSES) {
|
|
await new Promise((r) => setTimeout(r, 16));
|
|
this._pumpAgain = true;
|
|
} else {
|
|
console.warn("page rendering gave up after", passes, "passes");
|
|
break;
|
|
}
|
|
} while (this._pumpAgain);
|
|
} finally { this._pumping = false; }
|
|
});
|
|
}
|
|
|
|
async _draw(uid) {
|
|
const e = this.views.get(uid);
|
|
if (!e || e.drawn || e.drawing) return;
|
|
e.drawing = true;
|
|
try {
|
|
await e.pv.draw();
|
|
e.drawn = true;
|
|
e.retries = 0;
|
|
} catch (err) {
|
|
// A cancelled render is routine — the scale or rotation moved under it.
|
|
// Leave the page undrawn and let the next pump have another go, but
|
|
// give up eventually so one unrenderable page cannot hold the pump open.
|
|
if (err?.name !== "RenderingCancelledException") console.warn(`draw ${uid}:`, err?.message || err);
|
|
if ((e.retries = (e.retries || 0) + 1) <= MAX_DRAW_RETRIES) this._pumpAgain = true;
|
|
return;
|
|
} finally { e.drawing = false; }
|
|
// draw() appends the canvas wrapper and the annotation layer; ours have to
|
|
// end up above them, and update()/reset() may have removed them entirely.
|
|
if (this._dead || !this.views.has(uid)) return;
|
|
if (!e.textDiv) await this.buildTextLayer(uid);
|
|
else e.pv.div.append(e.textDiv);
|
|
if (this._dead || !this.views.has(uid)) return;
|
|
this.ensureOverlay(uid);
|
|
e.pv.div.append(e.overlay.svg);
|
|
this.refreshOverlay(uid);
|
|
}
|
|
|
|
_undraw(uid) {
|
|
const e = this.views.get(uid);
|
|
if (!e || !e.drawn) return;
|
|
e.drawn = false;
|
|
// Drop the canvas and the form layer; keep ours. reset() removes every
|
|
// child it was not told to keep, so re-attach after.
|
|
e.pv.reset();
|
|
if (e.textDiv) e.pv.div.append(e.textDiv);
|
|
if (e.overlay) e.pv.div.append(e.overlay.svg);
|
|
}
|
|
|
|
/** Force a redraw of one page — after a rotation, say. */
|
|
async redraw(uid) {
|
|
const e = this.views.get(uid);
|
|
if (!e) return;
|
|
this.applyPageGeometry(uid);
|
|
e.drawn = false;
|
|
this._pump();
|
|
}
|
|
|
|
// ---- current page tracking ----------------------------------------
|
|
_trackCurrent() {
|
|
this._visIo?.disconnect();
|
|
// A page counts as "current" once its top edge is in the upper half of
|
|
// the viewport; that matches how every PDF reader reports a page number.
|
|
this._visIo = new IntersectionObserver((entries) => {
|
|
let best = null, bestTop = Infinity;
|
|
for (const ent of entries) {
|
|
if (!ent.isIntersecting) continue;
|
|
const top = Math.abs(ent.boundingClientRect.top - ent.rootBounds.top);
|
|
if (top < bestTop) { bestTop = top; best = ent.target.dataset.uid; }
|
|
}
|
|
if (best && best !== this.current) {
|
|
this.current = best;
|
|
for (const fn of this._onCurrent) { try { fn(best); } catch {} }
|
|
}
|
|
}, { root: this.container, threshold: [0, 0.1, 0.5] });
|
|
for (const uid of this.model.visible()) {
|
|
const e = this.views.get(uid);
|
|
if (e) this._visIo.observe(e.pv.div);
|
|
}
|
|
}
|
|
|
|
scrollTo(uid) {
|
|
const e = this.views.get(uid);
|
|
if (e) e.pv.div.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
}
|
|
|
|
/** Which page div is under a client point, and where inside it. */
|
|
pageAt(clientX, clientY) {
|
|
for (const uid of this.model.visible()) {
|
|
const e = this.views.get(uid);
|
|
if (!e) continue;
|
|
const r = e.pv.div.getBoundingClientRect();
|
|
if (clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom) {
|
|
return { uid, vx: clientX - r.left, vy: clientY - r.top, rect: r };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
destroy() {
|
|
this._dead = true;
|
|
document.removeEventListener("visibilitychange", this._onVisibility);
|
|
this._io?.disconnect();
|
|
this._visIo?.disconnect();
|
|
for (const e of this.views.values()) {
|
|
try { e.overlay?.destroy(); } catch {}
|
|
try { e.pv.destroy(); } catch {}
|
|
}
|
|
this.views.clear();
|
|
this.viewer.textContent = "";
|
|
}
|
|
}
|