"use strict"; (() => { // src/types.ts var PageState = { PLACEHOLDER: 0, RENDERING: 1, RENDERED: 2 }; // src/page-manager.ts var MAX_CANVAS_PIXELS = 16777216; function clampedRenderScale(vpWidth, vpHeight, desiredScale, dpr) { const w = vpWidth * desiredScale * dpr; const h = vpHeight * desiredScale * dpr; const pixels = w * h; if (pixels <= MAX_CANVAS_PIXELS) return desiredScale * dpr; return desiredScale * dpr * Math.sqrt(MAX_CANVAS_PIXELS / pixels); } var PageManager = class { constructor(wrapper, dims, initialScale, dpr, dispatchRender2) { this._wrappers = []; this._renderGen = 0; this._inFlight = 0; this._zooming = false; this._wrapper = wrapper; this._dims = dims; this._scale = initialScale; this._dpr = dpr; this._dispatchRender = dispatchRender2; this._states = new Array(dims.length).fill(PageState.PLACEHOLDER); this._canvases = new Array(dims.length).fill(null); this._buildPlaceholders(); } get pageWrappers() { return this._wrappers; } get numPages() { return this._dims.length; } get renderGen() { return this._renderGen; } /** True when no renders are currently in flight (all visible pages are sharp). */ get allRendered() { return this._inFlight === 0; } /** Called by ZoomController to suppress canvas teardown during Phase 1 CSS zoom. */ setZooming(z) { this._zooming = z; } _buildPlaceholders() { for (let i = 0; i < this._dims.length; i++) { const dim = this._dims[i]; const wrap = document.createElement("div"); wrap.className = "page-wrapper"; wrap.dataset["page"] = String(i + 1); wrap.style.width = dim.width * this._scale + "px"; wrap.style.height = dim.height * this._scale + "px"; this._wrapper.appendChild(wrap); this._wrappers.push(wrap); } } /** * Called on each IntersectionObserver tick. * Dispatches renders for pages entering the buffer (visible pages first). * Cleans up pages that have left the buffer (unless zooming). */ reconcile(bufferSet, visibleSet) { const gen = this._renderGen; const toRender = [ ...[...visibleSet].sort((a, b) => a - b), ...[...bufferSet].filter((p) => !visibleSet.has(p)).sort((a, b) => a - b) ]; for (const pageNum of toRender) { const i = pageNum - 1; if (i < 0 || i >= this._dims.length) continue; if (this._states[i] === PageState.PLACEHOLDER) { this._startRender(i, gen); } } for (let i = 0; i < this._dims.length; i++) { const pageNum = i + 1; if (!this._zooming && !bufferSet.has(pageNum) && this._states[i] === PageState.RENDERED) { this._cleanup(i); } if (!bufferSet.has(pageNum) && this._states[i] === PageState.RENDERING) { this._inFlight--; this._states[i] = PageState.PLACEHOLDER; } } } _startRender(i, gen) { const dim = this._dims[i]; const scale = clampedRenderScale(dim.width, dim.height, this._scale, this._dpr); this._states[i] = PageState.RENDERING; this._inFlight++; this._dispatchRender(i + 1, scale, gen); } _cleanup(i) { if (this._states[i] === PageState.RENDERING) this._inFlight--; this._states[i] = PageState.PLACEHOLDER; const canvas = this._canvases[i]; if (canvas) { canvas.remove(); this._canvases[i] = null; } } /** * Called when the render worker returns a finished bitmap. * * Double-buffer swap: the old canvas stays in the DOM until the new one is * ready, then both mutations happen in the same synchronous JS turn so the * browser produces exactly one paint frame for the transition. */ onRendered(pageNum, gen, bitmap) { if (gen !== this._renderGen) { bitmap.close(); return; } const i = pageNum - 1; if (i < 0 || i >= this._dims.length || this._states[i] !== PageState.RENDERING) { bitmap.close(); return; } this._inFlight--; const dim = this._dims[i]; const wrap = this._wrappers[i]; const cssW = dim.width * this._scale; const cssH = dim.height * this._scale; const canvas = document.createElement("canvas"); canvas.width = bitmap.width; canvas.height = bitmap.height; canvas.style.width = cssW + "px"; canvas.style.height = cssH + "px"; canvas.style.display = "block"; const bitmapCtx = canvas.getContext("bitmaprenderer"); if (bitmapCtx) { bitmapCtx.transferFromImageBitmap(bitmap); } else { canvas.getContext("2d").drawImage(bitmap, 0, 0); bitmap.close(); } wrap.style.width = cssW + "px"; wrap.style.height = cssH + "px"; const old = this._canvases[i]; if (old) old.remove(); wrap.appendChild(canvas); this._canvases[i] = canvas; this._states[i] = PageState.RENDERED; } /** * Called after the zoom debounce fires (Phase 2). * Increments renderGen to cancel all stale in-flight renders, resizes * placeholders, then re-dispatches renders for the buffer zone. */ onScaleChange(newScale, bufferSet, visibleSet) { this._scale = newScale; this._renderGen++; for (let i = 0; i < this._dims.length; i++) { const pageNum = i + 1; const dim = this._dims[i]; const wrap = this._wrappers[i]; const cssW = dim.width * newScale; const cssH = dim.height * newScale; wrap.style.width = cssW + "px"; wrap.style.height = cssH + "px"; wrap.style.setProperty("zoom", "1"); if (bufferSet.has(pageNum)) { const canvas = this._canvases[i]; if (canvas) { canvas.style.width = cssW + "px"; canvas.style.height = cssH + "px"; } if (this._states[i] === PageState.RENDERING) this._inFlight--; this._states[i] = PageState.PLACEHOLDER; } else { const canvas = this._canvases[i]; if (canvas) { canvas.remove(); this._canvases[i] = null; } this._states[i] = PageState.PLACEHOLDER; } } this.reconcile(bufferSet, visibleSet); } /** Returns the 1-based page number of the topmost visible page. */ getCurrentPage(visibleSet) { if (visibleSet.size > 0) return Math.min(...visibleSet); const top = this._wrapper.parentElement?.getBoundingClientRect().top ?? 0; for (const wrap of this._wrappers) { if (wrap.getBoundingClientRect().bottom > top + 4) { return parseInt(wrap.dataset["page"], 10); } } return 1; } }; // src/viewport-tracker.ts var ViewportTracker = class { constructor(root, pageWrappers, onChange) { this._visibleSet = /* @__PURE__ */ new Set(); this._bufferSet = /* @__PURE__ */ new Set(); this._visibleObserver = null; this._bufferObserver = null; this._rafPending = null; this._onChange = onChange; this._observe(root, pageWrappers); } _observe(root, pageWrappers) { const scheduleNotify = () => { if (this._rafPending !== null) return; this._rafPending = requestAnimationFrame(() => { this._rafPending = null; for (const p of this._visibleSet) this._bufferSet.add(p); this._onChange(new Set(this._bufferSet), new Set(this._visibleSet)); }); }; this._visibleObserver = new IntersectionObserver((entries) => { for (const e of entries) { const page = parseInt(e.target.dataset["page"], 10); if (e.isIntersecting) this._visibleSet.add(page); else this._visibleSet.delete(page); } scheduleNotify(); }, { root, rootMargin: "0px", threshold: 0 }); this._bufferObserver = new IntersectionObserver((entries) => { for (const e of entries) { const page = parseInt(e.target.dataset["page"], 10); if (e.isIntersecting) this._bufferSet.add(page); else this._bufferSet.delete(page); } scheduleNotify(); }, { root, rootMargin: "200% 0px", threshold: 0 }); for (const wrap of pageWrappers) { this._visibleObserver.observe(wrap); this._bufferObserver.observe(wrap); } } /** Observe a newly-added page wrapper (unused currently, kept for extensibility). */ observe(wrap) { this._visibleObserver?.observe(wrap); this._bufferObserver?.observe(wrap); } disconnect() { this._visibleObserver?.disconnect(); this._bufferObserver?.disconnect(); this._visibleSet.clear(); this._bufferSet.clear(); if (this._rafPending !== null) { cancelAnimationFrame(this._rafPending); this._rafPending = null; } } }; // src/zoom-controller.ts var CONTENT_PADDING_TOP = 20; var PAGE_GAP = 12; var _ZoomController = class _ZoomController { constructor(container2, pageManager2, onReRender, getBuffer, zoomLabel2, initialScale) { this._debounce = null; this._rafPending = null; this._container = container2; this._pm = pageManager2; this._onReRender = onReRender; this._getBuffer = getBuffer; this._zoomLabel = zoomLabel2; this._scale = initialScale; this._renderScale = initialScale; this._updateLabel(); this._bindScrollZoom(); } get scale() { return this._scale; } clamp(s) { return Math.max(_ZoomController.ZOOM_MIN, Math.min(_ZoomController.ZOOM_MAX, s)); } _updateLabel() { this._zoomLabel.textContent = Math.round(this._scale * 100) + "%"; } /** * Apply a new zoom level. * * @param newScale - Target zoom scale. * @param anchorY - Pixel offset within container to hold fixed (default: center). * @param anchorX - Pixel offset within container to hold fixed (default: center). */ applyScale(newScale, anchorY, anchorX) { const container2 = this._container; const oldScale = this._scale; this._scale = this.clamp(newScale); this._updateLabel(); this._pm.setZooming(true); if (anchorY === void 0) anchorY = container2.clientHeight / 2; if (anchorX === void 0) anchorX = container2.clientWidth / 2; const anchorContentY = container2.scrollTop + anchorY; let fixedAbove = CONTENT_PADDING_TOP; let cumY = CONTENT_PADDING_TOP; for (const wrap of this._pm.pageWrappers) { const zoom = parseFloat(wrap.style.getPropertyValue("zoom") || "1") || 1; const h = parseFloat(wrap.style.height) * zoom; if (cumY + h > anchorContentY) break; cumY += h + PAGE_GAP; fixedAbove += PAGE_GAP; } const cssZoom = this._scale / this._renderScale; for (const wrap of this._pm.pageWrappers) { wrap.style.setProperty("zoom", String(cssZoom)); } const ratio = this._scale / oldScale; const scalable = container2.scrollTop + anchorY - fixedAbove; container2.scrollTop = Math.max(0, fixedAbove + scalable * ratio - anchorY); container2.scrollLeft = Math.max(0, (container2.scrollLeft + anchorX) * ratio - anchorX); this._scheduleReRender(); } _scheduleReRender() { if (this._rafPending !== null) cancelAnimationFrame(this._rafPending); this._rafPending = requestAnimationFrame(() => { this._rafPending = null; if (this._debounce !== null) clearTimeout(this._debounce); this._debounce = setTimeout(() => this._triggerReRender(), 250); }); } _triggerReRender() { this._pm.setZooming(false); this._renderScale = this._scale; const { bufferSet, visibleSet } = this._getBuffer(); this._onReRender(this._scale, bufferSet, visibleSet); } _bindScrollZoom() { this._container.addEventListener("wheel", (ev) => { if (!ev.ctrlKey) return; ev.preventDefault(); const rect = this._container.getBoundingClientRect(); const anchorY = ev.clientY - rect.top; const anchorX = ev.clientX - rect.left; this.applyScale( this._scale * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), anchorY, anchorX ); }, { passive: false }); } }; _ZoomController.ZOOM_MIN = 0.1; _ZoomController.ZOOM_MAX = 5; var ZoomController = _ZoomController; // src/viewer.ts if (typeof requestIdleCallback === "undefined") { self["requestIdleCallback"] = (cb) => setTimeout(() => cb({ timeRemaining: () => 50, didTimeout: false }), 1); } var container = document.getElementById("canvas-container"); var pagesWrapper = document.getElementById("pages-wrapper"); var statusEl = document.getElementById("status"); var zoomLabel = document.getElementById("zoom-label"); var pageIndicator = document.getElementById("page-indicator"); var params = new URLSearchParams(location.search); var refId = params.get("ref_id") ?? ""; var savedZoom = parseFloat(params.get("zoom") ?? ""); var savedScrollTop = parseFloat(params.get("scroll_top") ?? ""); var DPR = window.devicePixelRatio || 1; var pageManager = null; var viewportTracker = null; var zoomController = null; var renderWorker = null; var currentBufferSet = /* @__PURE__ */ new Set(); var currentVisibleSet = /* @__PURE__ */ new Set(); 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}`; } function scrollToPage(pageNum) { if (!pageManager) return; const wrap = pageManager.pageWrappers[pageNum - 1]; if (!wrap) return; container.scrollTop += wrap.getBoundingClientRect().top - container.getBoundingClientRect().top; } function sendViewerState() { if (!zoomController || window.parent === window) return; const msg = { type: "brittle:viewer-state", refId, zoom: zoomController.scale, scrollTop: container.scrollTop }; window.parent.postMessage(msg, "*"); } function dispatchRender(pageNum, scale, gen) { const msg = { type: "render", pageNum, scale, gen }; renderWorker?.postMessage(msg); } function onVisibilityChange(bufferSet, visibleSet) { currentBufferSet = bufferSet; currentVisibleSet = visibleSet; pageManager?.reconcile(bufferSet, visibleSet); refreshPageIndicator(); } function fitScale(dims) { const dim = dims[0] ?? { width: 595, height: 842 }; return Math.max(0.1, Math.min(5, Math.min( (container.clientWidth - 40) / dim.width, (container.clientHeight - 40) / dim.height ))); } async function load() { if (!refId) { showError("No ref_id in URL."); return; } setStatus("Loading\u2026"); try { const [workerBlob, pdfBuf] = await Promise.all([ fetch("brittle://app/viewer/render-worker.bundle.js").then((r) => r.blob()), fetch(`brittle://app/pdf?ref_id=${encodeURIComponent(refId)}`).then((r) => r.arrayBuffer()) ]); renderWorker = new Worker(URL.createObjectURL(workerBlob)); const { numPages, dims } = await new Promise((resolve, reject) => { renderWorker.onmessage = (ev) => { const msg = ev.data; if (msg.type === "ready") resolve({ numPages: msg.numPages, dims: msg.dims }); if (msg.type === "error") reject(new Error(msg.message)); }; renderWorker.onerror = (e) => reject(new Error(e.message)); const initMsg = { type: "init", pdfData: pdfBuf }; renderWorker.postMessage(initMsg, [pdfBuf]); }); const fitted = fitScale(dims); const initialScale = savedZoom > 0 ? Math.max(0.1, Math.min(5, savedZoom)) : fitted; pageManager = new PageManager( pagesWrapper, dims, initialScale, DPR, dispatchRender ); renderWorker.onmessage = (ev) => { const msg = ev.data; if (msg.type === "rendered") { pageManager?.onRendered(msg.pageNum, msg.gen, msg.bitmap); refreshPageIndicator(); if (pageManager?.allRendered) setStatus("Ready"); } else if (msg.type === "error") { console.warn("[viewer] worker error:", msg.message); } }; viewportTracker = new ViewportTracker( container, [...pageManager.pageWrappers], onVisibilityChange ); zoomController = new ZoomController( container, pageManager, (newScale, bufferSet, visibleSet) => { pageManager.onScaleChange(newScale, bufferSet, visibleSet); sendViewerState(); }, () => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }), zoomLabel, initialScale ); 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", () => zoomController.applyScale(fitScale(dims)) ); 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(); zoomController.applyScale(fitScale(dims)); } if (window.parent !== window) { const msg = { type: "brittle:keydown", key: ev.key, ctrlKey: ev.ctrlKey, shiftKey: ev.shiftKey, altKey: ev.altKey, metaKey: ev.metaKey }; window.parent.postMessage(msg, "*"); } }); let scrollSaveTimer = null; container.addEventListener("scroll", () => { refreshPageIndicator(); if (scrollSaveTimer !== null) clearTimeout(scrollSaveTimer); scrollSaveTimer = setTimeout(sendViewerState, 500); }, { passive: true }); if (savedScrollTop > 0) { requestAnimationFrame(() => { container.scrollTop = savedScrollTop; }); } window.addEventListener("message", (ev) => { if (ev.data === "pdf.page.next") { scrollToPage(Math.min( pageManager.getCurrentPage(currentVisibleSet) + 1, numPages )); } if (ev.data === "pdf.page.prev") { scrollToPage(Math.max( pageManager.getCurrentPage(currentVisibleSet) - 1, 1 )); } }); document.addEventListener("visibilitychange", () => { if (document.hidden) { renderWorker?.postMessage({ type: "cleanup" }); } else { pageManager?.reconcile(currentBufferSet, currentVisibleSet); } }); matchMedia(`(resolution: ${DPR}dppx)`).addEventListener("change", () => { if (pageManager && zoomController) { pageManager.onScaleChange( zoomController.scale, currentBufferSet, currentVisibleSet ); } }); window.addEventListener("beforeunload", () => { viewportTracker?.disconnect(); renderWorker?.postMessage({ type: "destroy" }); renderWorker = null; }); pageIndicator.textContent = `1 / ${numPages}`; setStatus("Rendering\u2026"); } catch (e) { showError("Could not load PDF: " + (e.message ?? String(e))); } } load(); })();