/** * viewer.js — PDF viewer orchestrator. * * Rendering happens on the main thread using the pdfDoc loaded here. * PDF.js's own sub-worker (pdf.worker.min.js) does the heavy parsing and * rasterisation off-thread; only the final bitmap transfer touches the * main thread. A separate render-worker is not used because Tauri's WebKit * webview does not support nested workers (workers spawned from workers), * which PDF.js requires for its own internal worker. * * Init sequence: * 1. Parse ref_id, configure PDF.js worker source * 2. Load pdfDoc (for viewports + rendering) * 3. Fetch all page viewports at scale=1 * 4. Create PageManager → N placeholder divs in #pages-wrapper * 5. Create ViewportTracker → observe placeholders * 6. Compute fit-to-width → create ZoomController * 7. Create MessageBridge * 8. Register lifecycle handlers * 9. Trigger initial reconcile */ import { ViewportTracker } from "./viewport-tracker.js"; import { PageManager } from "./page-manager.js"; import { ZoomController } from "./zoom-controller.js"; import { MessageBridge } from "./message-bridge.js"; // ── DOM refs ───────────────────────────────────────────────────────────────── const container = document.getElementById("canvas-container"); const pagesWrapper = document.getElementById("pages-wrapper"); const statusEl = document.getElementById("status"); const zoomLabel = document.getElementById("zoom-label"); const pageIndicator = document.getElementById("page-indicator"); // ── Global state ───────────────────────────────────────────────────────────── const refId = new URLSearchParams(location.search).get("ref_id") || ""; const DPR = window.devicePixelRatio || 1; let pdfDoc = null; let pageManager = null; let viewportTracker = null; let zoomController = null; let bridge = null; let currentBufferSet = new Set(); let currentVisibleSet = new Set(); // ── Utilities ──────────────────────────────────────────────────────────────── function setStatus(msg) { statusEl.textContent = msg; } function showError(msg) { const b = document.getElementById("error-banner"); b.textContent = msg; b.style.display = "block"; setStatus("Error"); } function refreshPageIndicator() { if (!pageManager) return; const cur = pageManager.getCurrentPage(currentVisibleSet); pageIndicator.textContent = cur + " / " + pageManager.numPages; } async function fitToWidth() { if (!pdfDoc) return 1.0; const page = await pdfDoc.getPage(1); const vp = page.getViewport({ scale: 1.0 }); const avail = container.clientWidth - 40; return Math.max(0.1, Math.min(5.0, avail / vp.width)); } function scrollToPage(pageNum) { if (!pageManager) return; const wrap = pageManager.pageWrappers[pageNum - 1]; if (!wrap) return; container.scrollTop += wrap.getBoundingClientRect().top - container.getBoundingClientRect().top; } // ── Main-thread rendering ──────────────────────────────────────────────────── // dispatchRender is called by PageManager when a page enters the buffer. // We fire-and-forget an async render; the gen check inside discards stale work. function dispatchRender(pageNum, scale, _vpWidth, _vpHeight, gen) { renderPage(pageNum, scale, gen); } async function renderPage(pageNum, scale, gen) { if (!pdfDoc) return; let page; try { page = await pdfDoc.getPage(pageNum); if (gen !== pageManager?.renderGen) return; // superseded by zoom or cleanup const vp = page.getViewport({ scale }); const width = Math.round(vp.width); const height = Math.round(vp.height); const offscreen = new OffscreenCanvas(width, height); await page.render({ canvasContext: offscreen.getContext("2d"), viewport: vp }).promise; if (gen !== pageManager?.renderGen) return; // superseded during render const bitmap = offscreen.transferToImageBitmap(); pageManager?.onRendered(pageNum, gen, bitmap); refreshPageIndicator(); setStatus("Ready"); } catch (e) { if (e?.name !== "RenderingCancelledException") { console.warn("[viewer] render error page", pageNum, e); } } finally { page?.cleanup(); } } // ── Visibility change callback (called by ViewportTracker) ─────────────────── function onVisibilityChange(bufferSet, visibleSet) { currentBufferSet = bufferSet; currentVisibleSet = visibleSet; pageManager?.reconcile(bufferSet, visibleSet); refreshPageIndicator(); } // ── Main init ──────────────────────────────────────────────────────────────── async function load() { if (!refId) { showError("No ref_id in URL."); return; } setStatus("Loading…"); const pdfjsLib = window.pdfjsLib; if (!pdfjsLib) { showError("PDF.js failed to load."); return; } pdfjsLib.GlobalWorkerOptions.workerSrc = "brittle://app/pdfjs/build/pdf.worker.min.js"; const pdfUrl = "brittle://app/pdf?ref_id=" + encodeURIComponent(refId); try { // 1. Load PDF pdfDoc = await pdfjsLib.getDocument({ url: pdfUrl }).promise; // 2. Fetch all page viewports at scale=1 setStatus("Reading…"); const viewports = []; for (let i = 1; i <= pdfDoc.numPages; i++) { const page = await pdfDoc.getPage(i); const vp = page.getViewport({ scale: 1.0 }); viewports.push({ width: vp.width, height: vp.height }); } // 3. Compute initial fit-to-width scale const avail = container.clientWidth - 40; const initialScale = Math.max(0.1, Math.min(5.0, avail / viewports[0].width)); // 4. PageManager — creates placeholder divs pageManager = new PageManager( pagesWrapper, viewports, initialScale, DPR, dispatchRender, ); // 5. ViewportTracker — IntersectionObserver fires once elements are in the // DOM, triggering the initial reconcile automatically. viewportTracker = new ViewportTracker( container, pageManager.pageWrappers, onVisibilityChange, ); // 6. ZoomController zoomController = new ZoomController( container, pageManager, (newScale, bufferSet, visibleSet) => pageManager.onScaleChange(newScale, bufferSet, visibleSet), () => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }), zoomLabel, initialScale, ); // 7. MessageBridge bridge = new MessageBridge( () => scrollToPage(Math.min(pageManager.getCurrentPage(currentVisibleSet) + 1, pdfDoc.numPages)), () => scrollToPage(Math.max(pageManager.getCurrentPage(currentVisibleSet) - 1, 1)), ); // 8. Toolbar buttons document.getElementById("btn-zoom-out").addEventListener("click", () => zoomController.applyScale(zoomController.scale / 1.25)); document.getElementById("btn-zoom-in").addEventListener("click", () => zoomController.applyScale(zoomController.scale * 1.25)); document.getElementById("btn-zoom-fit").addEventListener("click", async () => zoomController.applyScale(await fitToWidth())); // Keyboard shortcuts + keydown forwarding to parent document.addEventListener("keydown", ev => { if (ev.target.tagName === "INPUT") return; if (ev.key === "+" || ev.key === "=") { ev.preventDefault(); zoomController.applyScale(zoomController.scale * 1.25); } if (ev.key === "-") { ev.preventDefault(); zoomController.applyScale(zoomController.scale / 1.25); } if (ev.key === "0") { ev.preventDefault(); fitToWidth().then(s => zoomController.applyScale(s)); } bridge.forwardKeydown(ev); }); // Scroll → update page indicator container.addEventListener("scroll", refreshPageIndicator, { passive: true }); // Lifecycle: tab hidden → free caches; tab visible → re-reconcile document.addEventListener("visibilitychange", () => { if (document.hidden) { pdfDoc?.cleanup(); } else { pageManager?.reconcile(currentBufferSet, currentVisibleSet); } }); // Cleanup on unload window.addEventListener("beforeunload", () => { viewportTracker?.disconnect(); bridge?.disconnect(); pdfDoc?.destroy(); }); // DPR change (e.g., moving window to a monitor with different DPI) matchMedia(`(resolution: ${DPR}dppx)`).addEventListener("change", () => { if (pageManager && zoomController) { pageManager.onScaleChange(zoomController.scale, currentBufferSet, currentVisibleSet); } }); pageIndicator.textContent = "1 / " + pdfDoc.numPages; setStatus("Rendering…"); } catch (e) { showError("Could not load PDF: " + (e.message ?? String(e))); } } load();