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

257 lines
9.2 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._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<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.
// 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<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.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<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;
}
}