236 lines
8.0 KiB
JavaScript
236 lines
8.0 KiB
JavaScript
/**
|
||
* 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._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; }
|
||
|
||
_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<number>} bufferSet - 1-based page numbers in the buffer zone
|
||
* @param {Set<number>} 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
|
||
for (let i = 0; i < this._viewports.length; i++) {
|
||
const pageNum = i + 1;
|
||
if (!bufferSet.has(pageNum) && this._states[i] === State.RENDERED) {
|
||
this._cleanup(i);
|
||
}
|
||
// Also cancel stale RENDERING pages outside the buffer
|
||
if (!bufferSet.has(pageNum) && this._states[i] === State.RENDERING) {
|
||
this._states[i] = State.PLACEHOLDER;
|
||
}
|
||
}
|
||
}
|
||
|
||
_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._dispatchRender(i + 1, scale, vp.width, vp.height, gen);
|
||
}
|
||
|
||
_cleanup(i) {
|
||
this._states[i] = State.PLACEHOLDER;
|
||
const canvas = this._canvases[i];
|
||
if (canvas) {
|
||
canvas.remove();
|
||
this._canvases[i] = null;
|
||
}
|
||
// Reset wrapper size (without canvas it still holds placeholder dimensions)
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// Remove CSS zoom and set explicit size (canvas is now at the right dimensions)
|
||
wrap.style.zoom = "1";
|
||
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<number>} bufferSet
|
||
* @param {Set<number>} 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.PLACEHOLDER) {
|
||
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<number>} 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;
|
||
}
|
||
}
|