zoom
This commit is contained in:
27
main.ts
27
main.ts
@@ -765,4 +765,29 @@ try {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
win.show();
|
win.show();
|
||||||
|
|
||||||
|
// Workaround: with the experimental desktop backend the first BrowserWindow
|
||||||
|
// "adopts" the implicit startup window, which is created before user code
|
||||||
|
// runs. Sometimes the native window keeps its built-in default size even
|
||||||
|
// though getSize() already reports the requested values, so the early
|
||||||
|
// setSize-on-mismatch above is skipped. Enforce the persisted size
|
||||||
|
// unconditionally after show(), and verify once more shortly after.
|
||||||
|
try {
|
||||||
|
win.setSize(clamped.windowWidth, clamped.windowHeight);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const [w, h] = win.getSize();
|
||||||
|
if (w !== clamped.windowWidth || h !== clamped.windowHeight) {
|
||||||
|
console.log(
|
||||||
|
`${PREFIX} Window size drifted to ${w}x${h}, enforcing ${clamped.windowWidth}x${clamped.windowHeight}`,
|
||||||
|
);
|
||||||
|
win.setSize(clamped.windowWidth, clamped.windowHeight);
|
||||||
|
}
|
||||||
|
} catch { /* ok */ }
|
||||||
|
}, 250);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`${PREFIX} Post-show size enforcement failed: ${errorMessage(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,38 @@
|
|||||||
// PDF.js viewer using pdfjs-dist 6.1.200 legacy build.
|
// PDF.js viewer using pdfjs-dist 6.1.200 legacy build.
|
||||||
// Depends on global pdfjsLib (loaded in index.html).
|
// 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 {
|
class PdfViewer {
|
||||||
constructor(container) {
|
constructor(container) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
this.generation = 0;
|
this.generation = 0;
|
||||||
this.loadingTask = null;
|
this.loadingTask = null;
|
||||||
this.doc = null;
|
this.doc = null;
|
||||||
|
this.numPages = 0;
|
||||||
this.renderTasks = new Set();
|
this.renderTasks = new Set();
|
||||||
this.lastUrl = null;
|
this.lastUrl = null;
|
||||||
this.lastSize = 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._createDOM();
|
||||||
|
this._observeResize();
|
||||||
}
|
}
|
||||||
|
|
||||||
_createDOM() {
|
_createDOM() {
|
||||||
// The pages container is always visible (never hidden).
|
// The pages container is always visible (never hidden).
|
||||||
// Loading and error states are overlays on top.
|
// Loading and error states are overlays on top.
|
||||||
this.container.innerHTML = `
|
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-pages"></div>
|
||||||
<div class="pdf-v-overlay pdf-v-loading" hidden>Loading PDF\u2026</div>
|
<div class="pdf-v-overlay pdf-v-loading" hidden>Loading PDF\u2026</div>
|
||||||
<div class="pdf-v-overlay pdf-v-error" hidden>
|
<div class="pdf-v-overlay pdf-v-error" hidden>
|
||||||
@@ -24,6 +40,8 @@ class PdfViewer {
|
|||||||
<button class="pdf-v-retry">Retry</button>
|
<button class="pdf-v-retry">Retry</button>
|
||||||
</div>
|
</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.pagesDiv = this.container.querySelector(".pdf-v-pages");
|
||||||
this.loadingDiv = this.container.querySelector(".pdf-v-loading");
|
this.loadingDiv = this.container.querySelector(".pdf-v-loading");
|
||||||
this.errorDiv = this.container.querySelector(".pdf-v-error");
|
this.errorDiv = this.container.querySelector(".pdf-v-error");
|
||||||
@@ -36,16 +54,90 @@ class PdfViewer {
|
|||||||
this._loadInternal(this.lastUrl, this.lastSize);
|
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) {
|
// ---- Zoom ----
|
||||||
this.loadingDiv.hidden = true;
|
|
||||||
this.errorDiv.hidden = true;
|
setZoom(zoom) {
|
||||||
if (state === "loading") this.loadingDiv.hidden = false;
|
const clamped = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom));
|
||||||
if (state === "error") this.errorDiv.hidden = false;
|
if (Math.abs(clamped - this.zoom) < 0.001) return;
|
||||||
this._viewerState = state;
|
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) {
|
async load(url, byteSize) {
|
||||||
this.lastUrl = url;
|
this.lastUrl = url;
|
||||||
this.lastSize = byteSize;
|
this.lastSize = byteSize;
|
||||||
@@ -87,50 +179,25 @@ class PdfViewer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.doc = doc;
|
this.doc = doc;
|
||||||
|
this.numPages = doc.numPages;
|
||||||
|
|
||||||
const numPages = doc.numPages;
|
|
||||||
const loadMs = Math.round(performance.now() - loadStart);
|
const loadMs = Math.round(performance.now() - loadStart);
|
||||||
console.log(
|
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();
|
this.toolbar.hidden = false;
|
||||||
console.log(`[BizMatch QC] PDF usable render width: ${viewerWidth}px`);
|
this._updateZoomLabel();
|
||||||
|
|
||||||
const pageStart = performance.now();
|
await this._renderAllPages(doc, generation, true);
|
||||||
await this._renderPage(doc, 1, viewerWidth, generation);
|
|
||||||
if (generation !== this.generation) return;
|
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);
|
const totalMs = Math.round(performance.now() - loadStart);
|
||||||
console.log(
|
console.log(
|
||||||
`[BizMatch QC] PDF viewer load completed: generation ${generation}`,
|
`[BizMatch QC] PDF viewer load completed: generation ${generation}`,
|
||||||
);
|
);
|
||||||
console.log(
|
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) {
|
} catch (err) {
|
||||||
if (generation !== this.generation) return;
|
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() {
|
_getViewportWidth() {
|
||||||
const style = globalThis.getComputedStyle
|
const style = globalThis.getComputedStyle
|
||||||
? getComputedStyle(this.pagesDiv)
|
? getComputedStyle(this.pagesDiv)
|
||||||
@@ -181,6 +283,7 @@ class PdfViewer {
|
|||||||
await this.doc.destroy();
|
await this.doc.destroy();
|
||||||
} catch { /* ok */ }
|
} catch { /* ok */ }
|
||||||
this.doc = null;
|
this.doc = null;
|
||||||
|
this.numPages = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,8 +350,15 @@ class PdfViewer {
|
|||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
this.generation = 0;
|
this.generation = 0;
|
||||||
|
clearTimeout(this._resizeTimer);
|
||||||
|
clearTimeout(this._zoomTimer);
|
||||||
|
if (this.resizeObserver) {
|
||||||
|
this.resizeObserver.disconnect();
|
||||||
|
this.resizeObserver = null;
|
||||||
|
}
|
||||||
this._disposeCurrentDocument().catch(() => {});
|
this._disposeCurrentDocument().catch(() => {});
|
||||||
this.pagesDiv.innerHTML = "";
|
this.pagesDiv.innerHTML = "";
|
||||||
|
this.toolbar.hidden = true;
|
||||||
this._setState("loading");
|
this._setState("loading");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,7 +367,7 @@ function isCancellationError(err) {
|
|||||||
if (!(err instanceof Error)) return false;
|
if (!(err instanceof Error)) return false;
|
||||||
const msg = err.message || "";
|
const msg = err.message || "";
|
||||||
return msg.includes("cancelled") ||
|
return msg.includes("cancelled") ||
|
||||||
msg.includes("cancelled") ||
|
msg.includes("canceled") ||
|
||||||
msg.includes("destroyed") ||
|
msg.includes("destroyed") ||
|
||||||
msg.includes("Worker was destroyed");
|
msg.includes("Worker was destroyed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,11 +229,12 @@ hr.field-sep {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
overflow-x: hidden;
|
overflow-x: auto;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
align-items: safe center; /* keeps left edge reachable when zoomed wider than the pane */
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
@@ -245,3 +246,38 @@ hr.field-sep {
|
|||||||
.pdf-v-page canvas {
|
.pdf-v-page canvas {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Zoom toolbar */
|
||||||
|
.pdf-v-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
background: rgba(35, 35, 38, 0.88);
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 4px;
|
||||||
|
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.pdf-v-toolbar button {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #e5e5e5;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 5px 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.pdf-v-toolbar button:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
.pdf-v-zoom-label {
|
||||||
|
min-width: 52px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: #ccc !important;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user