/** * page-manager-enhanced.ts — Enhanced PageManager with performance optimizations * * Extends the original PageManager with: * - Better integration with render system * - Adaptive quality support * - Memory-efficient canvas management * - Smooth transitions during quality changes */ import { DispatchRenderFunction } from './types.js'; 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. */ function clampedRenderScale(vpWidth: number, vpHeight: number, desiredScale: number, dpr: number, quality: number = 1.0): number { const effectiveScale = desiredScale * quality; const w = vpWidth * effectiveScale * dpr; const h = vpHeight * effectiveScale * dpr; const pixels = w * h; if (pixels <= MAX_CANVAS_PIXELS) return effectiveScale * dpr; return effectiveScale * dpr * Math.sqrt(MAX_CANVAS_PIXELS / pixels); } const State = Object.freeze({ PLACEHOLDER: 0, RENDERING: 1, RENDERED: 2, QUALITY_ADJUSTING: 3 // New state for quality transitions }); export class EnhancedPageManager { private _wrapper: HTMLElement; private _viewports: Array<{width: number, height: number}>; private _scale: number; private _dpr: number; private _dispatchRender: DispatchRenderFunction; private _renderGen: number; private _inFlight: number; private _zooming: boolean; private _states: number[]; private _canvases: (HTMLCanvasElement | null)[]; private _wrappers: HTMLElement[]; private _currentQualities: number[]; private _targetQualities: number[]; private _needsQualityUpdate: boolean; /** * @param wrapper - #pages-wrapper container (already in DOM) * @param viewports - scale=1 viewports (0-indexed) * @param initialScale - initial display scale * @param dpr - devicePixelRatio * @param dispatchRender - (pageNum, scale, vpWidth, vpHeight, gen, quality) => void */ constructor(wrapper: HTMLElement, viewports: Array<{width: number, height: number}>, initialScale: number, dpr: number, dispatchRender: DispatchRenderFunction) { this._wrapper = wrapper; this._viewports = viewports; this._scale = initialScale; this._dpr = dpr; this._dispatchRender = dispatchRender; this._renderGen = 0; this._inFlight = 0; this._zooming = false; this._states = new Array(viewports.length).fill(State.PLACEHOLDER); this._canvases = new Array(viewports.length).fill(null); this._wrappers = []; this._currentQualities = new Array(viewports.length).fill(1.0); // Track quality per page this._targetQualities = new Array(viewports.length).fill(1.0); // Target quality for transitions this._needsQualityUpdate = false; this._buildPlaceholders(); } get pageWrappers(): HTMLElement[] { return this._wrappers; } get numPages(): number { return this._viewports.length; } get renderGen(): number { return this._renderGen; } /** Called by ZoomController to suppress canvas teardown during CSS zoom. */ setZooming(z: boolean): void { this._zooming = z; } setScale(scale: number): void { this._scale = scale; } setQualityForPage(pageNum: number, quality: number): void { const i = pageNum - 1; if (i >= 0 && i < this._viewports.length) { this._targetQualities[i] = quality; // If page is rendered and quality changed significantly, trigger re-render if (this._states[i] === State.RENDERED && Math.abs(quality - this._currentQualities[i]) > 0.1) { this._startQualityAdjustment(i); } } } setGlobalQuality(quality: number): void { for (let i = 0; i < this._targetQualities.length; i++) { this._targetQualities[i] = quality; } // Re-render visible pages with new quality this._needsQualityUpdate = true; } _buildPlaceholders(): void { 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 bufferSet - 1-based page numbers in the buffer zone * @param visibleSet - 1-based page numbers currently on screen */ reconcile(bufferSet: Set, visibleSet: Set): void { 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); } else if (this._states[i] === State.QUALITY_ADJUSTING) { // Already handling quality transition } else if (this._needsQualityUpdate && this._states[i] === State.RENDERED) { // Apply quality updates if needed if (Math.abs(this._targetQualities[i] - this._currentQualities[i]) > 0.05) { this._startQualityAdjustment(i); } } } // Clean up pages no longer in the buffer 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); } // Cancel stale RENDERING pages outside the buffer if (!bufferSet.has(pageNum) && (this._states[i] === State.RENDERING || this._states[i] === State.QUALITY_ADJUSTING)) { if (this._states[i] === State.RENDERING) this._inFlight--; this._states[i] = State.PLACEHOLDER; } } this._needsQualityUpdate = false; } get allRendered(): boolean { return this._inFlight === 0; } getPageElement(pageNum: number): HTMLElement | null { const i = pageNum - 1; if (i >= 0 && i < this._wrappers.length) { return this._wrappers[i]; } return null; } _startRender(i: number, gen: number): void { const vp = this._viewports[i]; const quality = this._targetQualities[i]; const scale = clampedRenderScale(vp.width, vp.height, this._scale, this._dpr, quality); this._states[i] = State.RENDERING; this._inFlight++; this._dispatchRender(i + 1, this._scale, vp.width, vp.height, gen, quality); } _startQualityAdjustment(i: number): void { // Transition to quality adjustment state this._states[i] = State.QUALITY_ADJUSTING; const pageNum = i + 1; const gen = ++this._renderGen; // Increment gen to cancel any pending renders const vp = this._viewports[i]; const quality = this._targetQualities[i]; const scale = clampedRenderScale(vp.width, vp.height, this._scale, this._dpr, quality); this._inFlight++; this._dispatchRender(pageNum, this._scale, vp.width, vp.height, gen, quality); } _cleanup(i: number): void { if (this._states[i] === State.RENDERING || this._states[i] === State.QUALITY_ADJUSTING) { this._inFlight--; } this._states[i] = State.PLACEHOLDER; const canvas = this._canvases[i]; if (canvas) { canvas.remove(); this._canvases[i] = null; } // Also remove the text and annotation layers const wrap = this._wrappers[i]; wrap.querySelector(".text-layer")?.remove(); wrap.querySelector(".annot-layer")?.remove(); } /** * Called when the worker returns a rendered bitmap. * * @param pageNum * @param gen - render generation the bitmap was rendered for * @param bitmap * @param quality - quality at which this bitmap was rendered */ onRendered(pageNum: number, gen: number, bitmap: ImageBitmap, quality: number): void { if (gen !== this._renderGen) { bitmap.close(); return; } const i = pageNum - 1; if (i < 0 || i >= this._viewports.length || (this._states[i] !== State.RENDERING && this._states[i] !== State.QUALITY_ADJUSTING)) { bitmap.close(); return; } this._inFlight--; this._currentQualities[i] = quality; const vp = this._viewports[i]; const wrap = this._wrappers[i]; const cssW = vp.width * this._scale; const cssH = vp.height * this._scale; // For quality adjustments, we want smooth transitions const wasQualityAdjustment = this._states[i] === State.QUALITY_ADJUSTING; this._states[i] = State.RENDERED; // Remove the old canvas const old = wrap.querySelector("canvas"); if (old) { if (wasQualityAdjustment) { // For quality adjustments, fade out the old canvas for smooth transition old.style.transition = "opacity 0.15s ease-out"; old.style.opacity = "0"; setTimeout(() => old.remove(), 150); } else { 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 { const ctx2d = canvas.getContext("2d"); if (ctx2d) { ctx2d.drawImage(bitmap, 0, 0); bitmap.close(); } } // For quality adjustments, fade in the new canvas if (wasQualityAdjustment) { canvas.style.opacity = "0"; canvas.style.transition = "opacity 0.15s ease-in"; wrap.appendChild(canvas); requestAnimationFrame(() => { canvas.style.opacity = "1"; }); } else { wrap.appendChild(canvas); } this._canvases[i] = canvas; } /** * Get the canvas element for a specific page */ getPageCanvas(pageNum: number): HTMLCanvasElement | null { const i = pageNum - 1; if (i >= 0 && i < this._canvases.length) { return this._canvases[i]; } return null; } /** * 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 newScale * @param bufferSet * @param visibleSet */ onScaleChange(newScale: number, bufferSet: Set, visibleSet: Set): void { 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. const canvas = this._canvases[i]; if (canvas) { canvas.style.width = cssW + "px"; canvas.style.height = cssH + "px"; } if (this._states[i] === State.RENDERING || this._states[i] === State.QUALITY_ADJUSTING) { 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 visibleSet */ getCurrentPage(visibleSet: Set): number { // Defensive programming: handle null/undefined visibleSet if (!visibleSet || visibleSet.size === 0) { // 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 || '1', 10); } } return 1; } return Math.min(...visibleSet); } /** * Get the current quality for a specific page */ getPageQuality(pageNum: number): number { const i = pageNum - 1; if (i >= 0 && i < this._currentQualities.length) { return this._currentQualities[i]; } return 1.0; } /** * Get the current render state for a page */ getPageState(pageNum: number): number { const i = pageNum - 1; if (i >= 0 && i < this._states.length) { return this._states[i]; } return State.PLACEHOLDER; } /** * Force cleanup of all resources */ cleanup(): void { for (let i = 0; i < this._viewports.length; i++) { this._cleanup(i); } this._inFlight = 0; this._renderGen = 0; } }