Files
anonymous_github/public/script/pdf-viewer.js
T
Thomas DurieuxandClaude Opus 5.5 c044b83541 feat(ui): one consistent design across author, reviewer and admin screens
The UI was uneven: the home page looked finished, but app pages used three
different display typefaces, the anonymize flow changed frame between steps,
and the dashboard, explorer and admin screens each had their own controls,
status colours and edge-case handling.

Make every screen follow one system: shared page header, grid, buttons,
filters and status colours; a repository picker and step trail on anonymize;
a "What reviewers will see" preview of the actual reviewer page; consistent
file headers and viewers for every file type; truthful admin charts and
tables. Fold the accumulated override blocks back into style.css (22%
smaller, no visual change).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 02:22:45 +00:00

427 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;
// Page 1's width in PDF points; 100% means one point is 1/72 inch.
let baseWidth = 0;
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;
// At the fit zoom the page fills the column, whatever its real
// scale, so say "Fit"; other steps show the true size.
zoomLabel.textContent = zoom === 1 || !baseWidth
? "Fit"
: Math.round((pageWidth() / (baseWidth * 96 / 72)) * 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;
baseWidth = v.width || 0;
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 });
},
};