diff --git a/main.ts b/main.ts index ef50d57..2b9125f 100644 --- a/main.ts +++ b/main.ts @@ -765,4 +765,29 @@ try { ); } -win.show(); \ No newline at end of file +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)}`, + ); +} \ No newline at end of file diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 224f930..2f369f2 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -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 = ` +
`; + 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"); } diff --git a/web/styles.css b/web/styles.css index db4edc5..69a2b87 100644 --- a/web/styles.css +++ b/web/styles.css @@ -229,11 +229,12 @@ hr.field-sep { width: 100%; height: 100%; overflow-y: scroll; - overflow-x: hidden; + overflow-x: auto; scrollbar-gutter: stable; display: flex; flex-direction: column; align-items: center; + align-items: safe center; /* keeps left edge reachable when zoomed wider than the pane */ gap: 16px; padding: 12px; } @@ -245,3 +246,38 @@ hr.field-sep { .pdf-v-page canvas { 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; +} \ No newline at end of file