This commit is contained in:
2026-07-15 22:52:53 -05:00
parent ba16f5fe06
commit a60d03a3a7
3 changed files with 211 additions and 40 deletions

View File

@@ -1,22 +1,38 @@
// PDF.js viewer using pdfjs-dist 6.1.200 legacy build.
// Depends on global pdfjsLib (loaded in index.html).
const ZOOM_MIN = 0.25;
const ZOOM_MAX = 5;
const ZOOM_STEP = 1.2; // multiplicative
class PdfViewer {
constructor(container) {
this.container = container;
this.generation = 0;
this.loadingTask = null;
this.doc = null;
this.numPages = 0;
this.renderTasks = new Set();
this.lastUrl = null;
this.lastSize = null;
this.zoom = 1; // multiplier on fit-to-width (1 = fit width)
this._lastRenderWidth = 0;
this._resizeTimer = null;
this._zoomTimer = null;
this.resizeObserver = null;
this._createDOM();
this._observeResize();
}
_createDOM() {
// The pages container is always visible (never hidden).
// Loading and error states are overlays on top.
this.container.innerHTML = `
<div class="pdf-v-toolbar" hidden>
<button class="pdf-v-zoom-out" title="Verkleinern (Strg+Mausrad)">\u2212</button>
<button class="pdf-v-zoom-label" title="Auf Seitenbreite einpassen">100%</button>
<button class="pdf-v-zoom-in" title="Vergr\u00f6\u00dfern (Strg+Mausrad)">+</button>
</div>
<div class="pdf-v-pages"></div>
<div class="pdf-v-overlay pdf-v-loading" hidden>Loading PDF\u2026</div>
<div class="pdf-v-overlay pdf-v-error" hidden>
@@ -24,6 +40,8 @@ class PdfViewer {
<button class="pdf-v-retry">Retry</button>
</div>
`;
this.toolbar = this.container.querySelector(".pdf-v-toolbar");
this.zoomLabel = this.container.querySelector(".pdf-v-zoom-label");
this.pagesDiv = this.container.querySelector(".pdf-v-pages");
this.loadingDiv = this.container.querySelector(".pdf-v-loading");
this.errorDiv = this.container.querySelector(".pdf-v-error");
@@ -36,16 +54,90 @@ class PdfViewer {
this._loadInternal(this.lastUrl, this.lastSize);
}
});
this.container.querySelector(".pdf-v-zoom-in").addEventListener(
"click",
() => this.setZoom(this.zoom * ZOOM_STEP),
);
this.container.querySelector(".pdf-v-zoom-out").addEventListener(
"click",
() => this.setZoom(this.zoom / ZOOM_STEP),
);
// Clicking the percentage resets to fit-width.
this.zoomLabel.addEventListener("click", () => this.setZoom(1));
// Ctrl + mouse wheel zoom, like in browsers / PDF readers.
this.pagesDiv.addEventListener("wheel", (e) => {
if (!e.ctrlKey) return;
e.preventDefault();
this.setZoom(this.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
}, { passive: false });
}
_setState(state) {
this.loadingDiv.hidden = true;
this.errorDiv.hidden = true;
if (state === "loading") this.loadingDiv.hidden = false;
if (state === "error") this.errorDiv.hidden = false;
this._viewerState = state;
// ---- Zoom ----
setZoom(zoom) {
const clamped = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom));
if (Math.abs(clamped - this.zoom) < 0.001) return;
this.zoom = clamped;
this._updateZoomLabel();
if (!this.doc) return;
// Debounce so repeated +/+/+ or wheel ticks trigger one re-render.
clearTimeout(this._zoomTimer);
this._zoomTimer = setTimeout(() => this._rerender(), 120);
}
_updateZoomLabel() {
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
}
// ---- Resize ----
_observeResize() {
if (typeof ResizeObserver === "undefined") return;
this.resizeObserver = new ResizeObserver(() => {
if (!this.doc) return;
const width = this._renderWidth();
if (Math.abs(width - this._lastRenderWidth) < 2) return;
clearTimeout(this._resizeTimer);
this._resizeTimer = setTimeout(() => {
console.log(
`[BizMatch QC] PDF viewer resized, re-rendering at ${this._renderWidth()}px`,
);
this._rerender();
}, 150);
});
this.resizeObserver.observe(this.container);
}
_renderWidth() {
return Math.max(Math.round(this._getViewportWidth() * this.zoom), 100);
}
// Re-render all pages of the already-loaded document (zoom / resize),
// preserving the relative scroll position.
async _rerender() {
if (!this.doc) return;
const generation = ++this.generation;
for (const rt of this.renderTasks) {
try {
rt.cancel();
} catch { /* ok */ }
}
this.renderTasks.clear();
const scrollRatio = this.pagesDiv.scrollHeight > 0
? this.pagesDiv.scrollTop / this.pagesDiv.scrollHeight
: 0;
await this._renderAllPages(this.doc, generation, false);
if (generation !== this.generation) return;
this.pagesDiv.scrollTop = scrollRatio * this.pagesDiv.scrollHeight;
}
// ---- Loading ----
async load(url, byteSize) {
this.lastUrl = url;
this.lastSize = byteSize;
@@ -87,50 +179,25 @@ class PdfViewer {
return;
}
this.doc = doc;
this.numPages = doc.numPages;
const numPages = doc.numPages;
const loadMs = Math.round(performance.now() - loadStart);
console.log(
`[BizMatch QC] PDF document loaded: ${numPages} pages in ${loadMs}ms`,
`[BizMatch QC] PDF document loaded: ${this.numPages} pages in ${loadMs}ms`,
);
const viewerWidth = this._getViewportWidth();
console.log(`[BizMatch QC] PDF usable render width: ${viewerWidth}px`);
this.toolbar.hidden = false;
this._updateZoomLabel();
const pageStart = performance.now();
await this._renderPage(doc, 1, viewerWidth, generation);
await this._renderAllPages(doc, generation, true);
if (generation !== this.generation) return;
this._setState(null); // Remove loading overlay
const firstPageMs = Math.round(performance.now() - pageStart);
console.log(
`[BizMatch QC] PDF page 1 rendered in ${firstPageMs}ms`,
);
console.log(
`[BizMatch QC] First page visible after ${
Math.round(performance.now() - loadStart)
}ms`,
);
for (let p = 2; p <= numPages; p++) {
if (generation !== this.generation) return;
const pStart = performance.now();
await this._renderPage(doc, p, viewerWidth, generation);
if (generation !== this.generation) return;
console.log(
`[BizMatch QC] PDF page ${p} rendered in ${
Math.round(performance.now() - pStart)
}ms`,
);
await new Promise((r) => requestAnimationFrame(r));
}
const totalMs = Math.round(performance.now() - loadStart);
console.log(
`[BizMatch QC] PDF viewer load completed: generation ${generation}`,
);
console.log(
`[BizMatch QC] PDF fully rendered: ${numPages} pages in ${totalMs}ms`,
`[BizMatch QC] PDF fully rendered: ${this.numPages} pages in ${totalMs}ms`,
);
} catch (err) {
if (generation !== this.generation) return;
@@ -148,6 +215,41 @@ class PdfViewer {
}
}
// Shared render loop for initial load, zoom and resize.
// hideLoadingAfterFirstPage: true on initial load (removes the overlay
// as soon as page 1 is visible).
async _renderAllPages(doc, generation, hideLoadingAfterFirstPage) {
this.pagesDiv.innerHTML = "";
const viewerWidth = this._renderWidth();
this._lastRenderWidth = viewerWidth;
console.log(`[BizMatch QC] PDF render width: ${viewerWidth}px`);
for (let p = 1; p <= this.numPages; p++) {
if (generation !== this.generation) return;
const pStart = performance.now();
await this._renderPage(doc, p, viewerWidth, generation);
if (generation !== this.generation) return;
if (p === 1 && hideLoadingAfterFirstPage) {
this._setState(null); // Remove loading overlay
}
console.log(
`[BizMatch QC] PDF page ${p} rendered in ${
Math.round(performance.now() - pStart)
}ms`,
);
await new Promise((r) => requestAnimationFrame(r));
}
}
_setState(state) {
this.loadingDiv.hidden = true;
this.errorDiv.hidden = true;
if (state === "loading") this.loadingDiv.hidden = false;
if (state === "error") this.errorDiv.hidden = false;
this._viewerState = state;
}
_getViewportWidth() {
const style = globalThis.getComputedStyle
? getComputedStyle(this.pagesDiv)
@@ -181,6 +283,7 @@ class PdfViewer {
await this.doc.destroy();
} catch { /* ok */ }
this.doc = null;
this.numPages = 0;
}
}
@@ -247,8 +350,15 @@ class PdfViewer {
destroy() {
this.generation = 0;
clearTimeout(this._resizeTimer);
clearTimeout(this._zoomTimer);
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
this._disposeCurrentDocument().catch(() => {});
this.pagesDiv.innerHTML = "";
this.toolbar.hidden = true;
this._setState("loading");
}
}
@@ -257,7 +367,7 @@ function isCancellationError(err) {
if (!(err instanceof Error)) return false;
const msg = err.message || "";
return msg.includes("cancelled") ||
msg.includes("cancelled") ||
msg.includes("canceled") ||
msg.includes("destroyed") ||
msg.includes("Worker was destroyed");
}