162 lines
6.4 KiB
JavaScript
162 lines
6.4 KiB
JavaScript
|
|
// 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 = `
|
||
|
|
<canvas class="tcanvas"></canvas>
|
||
|
|
<div class="tfoot">
|
||
|
|
<span class="tnum">${p.deleted ? "removed" : pos + 1}</span>
|
||
|
|
<button class="tbtn rot" title="Rotate this page 90° clockwise">
|
||
|
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M13 8a5 5 0 1 1-1.5-3.5"/><path d="M13 2v3h-3"/></svg>
|
||
|
|
</button>
|
||
|
|
<button class="tbtn del" title="${p.deleted ? "Bring this page back" : "Remove this page"}">
|
||
|
|
${p.deleted
|
||
|
|
? '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M3 8c0-3 2-5 5-5s5 2 5 5-2 5-5 5"/><path d="M6 5L3 8l3 3"/></svg>'
|
||
|
|
: '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h10"/><path d="M6 4V2h4v2"/><path d="M4 4l1 10h6l1-10"/></svg>'}
|
||
|
|
</button>
|
||
|
|
</div>`;
|
||
|
|
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 = ""; }
|
||
|
|
}
|