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