// 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 = ""; } }