Files
brittle/src-tauri/assets/viewer/page-manager.js

236 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
}