Files
anonymous_github/public/script/pdf-viewer.js
T
Thomas Durieux 6434337923 perf: bound request work and resource usage (#866)
* perf: bound request work and resource usage

* fix: address performance review regressions

* fix: guard content expiration and dashboard state

* fix: tolerate cache outages and retire derived paths

* fix: revalidate expiration claims and status pagination

* fix: retry interrupted repository metadata cleanup

* fix: retire obsolete content cache storage

* fix: activate tree metadata and recheck expiration claims

* fix: fence cache publication across expiration

* fix: recover staged trees and enforce cache lifecycle cleanup

* fix: prune expired transforms during database outages

* fix: finish conference removals and reload expiration candidates
2026-10-08 03:38:19 +01:00

420 lines
16 KiB
JavaScript

import { h, ref, watch, onMounted, onBeforeUnmount } from "vue";
export default {
name: "pdfviewer",
props: ["src"],
setup(props) {
const host = ref(null);
onMounted(() => {
const element = [host.value];
// How far beyond the viewport to rasterise, so scrolling stays ahead of
// the reader.
const PRERENDER_MARGIN = 400;
const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
// Widest a page is drawn at 100%; beyond this a page is more comfortable
// to read zoomed than stretched across a wide monitor.
const FIT_MAX_WIDTH = 1000;
const container = element[0];
container.classList.add("pdf-viewer");
let doc = null;
let slots = [];
let scheduled = false;
let resizeTimer = null;
let zoom = 1;
let currentPage = 1;
// Page the reader asked for, held against layout shifts until
// anchorUntil passes.
let anchorPage = 0;
let anchorUntil = 0;
let aspect = 612 / 792; // US Letter, replaced once page 1 is known
// Bumped on every load so pages still resolving from a previous
// document are dropped instead of appended to the new one.
let generation = 0;
let loadingTask = null;
const renders = new Map();
let rendering = 0;
function releaseSlot(slot) {
const record = renders.get(slot);
if (record) {
record.cancelled = true;
record.task?.cancel?.();
renders.delete(slot);
}
const canvas = slot.querySelector("canvas");
if (canvas) { canvas.width = 0; canvas.height = 0; }
slot.innerHTML = "";
delete slot.dataset.state;
}
// ---------------------------------------------------------------
// Toolbar
// ---------------------------------------------------------------
const toolbar = document.createElement("div");
toolbar.className = "pdf-toolbar";
const pages = document.createElement("div");
pages.className = "pdf-pages";
// Focusable so the arrow/Page keys scroll the document.
pages.tabIndex = 0;
pages.setAttribute("aria-label", "PDF pages");
container.appendChild(toolbar);
container.appendChild(pages);
function button(icon, label, onClick) {
const b = document.createElement("button");
b.type = "button";
b.className = "pdf-toolbar-btn";
b.title = label;
b.setAttribute("aria-label", label);
b.innerHTML = '<i class="fas ' + icon + '"></i>';
b.addEventListener("click", onClick);
return b;
}
const prevBtn = button("fa-chevron-up", "Previous page", function () {
goToPage(currentPage - 1);
});
const nextBtn = button("fa-chevron-down", "Next page", function () {
goToPage(currentPage + 1);
});
const pageInput = document.createElement("input");
pageInput.type = "text";
pageInput.className = "pdf-toolbar-page";
pageInput.setAttribute("aria-label", "Page number");
pageInput.title = "Page number — type a page and press Enter";
pageInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
goToPage(parseInt(pageInput.value, 10));
pageInput.blur();
}
});
pageInput.addEventListener("blur", function () {
pageInput.value = String(currentPage);
});
const pageCount = document.createElement("span");
pageCount.className = "pdf-toolbar-count";
const zoomOutBtn = button("fa-search-minus", "Zoom out", function () {
setZoom(ZOOM_STEPS[Math.max(0, zoomIndex() - 1)]);
});
const zoomInBtn = button("fa-search-plus", "Zoom in", function () {
setZoom(ZOOM_STEPS[Math.min(ZOOM_STEPS.length - 1, zoomIndex() + 1)]);
});
const zoomLabel = document.createElement("button");
zoomLabel.type = "button";
zoomLabel.className = "pdf-toolbar-btn pdf-toolbar-zoom";
zoomLabel.title = "Reset zoom to fit the width";
zoomLabel.setAttribute("aria-label", "Reset zoom to fit the width");
zoomLabel.addEventListener("click", function () {
setZoom(1);
});
toolbar.appendChild(prevBtn);
toolbar.appendChild(nextBtn);
const pageGroup = document.createElement("span");
pageGroup.className = "pdf-toolbar-group";
pageGroup.appendChild(pageInput);
pageGroup.appendChild(pageCount);
toolbar.appendChild(pageGroup);
const zoomGroup = document.createElement("span");
zoomGroup.className = "pdf-toolbar-group pdf-toolbar-right";
zoomGroup.appendChild(zoomOutBtn);
zoomGroup.appendChild(zoomLabel);
zoomGroup.appendChild(zoomInBtn);
toolbar.appendChild(zoomGroup);
function zoomIndex() {
let best = 0;
for (let i = 0; i < ZOOM_STEPS.length; i++) {
if (Math.abs(ZOOM_STEPS[i] - zoom) < Math.abs(ZOOM_STEPS[best] - zoom)) {
best = i;
}
}
return best;
}
function syncToolbar() {
const total = doc ? doc.numPages : 0;
toolbar.style.display = total ? "" : "none";
pageCount.textContent = "/ " + total;
if (document.activeElement !== pageInput) {
pageInput.value = String(currentPage);
}
prevBtn.disabled = currentPage <= 1;
nextBtn.disabled = currentPage >= total;
zoomLabel.textContent = Math.round(zoom * 100) + "%";
zoomOutBtn.disabled = zoomIndex() === 0;
zoomInBtn.disabled = zoomIndex() === ZOOM_STEPS.length - 1;
}
function scrollToPage(target) {
const slot = slots[target - 1];
if (!slot) return;
pages.scrollTop +=
slot.getBoundingClientRect().top -
pages.getBoundingClientRect().top;
}
function goToPage(num) {
if (!doc || isNaN(num)) return;
const target = Math.min(Math.max(1, num), doc.numPages);
if (!slots[target - 1]) return;
scrollToPage(target);
currentPage = target;
// Pages below are still placeholders whose estimated heights shift
// as they rasterise, which would drag the reader off the page they
// asked for. Hold the target until the layout around it settles.
anchorPage = target;
anchorUntil = Date.now() + 800;
syncToolbar();
renderVisible();
}
// The page filling most of the viewport is the one the reader is on.
function updateCurrentPage() {
if (!slots.length) return;
// Don't fight a jump that is still settling.
if (Date.now() < anchorUntil) return;
const view = pages.getBoundingClientRect();
const middle = view.top + view.height / 2;
let page = 1;
for (let i = 0; i < slots.length; i++) {
const rect = slots[i].getBoundingClientRect();
if (rect.top <= middle) page = i + 1;
else break;
}
if (page !== currentPage) {
currentPage = page;
syncToolbar();
}
}
// ---------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------
function teardown() {
generation++;
slots.forEach(releaseSlot);
if (loadingTask) { Promise.resolve(loadingTask.destroy?.()).catch(() => {}); loadingTask = null; }
if (resizeTimer) {
clearTimeout(resizeTimer);
resizeTimer = null;
}
if (doc) {
doc.destroy();
doc = null;
}
slots = [];
currentPage = 1;
pages.innerHTML = "";
syncToolbar();
}
function showError() {
pages.innerHTML = "";
const msg = document.createElement("div");
msg.className = "pdf-viewer-error";
msg.textContent = "This PDF could not be displayed.";
pages.appendChild(msg);
}
function pageWidth() {
// clientWidth excludes the scrollbar, so the pages never overflow
// horizontally at 100%.
const available = Math.max(120, pages.clientWidth - 24);
return Math.round(Math.min(available, FIT_MAX_WIDTH) * zoom);
}
// Match the canvas backing store to the device pixel ratio, else text
// is visibly soft on HiDPI screens.
function renderPage(page, slot, record) {
const ratio = window.devicePixelRatio || 1;
const unscaled = page.getViewport({ scale: 1 });
const cssWidth = parseFloat(slot.style.width);
if (!cssWidth) return;
const viewport = page.getViewport({
scale: (cssWidth / unscaled.width) * ratio,
});
const canvas = document.createElement("canvas");
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = cssWidth + "px";
canvas.style.height = Math.floor(viewport.height / ratio) + "px";
slot.innerHTML = "";
slot.appendChild(canvas);
// The placeholder height was an estimate; the canvas is now the
// authority on how tall this page is.
slot.style.height = canvas.style.height;
record.task = page.render({
canvasContext: canvas.getContext("2d"),
viewport: viewport,
});
return record.task.promise;
}
function renderVisible() {
if (!doc) return;
const myGeneration = generation;
// Compared in viewport coordinates so this doesn't depend on which
// ancestor happens to be the slots' offsetParent. clientHeight, not
// the rect's height, bounds the window: on a hard page load the
// scroller can be measured before it is clipped, and trusting the
// rect there would rasterise the whole document at once.
const view = pages.getBoundingClientRect();
const top = view.top - PRERENDER_MARGIN;
const bottom = view.top + pages.clientHeight + PRERENDER_MARGIN;
const visible = slots.map((slot, i) => ({ slot, i, rect: slot.getBoundingClientRect() }))
.filter(item => item.rect.top <= bottom && item.rect.bottom >= top)
.sort((a, b) => Math.abs(a.rect.top - view.top) - Math.abs(b.rect.top - view.top)).slice(0, 6);
const keep = new Set(visible.map(item => item.slot));
for (const slot of renders.keys()) if (!keep.has(slot)) releaseSlot(slot);
for (const { slot, i } of visible) {
if (slot.dataset.state || rendering >= 2) continue;
const record = { cancelled: false, task: null };
renders.set(slot, record);
rendering++;
slot.dataset.state = "rendering";
let page;
doc.getPage(i + 1).then(value => {
page = value;
if (record.cancelled || myGeneration !== generation) return;
return renderPage(page, slot, record);
}).then(() => {
if (record.cancelled || myGeneration !== generation) return;
slot.dataset.state = "done";
if (anchorPage && Date.now() < anchorUntil) scrollToPage(anchorPage);
}).catch(() => {
if (!record.cancelled && myGeneration === generation) slot.dataset.state = "failed";
}).finally(() => {
rendering--;
page?.cleanup?.();
if (doc) renderVisible();
});
}
}
function schedule() {
if (scheduled) return;
scheduled = true;
window.requestAnimationFrame(function () {
scheduled = false;
updateCurrentPage();
renderVisible();
});
}
// Give layout a frame to settle before the first measurement; the
// timeout is the fallback for occluded tabs, where frame callbacks
// never run and the reader would otherwise see only placeholders.
function renderVisibleSoon() {
let ran = false;
function once() {
if (ran) return;
ran = true;
renderVisible();
}
window.requestAnimationFrame(once);
window.setTimeout(once, 50);
}
// Reset every page to a placeholder at the current width, then redraw
// whatever is on screen. Used for zoom changes and window resizes,
// both of which invalidate canvases rasterised at the old width.
function relayout() {
generation++;
slots.forEach(releaseSlot);
const width = pageWidth();
slots.forEach(function (slot) {
delete slot.dataset.state;
slot.innerHTML = "";
slot.style.width = width + "px";
slot.style.height = Math.round(width / aspect) + "px";
});
renderVisible();
}
function setZoom(value) {
if (!doc || !slots.length || value === zoom) return;
const anchor = currentPage;
zoom = value;
syncToolbar();
relayout();
goToPage(anchor);
}
function onResize() {
if (!doc) return;
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
resizeTimer = null;
if (doc && slots.length) relayout();
}, 250);
}
function load(url) {
teardown();
if (!url) return;
const myGeneration = generation;
loadingTask = pdfjsLib.getDocument({
url: url,
isEvalSupported: false,
// Needed for PDFs that reference the 14 standard fonts without
// embedding them, and for CJK encodings — without these the
// text silently fails to draw.
standardFontDataUrl: "/script/external/pdf-standard-fonts/",
cMapUrl: "/script/external/pdf-cmaps/",
cMapPacked: true,
});
loadingTask.promise.then(function (_doc) {
if (myGeneration !== generation) {
_doc.destroy();
return;
}
doc = _doc;
// Page 1's shape sizes the placeholders. Without a realistic
// estimate the scrollbar lies and every page counts as visible.
return doc.getPage(1).then(function (first) {
if (myGeneration !== generation) return;
const v = first.getViewport({ scale: 1 });
if (v.width && v.height) aspect = v.width / v.height;
const width = pageWidth();
for (let i = 1; i <= doc.numPages; i++) {
const slot = document.createElement("div");
slot.className = "pdf-viewer-page";
slot.style.width = width + "px";
slot.style.height = Math.round(width / aspect) + "px";
pages.appendChild(slot);
slots.push(slot);
}
syncToolbar();
renderVisibleSoon();
});
})
.catch(function () {
if (myGeneration !== generation) return;
showError();
});
}
pages.addEventListener("scroll", schedule, { passive: true });
$(window).on("resize", onResize);
syncToolbar();
watch(() => props.src, load, { immediate: true });
onBeforeUnmount(function () {
pages.removeEventListener("scroll", schedule);
$(window).off("resize", onResize);
teardown();
});
});
return () => h("pdfviewer", { ref: host });
},
};