Replace js by ts
This commit is contained in:
414
src-tauri/assets/viewer/page-manager-enhanced.ts
Normal file
414
src-tauri/assets/viewer/page-manager-enhanced.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* 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<number>, visibleSet: Set<number>): 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<number>, visibleSet: Set<number>): 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>): 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user