/** * PageManager — page lifecycle: PLACEHOLDER → RENDERING → RENDERED → PLACEHOLDER * * Each page is a sized div (placeholder). When a page enters the buffer, * a canvas is created and a render is dispatched to the worker. When a page * leaves the buffer, its canvas is removed and the page is reset to placeholder. */ const MAX_CANVAS_PIXELS = 16_777_216; // 4096 × 4096 /** * Clamp render scale so the canvas pixel count stays within the budget. * CSS dimensions stay correct — pages appear at the right size, just at * reduced effective DPI when zoomed very high. Same strategy as Chrome's viewer. */ 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); } const State = Object.freeze({ PLACEHOLDER: 0, RENDERING: 1, RENDERED: 2 }); export class PageManager { /** * @param {HTMLElement} wrapper - #pages-wrapper container (already in DOM) * @param {Array<{width:number,height:number}>} viewports - scale=1 viewports (0-indexed) * @param {number} initialScale - initial display scale * @param {number} dpr - devicePixelRatio * @param {Function} dispatchRender - (pageNum, scale, vpWidth, vpHeight, gen) => void */ constructor(wrapper, viewports, initialScale, dpr, dispatchRender) { this._wrapper = wrapper; this._viewports = viewports; this._scale = initialScale; this._dpr = dpr; this._dispatchRender = dispatchRender; this._renderGen = 0; this._inFlight = 0; // renders dispatched but not yet completed/cancelled this._zooming = false; // true during Phase 1 CSS zoom (before debounced re-render) this._states = new Array(viewports.length).fill(State.PLACEHOLDER); this._canvases = new Array(viewports.length).fill(null); this._wrappers = []; this._buildPlaceholders(); } get pageWrappers() { return this._wrappers; } get numPages() { return this._viewports.length; } get renderGen() { return this._renderGen; } /** Called by ZoomController to suppress canvas teardown during CSS zoom. */ setZooming(z) { this._zooming = z; } _buildPlaceholders() { for (let i = 0; i < this._viewports.length; i++) { const vp = this._viewports[i]; const wrap = document.createElement("div"); wrap.className = "page-wrapper"; wrap.dataset.page = String(i + 1); wrap.style.width = vp.width * this._scale + "px"; wrap.style.height = vp.height * this._scale + "px"; this._wrapper.appendChild(wrap); this._wrappers.push(wrap); } } /** * Called on each IntersectionObserver tick. * Renders pages entering the buffer; cleans up pages leaving it. * Visible pages are prioritized in the render queue. * * @param {Set} bufferSet - 1-based page numbers in the buffer zone * @param {Set} visibleSet - 1-based page numbers currently on screen */ reconcile(bufferSet, visibleSet) { const gen = this._renderGen; // Prioritize visible pages, then the rest of the buffer 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._viewports.length) continue; if (this._states[i] === State.PLACEHOLDER) { this._startRender(i, gen); } } // Clean up pages no longer in the buffer. // Skip during active CSS zoom (Phase 1): IO may report stale intersection // data while the layout is still settling, and prematurely removing a // canvas causes a visible dark flash. onScaleChange (Phase 2) handles the // authoritative cleanup once the debounce fires. for (let i = 0; i < this._viewports.length; i++) { const pageNum = i + 1; if (!this._zooming && !bufferSet.has(pageNum) && this._states[i] === State.RENDERED) { this._cleanup(i); } // Also cancel stale RENDERING pages outside the buffer. // Don't remove the canvas — it's off-screen and harmless, and tearing it // down immediately causes a dark flash when IntersectionObserver fires // between Phase 1 (CSS zoom) and Phase 2 (debounced re-render). if (!bufferSet.has(pageNum) && this._states[i] === State.RENDERING) { this._inFlight--; this._states[i] = State.PLACEHOLDER; } } } get allRendered() { return this._inFlight === 0; } _startRender(i, gen) { const vp = this._viewports[i]; const scale = clampedRenderScale(vp.width, vp.height, this._scale, this._dpr); this._states[i] = State.RENDERING; this._inFlight++; this._dispatchRender(i + 1, scale, vp.width, vp.height, gen); } _cleanup(i) { if (this._states[i] === State.RENDERING) this._inFlight--; this._states[i] = State.PLACEHOLDER; const canvas = this._canvases[i]; if (canvas) { canvas.remove(); this._canvases[i] = null; } } /** * Called when the worker returns a rendered bitmap. * * @param {number} pageNum * @param {number} gen - render generation the bitmap was rendered for * @param {ImageBitmap} bitmap */ onRendered(pageNum, gen, bitmap) { if (gen !== this._renderGen) { bitmap.close(); return; } const i = pageNum - 1; if (i < 0 || i >= this._viewports.length || this._states[i] !== State.RENDERING) { bitmap.close(); return; } this._inFlight--; const vp = this._viewports[i]; const wrap = this._wrappers[i]; const cssW = vp.width * this._scale; const cssH = vp.height * this._scale; // Remove the old canvas (may be present — kept visible during re-render to // avoid a blank flash). Both mutations land in the same paint frame. const old = wrap.querySelector("canvas"); if (old) old.remove(); const canvas = document.createElement("canvas"); canvas.width = bitmap.width; canvas.height = bitmap.height; canvas.style.width = cssW + "px"; canvas.style.height = cssH + "px"; // Zero-copy display — fall back to drawImage if bitmaprenderer unavailable const ctx = canvas.getContext("bitmaprenderer"); if (ctx) { ctx.transferFromImageBitmap(bitmap); } else { canvas.getContext("2d").drawImage(bitmap, 0, 0); bitmap.close(); } // Set explicit wrapper size. Do NOT touch wrap.style.zoom here — // ZoomController may have applied a CSS zoom since the last onScaleChange // (the user kept zooming while this render was in-flight). Resetting zoom // to "1" would briefly show the page at the wrong visual scale until the // next onScaleChange corrects it, causing the "zoomed far in/out" flash. wrap.style.width = cssW + "px"; wrap.style.height = cssH + "px"; wrap.appendChild(canvas); this._canvases[i] = canvas; this._states[i] = State.RENDERED; } /** * Called after the zoom debounce fires. * Increments renderGen (cancels stale work), resizes all placeholders, * then re-renders the buffer pages at the new resolution. * * @param {number} newScale * @param {Set} bufferSet * @param {Set} visibleSet */ onScaleChange(newScale, bufferSet, visibleSet) { this._scale = newScale; this._renderGen++; for (let i = 0; i < this._viewports.length; i++) { const pageNum = i + 1; const vp = this._viewports[i]; const wrap = this._wrappers[i]; const cssW = vp.width * newScale; const cssH = vp.height * newScale; wrap.style.width = cssW + "px"; wrap.style.height = cssH + "px"; wrap.style.zoom = "1"; if (bufferSet.has(pageNum)) { // Keep the old canvas visible (stretched to the new CSS size) so there // is no blank flash while the new render is in flight. onRendered() // will replace it atomically in the same synchronous JS turn. const canvas = this._canvases[i]; if (canvas) { canvas.style.width = cssW + "px"; canvas.style.height = cssH + "px"; } if (this._states[i] === State.RENDERING) { this._inFlight--; } this._states[i] = State.PLACEHOLDER; } else { // Off-screen: clean up immediately — not visible, so no flash. const canvas = this._canvases[i]; if (canvas) { canvas.remove(); this._canvases[i] = null; } this._states[i] = State.PLACEHOLDER; } } this.reconcile(bufferSet, visibleSet); } /** * Returns the 1-based number of the topmost visible page. * * @param {Set} visibleSet */ getCurrentPage(visibleSet) { if (visibleSet.size > 0) return Math.min(...visibleSet); // Fall back to scroll position 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; } }