Files
brittle/src-tauri/dist/page-manager-enhanced.js
2026-04-01 01:27:51 +02:00

361 lines
14 KiB
JavaScript
Raw Permalink 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.
/**
* 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
*/
const MAX_CANVAS_PIXELS = 16777216; // 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, vpHeight, desiredScale, dpr, quality = 1.0) {
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 {
/**
* @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, 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;
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() { 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; }
setScale(scale) {
this._scale = scale;
}
setQualityForPage(pageNum, quality) {
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) {
for (let i = 0; i < this._targetQualities.length; i++) {
this._targetQualities[i] = quality;
}
// Re-render visible pages with new quality
this._needsQualityUpdate = true;
}
_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 bufferSet - 1-based page numbers in the buffer zone
* @param 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);
}
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() { return this._inFlight === 0; }
getPageElement(pageNum) {
const i = pageNum - 1;
if (i >= 0 && i < this._wrappers.length) {
return this._wrappers[i];
}
return null;
}
_startRender(i, gen) {
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) {
// 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) {
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, gen, bitmap, quality) {
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) {
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, 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.
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) {
// 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) {
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) {
const i = pageNum - 1;
if (i >= 0 && i < this._states.length) {
return this._states[i];
}
return State.PLACEHOLDER;
}
/**
* Force cleanup of all resources
*/
cleanup() {
for (let i = 0; i < this._viewports.length; i++) {
this._cleanup(i);
}
this._inFlight = 0;
this._renderGen = 0;
}
}
//# sourceMappingURL=page-manager-enhanced.js.map