// The page rail: a thumbnail per page, draggable to reorder, with rotate and // delete on each one. // // Thumbnails render off the same pdf.js page objects as the main strip but at // a fixed small width, lazily, and are cached as bitmaps. A rotation re-renders // just that one; a reorder re-renders nothing at all, because the canvas is // already correct and only its position in the list changes. // // Reordering uses HTML5 drag-and-drop rather than pointer maths: it gives us // the drag image, the escape-to-cancel and the autoscroll for free, all of // which a hand-rolled version gets wrong first. const THUMB_W = 148; export class Rail { constructor({ list, countEl, model, strip, host }) { this.list = list; this.countEl = countEl; this.model = model; this.strip = strip; this.host = host; this.cache = new Map(); // uid -> canvas this.dragUid = null; this._io = new IntersectionObserver((entries) => { for (const ent of entries) { if (ent.isIntersecting) this._render(ent.target.dataset.uid); } }, { root: this.list, rootMargin: "300px 0px" }); } build() { this.list.textContent = ""; this._io.disconnect(); const visible = this.model.visible(); this.model.order.forEach((uid) => { const p = this.model.page(uid); const el = document.createElement("div"); el.className = "thumb" + (p.deleted ? " deleted" : ""); el.dataset.uid = uid; el.draggable = !p.deleted; const pos = visible.indexOf(uid); el.innerHTML = `
${p.deleted ? "removed" : pos + 1}
`; this.list.append(el); this._io.observe(el); this._wire(el, uid); }); this._paintCount(); this.markCurrent(this.strip.current); } _paintCount() { const n = this.model.visible().length; const gone = this.model.deletedCount(); this.countEl.textContent = `${n} page${n === 1 ? "" : "s"}${gone ? ` · ${gone} removed` : ""}`; const restore = document.getElementById("restore-pages"); if (restore) restore.hidden = gone === 0; } _wire(el, uid) { el.querySelector(".rot").addEventListener("click", (e) => { e.stopPropagation(); this.host.rotate(uid); }); el.querySelector(".del").addEventListener("click", (e) => { e.stopPropagation(); this.host.toggleDelete(uid); }); el.addEventListener("click", () => { if (this.model.page(uid).deleted) return; this.strip.scrollTo(uid); }); el.addEventListener("dragstart", (e) => { this.dragUid = uid; el.classList.add("dragging"); e.dataTransfer.effectAllowed = "move"; // Firefox and Chromium both need *something* set or the drag aborts. e.dataTransfer.setData("text/plain", uid); }); el.addEventListener("dragend", () => { this.dragUid = null; el.classList.remove("dragging"); for (const t of this.list.children) t.classList.remove("dropbefore", "dropafter"); }); el.addEventListener("dragover", (e) => { if (!this.dragUid || this.dragUid === uid) return; e.preventDefault(); const r = el.getBoundingClientRect(); const after = e.clientY > r.top + r.height / 2; for (const t of this.list.children) t.classList.remove("dropbefore", "dropafter"); el.classList.add(after ? "dropafter" : "dropbefore"); }); el.addEventListener("drop", (e) => { if (!this.dragUid || this.dragUid === uid) return; e.preventDefault(); const r = el.getBoundingClientRect(); const after = e.clientY > r.top + r.height / 2; const order = this.model.order; let beforeUid = uid; if (after) { const i = order.indexOf(uid); beforeUid = order[i + 1] ?? null; } this.host.reorder(this.dragUid, beforeUid); }); } async _render(uid) { const el = this.list.querySelector(`.thumb[data-uid="${uid}"]`); if (!el) return; const canvas = el.querySelector(".tcanvas"); if (canvas.dataset.done === this._sig(uid)) return; const entry = this.strip.views.get(uid); if (!entry) return; const base = entry.pdfPage.getViewport({ scale: 1, rotation: this.model.displayRotation(uid) }); const scale = THUMB_W / base.width; const vp = entry.pdfPage.getViewport({ scale, rotation: this.model.displayRotation(uid) }); const dpr = Math.min(2, window.devicePixelRatio || 1); canvas.width = Math.max(1, Math.floor(vp.width * dpr)); canvas.height = Math.max(1, Math.floor(vp.height * dpr)); canvas.style.aspectRatio = `${vp.width} / ${vp.height}`; const ctx = canvas.getContext("2d", { alpha: false }); ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.scale(dpr, dpr); try { await entry.pdfPage.render({ canvasContext: ctx, viewport: vp, intent: "display" }).promise; canvas.dataset.done = this._sig(uid); } catch (e) { if (e?.name !== "RenderingCancelledException") console.warn(`thumb ${uid}:`, e?.message || e); } } // A thumbnail only needs re-rendering when its rotation changes. _sig(uid) { return String(this.model.displayRotation(uid)); } /** Re-render one thumbnail — after a rotation. */ refresh(uid) { const el = this.list.querySelector(`.thumb[data-uid="${uid}"]`); if (el) el.querySelector(".tcanvas").dataset.done = ""; this._render(uid); } markCurrent(uid) { for (const t of this.list.children) t.classList.toggle("current", t.dataset.uid === uid); } destroy() { this._io.disconnect(); this.list.textContent = ""; } }