Replace js by ts
This commit is contained in:
122
src-tauri/assets/viewer/annotation-layer.js
Normal file
122
src-tauri/assets/viewer/annotation-layer.js
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* AnnotationLayer — per-page SVG overlays for text highlight annotations.
|
||||
*
|
||||
* Each page wrapper gets one <svg class="annot-layer"> absolutely positioned
|
||||
* over the canvas. Highlight quads (in PDF coordinate space, origin
|
||||
* bottom-left) are converted to CSS pixel coordinates using the simple
|
||||
* transform: cx = x * scale, cy = (vpHeight - y) * scale
|
||||
* which is correct for upright (non-rotated) pages.
|
||||
*
|
||||
* Annotations from the model arrive as snake_case JSON matching the Rust
|
||||
* field names (annotation_type.type = "textmarkup", etc.).
|
||||
*/
|
||||
export class AnnotationLayer {
|
||||
/**
|
||||
* @param {HTMLElement[]} wrappers - PageManager.pageWrappers (0-indexed array)
|
||||
* @param {Array<{width:number,height:number}>} viewports - scale=1 page dims
|
||||
* @param {Function} onAnnotationClick - (annotationId: string) => void
|
||||
*/
|
||||
constructor(wrappers, viewports, onAnnotationClick) {
|
||||
this._wrappers = wrappers;
|
||||
this._viewports = viewports;
|
||||
this._onAnnotationClick = onAnnotationClick;
|
||||
this._annotations = [];
|
||||
this._scale = 1;
|
||||
this._selectedId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the full annotation list and re-render every page overlay.
|
||||
*
|
||||
* @param {object[]} annotations - Annotation objects from the backend
|
||||
* @param {number} scale - Current CSS display scale
|
||||
*/
|
||||
setAnnotations(annotations, scale) {
|
||||
this._annotations = annotations || [];
|
||||
this._scale = scale;
|
||||
for (let i = 0; i < this._wrappers.length; i++) {
|
||||
this._renderPage(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by viewer when zoom debounce fires; re-renders all overlays. */
|
||||
onScaleChange(scale) {
|
||||
this._scale = scale;
|
||||
for (let i = 0; i < this._wrappers.length; i++) {
|
||||
this._renderPage(i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one annotation as visually selected (adds .selected CSS class).
|
||||
* Pass null to clear the selection.
|
||||
*
|
||||
* @param {string|null} id
|
||||
*/
|
||||
selectAnnotation(id) {
|
||||
if (this._selectedId) {
|
||||
document.querySelectorAll(`.annot-layer [data-id="${CSS.escape(this._selectedId)}"]`)
|
||||
.forEach(el => el.classList.remove("selected"));
|
||||
}
|
||||
this._selectedId = id;
|
||||
if (id) {
|
||||
document.querySelectorAll(`.annot-layer [data-id="${CSS.escape(id)}"]`)
|
||||
.forEach(el => el.classList.add("selected"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────────
|
||||
|
||||
_renderPage(pageNum) {
|
||||
const i = pageNum - 1;
|
||||
const wrap = this._wrappers[i];
|
||||
if (!wrap) return;
|
||||
|
||||
// Remove any existing overlay for this page
|
||||
wrap.querySelector(".annot-layer")?.remove();
|
||||
|
||||
const vp0 = this._viewports[i];
|
||||
const scale = this._scale;
|
||||
const cssW = vp0.width * scale;
|
||||
const cssH = vp0.height * scale;
|
||||
|
||||
// Collect highlights for this page (page field is 0-indexed in the model)
|
||||
const highlights = this._annotations.filter(a =>
|
||||
a.page === i &&
|
||||
a.annotation_type?.type === "textmarkup" &&
|
||||
a.annotation_type?.markup_type === "highlight"
|
||||
);
|
||||
if (highlights.length === 0) return;
|
||||
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.classList.add("annot-layer");
|
||||
svg.setAttribute("viewBox", `0 0 ${cssW} ${cssH}`);
|
||||
svg.style.width = cssW + "px";
|
||||
svg.style.height = cssH + "px";
|
||||
|
||||
for (const ann of highlights) {
|
||||
for (const quad of ann.annotation_type.quads ?? []) {
|
||||
const pts = (quad.points ?? []).map(pt => {
|
||||
const cx = pt.x * scale;
|
||||
const cy = (vp0.height - pt.y) * scale;
|
||||
return `${cx},${cy}`;
|
||||
}).join(" ");
|
||||
|
||||
const polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
|
||||
polygon.setAttribute("points", pts);
|
||||
polygon.classList.add("highlight");
|
||||
polygon.dataset.id = ann.id;
|
||||
if (this._selectedId === ann.id) polygon.classList.add("selected");
|
||||
|
||||
polygon.addEventListener("click", ev => {
|
||||
ev.stopPropagation();
|
||||
this._onAnnotationClick?.(ann.id);
|
||||
});
|
||||
|
||||
svg.appendChild(polygon);
|
||||
}
|
||||
}
|
||||
|
||||
wrap.appendChild(svg);
|
||||
}
|
||||
}
|
||||
35
src-tauri/assets/viewer/dom-types.d.ts
vendored
Normal file
35
src-tauri/assets/viewer/dom-types.d.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
// Type extensions for DOM APIs used in the PDF viewer
|
||||
|
||||
declare global {
|
||||
interface OffscreenCanvas {
|
||||
transferToImageBitmap(): ImageBitmap;
|
||||
}
|
||||
|
||||
// Extend Performance interface for non-standard memory API
|
||||
interface Performance {
|
||||
memory?: {
|
||||
usedJSHeapSize: number;
|
||||
jsHeapSizeLimit: number;
|
||||
totalJSHeapSize?: number;
|
||||
jsHeapSizeLimit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface HTMLCanvasElement {
|
||||
transferControlToOffscreen(): OffscreenCanvas;
|
||||
}
|
||||
|
||||
interface CanvasRenderingContext2D {
|
||||
getImageData(sx: number, sy: number, sw: number, sh: number): ImageData;
|
||||
putImageData(imageData: ImageData, dx: number, dy: number): void;
|
||||
}
|
||||
|
||||
interface ImageBitmap {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
// Extend Element for our custom methods
|
||||
interface Element {
|
||||
scrollIntoView(options?: ScrollIntoViewOptions): void;
|
||||
}
|
||||
}
|
||||
18
src-tauri/assets/viewer/global-types.d.ts
vendored
Normal file
18
src-tauri/assets/viewer/global-types.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
// Global type extensions and ambient declarations
|
||||
|
||||
declare global {
|
||||
interface Performance {
|
||||
memory?: {
|
||||
usedJSHeapSize: number;
|
||||
jsHeapSizeLimit: number;
|
||||
totalJSHeapSize?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Window {
|
||||
pdfjsLib: any;
|
||||
__TAURI__?: {
|
||||
invoke: (cmd: string, args?: any) => Promise<any>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,60 @@
|
||||
.page-wrapper canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Text layer — transparent spans matching PDF text positions for selection.
|
||||
pointer-events must NOT be none on the container: WebKit does not allow
|
||||
text selection through a pointer-events:none parent, even when child
|
||||
spans have pointer-events:all. */
|
||||
.text-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
line-height: 1;
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.text-layer span,
|
||||
.text-layer div,
|
||||
.text-layer br {
|
||||
color: transparent;
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
transform-origin: 0% 0%;
|
||||
}
|
||||
|
||||
.text-layer ::selection {
|
||||
background: rgba(100, 160, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Annotation overlay — SVG with highlight polygons */
|
||||
.annot-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.annot-layer polygon.highlight {
|
||||
fill: rgba(255, 220, 0, 0.38);
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.annot-layer polygon.highlight:hover {
|
||||
fill: rgba(255, 180, 0, 0.55);
|
||||
}
|
||||
|
||||
.annot-layer polygon.highlight.selected {
|
||||
fill: rgba(255, 140, 0, 0.55);
|
||||
stroke: #e07000;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -123,6 +177,11 @@
|
||||
</div>
|
||||
|
||||
<script src="brittle://app/pdfjs/build/pdf.min.js"></script>
|
||||
<script type="module" src="brittle://app/viewer/performance-manager.js"></script>
|
||||
<script type="module" src="brittle://app/viewer/render-system.js"></script>
|
||||
<script type="module" src="brittle://app/viewer/visibility-manager.js"></script>
|
||||
<script type="module" src="brittle://app/viewer/zoom-controller-enhanced.js"></script>
|
||||
<script type="module" src="brittle://app/viewer/page-manager-enhanced.js"></script>
|
||||
<script type="module" src="brittle://app/viewer/viewer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,22 +2,39 @@
|
||||
* MessageBridge — postMessage protocol between the PDF viewer iframe
|
||||
* and the parent Leptos application.
|
||||
*
|
||||
* Inbound (parent → iframe): "pdf.page.next" | "pdf.page.prev"
|
||||
* Outbound (iframe → parent): { type: "brittle:keydown", key, ctrlKey, ... }
|
||||
* Inbound (parent → iframe):
|
||||
* "pdf.page.next" | "pdf.page.prev"
|
||||
* { type: "brittle:annotations-set", annotations: [...] }
|
||||
*
|
||||
* Outbound (iframe → parent):
|
||||
* { type: "brittle:keydown", key, ctrlKey, … }
|
||||
* { type: "brittle:viewer-state", refId, zoom, scrollTop }
|
||||
* { type: "brittle:annotations-request", refId }
|
||||
* { type: "brittle:annotation-create", refId, page, quads, selectedText }
|
||||
* { type: "brittle:annotation-click", refId, annotationId }
|
||||
*/
|
||||
export class MessageBridge {
|
||||
/**
|
||||
* @param {Function} onPageNext - () => void
|
||||
* @param {Function} onPagePrev - () => void
|
||||
* @param {Function} onPageNext - () => void
|
||||
* @param {Function} onPagePrev - () => void
|
||||
* @param {object} [options]
|
||||
* @param {Function} [options.onAnnotationsSet] - (annotations: object[]) => void
|
||||
*/
|
||||
constructor(onPageNext, onPagePrev) {
|
||||
constructor(onPageNext, onPagePrev, options = {}) {
|
||||
this._onAnnotationsSet = options.onAnnotationsSet ?? null;
|
||||
|
||||
this._handler = ev => {
|
||||
if (ev.data === "pdf.page.next") onPageNext();
|
||||
if (ev.data === "pdf.page.prev") onPagePrev();
|
||||
if (ev.data === "pdf.page.next") { onPageNext(); return; }
|
||||
if (ev.data === "pdf.page.prev") { onPagePrev(); return; }
|
||||
if (ev.data?.type === "brittle:annotations-set") {
|
||||
this._onAnnotationsSet?.(ev.data.annotations ?? []);
|
||||
}
|
||||
};
|
||||
window.addEventListener("message", this._handler);
|
||||
}
|
||||
|
||||
// ── Outbound ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Send the current viewer state (zoom + scroll) to the parent window. */
|
||||
postViewerState(refId, zoom, scrollTop) {
|
||||
if (window.parent === window) return;
|
||||
@@ -29,6 +46,47 @@ export class MessageBridge {
|
||||
}, "*");
|
||||
}
|
||||
|
||||
/** Ask the parent to send the annotation set for this reference. */
|
||||
requestAnnotations(refId) {
|
||||
if (window.parent === window) return;
|
||||
window.parent.postMessage({ type: "brittle:annotations-request", refId }, "*");
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the parent that the user selected text and wants to create a
|
||||
* highlight annotation.
|
||||
*
|
||||
* @param {string} refId
|
||||
* @param {number} page - 0-indexed physical page number
|
||||
* @param {object[]} quads - [{points:[{x,y}×4]}] in PDF coordinate space
|
||||
* @param {string} selectedText
|
||||
*/
|
||||
postAnnotationCreate(refId, page, quads, selectedText) {
|
||||
if (window.parent === window) return;
|
||||
window.parent.postMessage({
|
||||
type: "brittle:annotation-create",
|
||||
refId,
|
||||
page,
|
||||
quads,
|
||||
selectedText,
|
||||
}, "*");
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the parent that the user clicked an existing annotation.
|
||||
*
|
||||
* @param {string} refId
|
||||
* @param {string} annotationId
|
||||
*/
|
||||
postAnnotationClick(refId, annotationId) {
|
||||
if (window.parent === window) return;
|
||||
window.parent.postMessage({
|
||||
type: "brittle:annotation-click",
|
||||
refId,
|
||||
annotationId,
|
||||
}, "*");
|
||||
}
|
||||
|
||||
/** Forward a keydown event to the parent window for global keybindings. */
|
||||
forwardKeydown(ev) {
|
||||
if (window.parent === window) return;
|
||||
|
||||
393
src-tauri/assets/viewer/page-manager-enhanced.js
Normal file
393
src-tauri/assets/viewer/page-manager-enhanced.js
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* page-manager-enhanced.js — 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 = 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, 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 {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, 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._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 {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);
|
||||
} 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 {number} pageNum
|
||||
* @param {number} gen - render generation the bitmap was rendered for
|
||||
* @param {ImageBitmap} bitmap
|
||||
* @param {number} 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 {
|
||||
canvas.getContext("2d").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 {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.
|
||||
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 {Set<number>} 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, 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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
38
src-tauri/assets/viewer/pdf-types.d.ts
vendored
Normal file
38
src-tauri/assets/viewer/pdf-types.d.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
// Type definitions for PDF.js library
|
||||
// Minimal types to support our PDF viewer functionality
|
||||
|
||||
declare module 'pdfjs-dist' {
|
||||
interface PDFDocumentProxy {
|
||||
getPage(pageNumber: number): Promise<PDFPageProxy>;
|
||||
numPages: number;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
interface PDFPageProxy {
|
||||
getViewport(options: { scale: number }): { width: number; height: number };
|
||||
render(options: {
|
||||
canvasContext: CanvasRenderingContext2D;
|
||||
viewport: any;
|
||||
renderQuality?: string;
|
||||
}): { promise: Promise<void> };
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
interface PDFDocumentLoadingTask {
|
||||
promise: Promise<PDFDocumentProxy>;
|
||||
}
|
||||
|
||||
function getDocument(options: { url: string }): PDFDocumentLoadingTask;
|
||||
|
||||
// Global worker options
|
||||
const GlobalWorkerOptions: {
|
||||
workerSrc: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Extend Window interface for PDF.js global
|
||||
declare global {
|
||||
interface Window {
|
||||
pdfjsLib: any;
|
||||
}
|
||||
}
|
||||
115
src-tauri/assets/viewer/performance-manager.js
Normal file
115
src-tauri/assets/viewer/performance-manager.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// performance-manager.ts — Performance monitoring and optimization system
|
||||
// TypeScript rewrite with proper type safety
|
||||
class PerformanceManager {
|
||||
constructor() {
|
||||
this.frameTimes = [];
|
||||
this.maxFrameHistory = 60;
|
||||
this.lastFrameTime = 0;
|
||||
this.fps = 60;
|
||||
this.memoryWarnings = 0;
|
||||
this.renderQuality = 1.0;
|
||||
this.adaptiveMode = false;
|
||||
this.setupMonitoring();
|
||||
}
|
||||
setupMonitoring() {
|
||||
let lastTime = performance.now();
|
||||
let frameCount = 0;
|
||||
const monitorLoop = (currentTime) => {
|
||||
frameCount++;
|
||||
if (frameCount % 10 === 0) {
|
||||
const delta = currentTime - lastTime;
|
||||
const avgFrameTime = delta / 10;
|
||||
this.fps = Math.round(1000 / avgFrameTime);
|
||||
this.frameTimes.push(avgFrameTime);
|
||||
if (this.frameTimes.length > this.maxFrameHistory) {
|
||||
this.frameTimes.shift();
|
||||
}
|
||||
this.analyzePerformance();
|
||||
lastTime = currentTime;
|
||||
}
|
||||
requestAnimationFrame(monitorLoop);
|
||||
};
|
||||
requestAnimationFrame(monitorLoop);
|
||||
if (window.performance && performance.memory) {
|
||||
setInterval(() => this.checkMemory(), 2000);
|
||||
}
|
||||
}
|
||||
analyzePerformance() {
|
||||
if (this.frameTimes.length < 10)
|
||||
return;
|
||||
const sorted = [...this.frameTimes].sort((a, b) => a - b);
|
||||
const avgFrameTime = sorted.reduce((a, b) => a + b, 0) / sorted.length;
|
||||
const p90FrameTime = sorted[Math.floor(sorted.length * 0.9)];
|
||||
const frameBudget = 16; // 16ms for 60fps
|
||||
if (p90FrameTime > frameBudget * 1.5) {
|
||||
this.setRenderQuality(Math.max(0.5, this.renderQuality - 0.2));
|
||||
this.adaptiveMode = true;
|
||||
}
|
||||
else if (p90FrameTime > frameBudget * 1.2) {
|
||||
this.setRenderQuality(Math.max(0.7, this.renderQuality - 0.1));
|
||||
}
|
||||
else if (avgFrameTime < frameBudget * 0.8 && this.adaptiveMode) {
|
||||
this.setRenderQuality(Math.min(1.0, this.renderQuality + 0.05));
|
||||
if (this.renderQuality >= 0.95) {
|
||||
this.adaptiveMode = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
checkMemory() {
|
||||
try {
|
||||
// Use type assertion for non-standard memory API
|
||||
const perf = performance;
|
||||
const memory = perf.memory;
|
||||
if (!memory) {
|
||||
throw new Error('Memory API not available');
|
||||
}
|
||||
const usedHeap = memory.usedJSHeapSize;
|
||||
const heapLimit = memory.jsHeapSizeLimit;
|
||||
const usageRatio = usedHeap / heapLimit;
|
||||
if (usageRatio > 0.8) {
|
||||
this.memoryWarnings++;
|
||||
if (this.memoryWarnings > 3) {
|
||||
this.triggerMemoryCleanup();
|
||||
this.memoryWarnings = 0;
|
||||
}
|
||||
this.setRenderQuality(Math.max(0.6, this.renderQuality - 0.1));
|
||||
}
|
||||
else {
|
||||
this.memoryWarnings = Math.max(0, this.memoryWarnings - 0.5);
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn('Memory monitoring not available:', e);
|
||||
}
|
||||
}
|
||||
triggerMemoryCleanup() {
|
||||
if (window.gc) {
|
||||
window.gc();
|
||||
}
|
||||
}
|
||||
setRenderQuality(quality) {
|
||||
this.renderQuality = Math.max(0.3, Math.min(1.0, quality));
|
||||
console.debug(`[Perf] Render quality adjusted to: ${(this.renderQuality * 100).toFixed(0)}%`);
|
||||
}
|
||||
getCurrentQuality() {
|
||||
return this.renderQuality;
|
||||
}
|
||||
isPerformanceCritical() {
|
||||
return this.adaptiveMode || this.fps < 45;
|
||||
}
|
||||
getPerformanceStats() {
|
||||
return {
|
||||
fps: this.fps,
|
||||
avgFrameTime: this.frameTimes.length > 0
|
||||
? this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length
|
||||
: 0,
|
||||
renderQuality: this.renderQuality,
|
||||
adaptiveMode: this.adaptiveMode
|
||||
};
|
||||
}
|
||||
cleanup() {
|
||||
this.frameTimes = [];
|
||||
}
|
||||
}
|
||||
export { PerformanceManager };
|
||||
//# sourceMappingURL=performance-manager.js.map
|
||||
1
src-tauri/assets/viewer/performance-manager.js.map
Normal file
1
src-tauri/assets/viewer/performance-manager.js.map
Normal file
File diff suppressed because one or more lines are too long
139
src-tauri/assets/viewer/performance-manager.ts
Normal file
139
src-tauri/assets/viewer/performance-manager.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// performance-manager.ts — Performance monitoring and optimization system
|
||||
// TypeScript rewrite with proper type safety
|
||||
|
||||
import { PerformanceStats } from './types';
|
||||
|
||||
class PerformanceManager {
|
||||
private frameTimes: number[] = [];
|
||||
private maxFrameHistory: number = 60;
|
||||
private lastFrameTime: number = 0;
|
||||
private fps: number = 60;
|
||||
private memoryWarnings: number = 0;
|
||||
private renderQuality: number = 1.0;
|
||||
private adaptiveMode: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.setupMonitoring();
|
||||
}
|
||||
|
||||
private setupMonitoring(): void {
|
||||
let lastTime = performance.now();
|
||||
let frameCount = 0;
|
||||
|
||||
const monitorLoop = (currentTime: number) => {
|
||||
frameCount++;
|
||||
|
||||
if (frameCount % 10 === 0) {
|
||||
const delta = currentTime - lastTime;
|
||||
const avgFrameTime = delta / 10;
|
||||
this.fps = Math.round(1000 / avgFrameTime);
|
||||
|
||||
this.frameTimes.push(avgFrameTime);
|
||||
if (this.frameTimes.length > this.maxFrameHistory) {
|
||||
this.frameTimes.shift();
|
||||
}
|
||||
|
||||
this.analyzePerformance();
|
||||
lastTime = currentTime;
|
||||
}
|
||||
|
||||
requestAnimationFrame(monitorLoop);
|
||||
};
|
||||
|
||||
requestAnimationFrame(monitorLoop);
|
||||
|
||||
if (window.performance && (performance as any).memory) {
|
||||
setInterval(() => this.checkMemory(), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
private analyzePerformance(): void {
|
||||
if (this.frameTimes.length < 10) return;
|
||||
|
||||
const sorted = [...this.frameTimes].sort((a, b) => a - b);
|
||||
const avgFrameTime = sorted.reduce((a, b) => a + b, 0) / sorted.length;
|
||||
const p90FrameTime = sorted[Math.floor(sorted.length * 0.9)];
|
||||
|
||||
const frameBudget = 16; // 16ms for 60fps
|
||||
|
||||
if (p90FrameTime > frameBudget * 1.5) {
|
||||
this.setRenderQuality(Math.max(0.5, this.renderQuality - 0.2));
|
||||
this.adaptiveMode = true;
|
||||
} else if (p90FrameTime > frameBudget * 1.2) {
|
||||
this.setRenderQuality(Math.max(0.7, this.renderQuality - 0.1));
|
||||
} else if (avgFrameTime < frameBudget * 0.8 && this.adaptiveMode) {
|
||||
this.setRenderQuality(Math.min(1.0, this.renderQuality + 0.05));
|
||||
|
||||
if (this.renderQuality >= 0.95) {
|
||||
this.adaptiveMode = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private checkMemory(): void {
|
||||
try {
|
||||
// Use type assertion for non-standard memory API
|
||||
const perf = performance as { memory?: { usedJSHeapSize: number; jsHeapSizeLimit: number } };
|
||||
const memory = perf.memory;
|
||||
|
||||
if (!memory) {
|
||||
throw new Error('Memory API not available');
|
||||
}
|
||||
|
||||
const usedHeap = memory.usedJSHeapSize;
|
||||
const heapLimit = memory.jsHeapSizeLimit;
|
||||
const usageRatio = usedHeap / heapLimit;
|
||||
|
||||
if (usageRatio > 0.8) {
|
||||
this.memoryWarnings++;
|
||||
|
||||
if (this.memoryWarnings > 3) {
|
||||
this.triggerMemoryCleanup();
|
||||
this.memoryWarnings = 0;
|
||||
}
|
||||
|
||||
this.setRenderQuality(Math.max(0.6, this.renderQuality - 0.1));
|
||||
} else {
|
||||
this.memoryWarnings = Math.max(0, this.memoryWarnings - 0.5);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Memory monitoring not available:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private triggerMemoryCleanup(): void {
|
||||
if (window.gc) {
|
||||
window.gc();
|
||||
}
|
||||
}
|
||||
|
||||
setRenderQuality(quality: number): void {
|
||||
this.renderQuality = Math.max(0.3, Math.min(1.0, quality));
|
||||
console.debug(`[Perf] Render quality adjusted to: ${(this.renderQuality * 100).toFixed(0)}%`);
|
||||
}
|
||||
|
||||
getCurrentQuality(): number {
|
||||
return this.renderQuality;
|
||||
}
|
||||
|
||||
isPerformanceCritical(): boolean {
|
||||
return this.adaptiveMode || this.fps < 45;
|
||||
}
|
||||
|
||||
getPerformanceStats(): PerformanceStats {
|
||||
return {
|
||||
fps: this.fps,
|
||||
avgFrameTime: this.frameTimes.length > 0
|
||||
? this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length
|
||||
: 0,
|
||||
renderQuality: this.renderQuality,
|
||||
adaptiveMode: this.adaptiveMode
|
||||
};
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.frameTimes = [];
|
||||
}
|
||||
}
|
||||
|
||||
export { PerformanceManager };
|
||||
413
src-tauri/assets/viewer/render-system.js
Normal file
413
src-tauri/assets/viewer/render-system.js
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* render-system.js — Advanced rendering system with performance optimization
|
||||
*
|
||||
* Manages the complete rendering pipeline with:
|
||||
* - Double buffering for flicker-free rendering
|
||||
* - Adaptive quality based on performance
|
||||
* - Intelligent page caching
|
||||
* - Smooth zoom and scroll handling
|
||||
*/
|
||||
|
||||
import { PerformanceManager } from './performance-manager.js';
|
||||
|
||||
class RenderSystem {
|
||||
constructor(pdfDoc, container, pageManager) {
|
||||
this.pdfDoc = pdfDoc;
|
||||
this.container = container;
|
||||
this.pageManager = pageManager;
|
||||
|
||||
try {
|
||||
this.performanceManager = new PerformanceManager();
|
||||
} catch (error) {
|
||||
console.error("Failed to create PerformanceManager:", error);
|
||||
// Fallback: create a minimal performance manager
|
||||
this.performanceManager = {
|
||||
getPerformanceStats: () => ({ fps: 60, renderQuality: 1.0, adaptiveMode: false }),
|
||||
isPerformanceCritical: () => false,
|
||||
cleanup: () => {}
|
||||
};
|
||||
}
|
||||
|
||||
// Render state
|
||||
this.currentScale = 1.0;
|
||||
this.targetScale = 1.0;
|
||||
this.renderQuality = 1.0;
|
||||
this.visiblePages = new Set();
|
||||
this.bufferPages = new Set();
|
||||
this.renderQueue = [];
|
||||
this.activeRenders = new Map();
|
||||
this.pageCache = new Map(); // { "page_scale": {bitmap, timestamp, quality} }
|
||||
this.maxCacheSize = 20; // Max pages in cache
|
||||
this.cacheSize = 0;
|
||||
|
||||
// Performance metrics
|
||||
this.lastRenderTime = 0;
|
||||
this.renderCount = 0;
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Performance-based quality adjustments
|
||||
setInterval(() => {
|
||||
this.adjustQualityBasedOnPerformance();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
setVisibility(visiblePages, bufferPages) {
|
||||
this.visiblePages = new Set(visiblePages);
|
||||
this.bufferPages = new Set(bufferPages);
|
||||
this.scheduleRenders();
|
||||
}
|
||||
|
||||
setScale(scale) {
|
||||
this.targetScale = scale;
|
||||
|
||||
// Smooth transition for zoom
|
||||
if (Math.abs(this.targetScale - this.currentScale) > 0.01) {
|
||||
this.startZoomTransition();
|
||||
} else {
|
||||
this.currentScale = this.targetScale;
|
||||
this.scheduleRenders();
|
||||
}
|
||||
}
|
||||
|
||||
startZoomTransition() {
|
||||
const startScale = this.currentScale;
|
||||
const endScale = this.targetScale;
|
||||
const startTime = performance.now();
|
||||
const duration = 150; // 150ms transition
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease-in-out animation
|
||||
const easedProgress = this.easeInOutCubic(progress);
|
||||
this.currentScale = startScale + (endScale - startScale) * easedProgress;
|
||||
|
||||
// Use lower quality during transition
|
||||
const transitionQuality = this.renderQuality * 0.8;
|
||||
this.scheduleRenders(transitionQuality);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
this.currentScale = endScale;
|
||||
// Final high-quality render after transition
|
||||
this.scheduleRenders(this.renderQuality);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
easeInOutCubic(t) {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
scheduleRenders(overrideQuality = null) {
|
||||
const quality = overrideQuality !== null ? overrideQuality : this.renderQuality;
|
||||
const scale = this.currentScale;
|
||||
|
||||
// Cancel renders for pages that are no longer needed
|
||||
this.cancelStaleRenders();
|
||||
|
||||
// Prioritize visible pages, then buffer pages
|
||||
const allPages = [...this.visiblePages, ...this.bufferPages];
|
||||
|
||||
allPages.forEach(pageNum => {
|
||||
const cacheKey = this.getCacheKey(pageNum, scale);
|
||||
|
||||
// Check if we need to render this page
|
||||
if (this.shouldRenderPage(pageNum, scale, quality)) {
|
||||
this.queueRender(pageNum, scale, quality);
|
||||
}
|
||||
});
|
||||
|
||||
// Process the render queue
|
||||
this.processRenderQueue();
|
||||
}
|
||||
|
||||
shouldRenderPage(pageNum, scale, quality) {
|
||||
const cacheKey = this.getCacheKey(pageNum, scale);
|
||||
|
||||
// Check if already rendering
|
||||
if (this.activeRenders.has(pageNum)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cache with acceptable quality
|
||||
const cached = this.pageCache.get(cacheKey);
|
||||
if (cached) {
|
||||
// If cached quality is acceptable, no need to re-render
|
||||
if (cached.quality >= quality * 0.9) {
|
||||
this.useCachedPage(pageNum, scale, cached.bitmap);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
queueRender(pageNum, scale, quality) {
|
||||
// Avoid duplicate queue entries
|
||||
if (this.renderQueue.some(item => item.pageNum === pageNum && Math.abs(item.scale - scale) < 0.01)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prioritize visible pages
|
||||
const priority = this.visiblePages.has(pageNum) ? 1 : 2;
|
||||
|
||||
this.renderQueue.push({ pageNum, scale, quality, priority });
|
||||
|
||||
// Sort by priority (visible pages first)
|
||||
this.renderQueue.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
processRenderQueue() {
|
||||
if (this.renderQueue.length === 0) return;
|
||||
|
||||
// Limit concurrent renders based on performance
|
||||
const maxConcurrent = this.performanceManager.isPerformanceCritical() ? 2 : 4;
|
||||
|
||||
while (this.activeRenders.size < maxConcurrent && this.renderQueue.length > 0) {
|
||||
const renderTask = this.renderQueue.shift();
|
||||
this.startRender(renderTask.pageNum, renderTask.scale, renderTask.quality);
|
||||
}
|
||||
}
|
||||
|
||||
async startRender(pageNum, scale, vpWidth, vpHeight, quality) {
|
||||
this.activeRenders.set(pageNum, true);
|
||||
let page = null;
|
||||
|
||||
try {
|
||||
page = await this.pdfDoc.getPage(pageNum);
|
||||
|
||||
// Apply quality-based scaling
|
||||
const effectiveScale = scale * quality;
|
||||
const viewport = page.getViewport({ scale: effectiveScale });
|
||||
|
||||
// Validate canvas dimensions
|
||||
const width = Math.round(viewport.width);
|
||||
const height = Math.round(viewport.height);
|
||||
|
||||
if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) {
|
||||
throw new Error(`Invalid canvas dimensions: ${width}x${height}`);
|
||||
}
|
||||
|
||||
// Create offscreen canvas
|
||||
const offscreen = new OffscreenCanvas(width, height);
|
||||
const context = offscreen.getContext('2d');
|
||||
|
||||
// Render with quality settings
|
||||
await page.render({
|
||||
canvasContext: context,
|
||||
viewport: viewport,
|
||||
// Use lower quality rendering when performance is critical
|
||||
renderQuality: this.performanceManager.isPerformanceCritical() ? 'low' : 'high'
|
||||
}).promise;
|
||||
|
||||
// Transfer bitmap
|
||||
const bitmap = offscreen.transferToImageBitmap();
|
||||
|
||||
// Cache the result
|
||||
const cacheKey = this.getCacheKey(pageNum, scale);
|
||||
this.cachePage(cacheKey, bitmap, quality);
|
||||
|
||||
// Update display
|
||||
this.updatePageDisplay(pageNum, bitmap);
|
||||
|
||||
} catch (error) {
|
||||
if (error?.name !== "RenderingCancelledException") {
|
||||
console.warn(`[RenderSystem] Render failed for page ${pageNum}:`, error);
|
||||
}
|
||||
} finally {
|
||||
this.activeRenders.delete(pageNum);
|
||||
page?.cleanup();
|
||||
|
||||
// Process next in queue
|
||||
this.processRenderQueue();
|
||||
}
|
||||
}
|
||||
|
||||
cachePage(cacheKey, bitmap, quality) {
|
||||
// Evict if cache is full
|
||||
if (this.cacheSize >= this.maxCacheSize) {
|
||||
this.evictOldestCacheEntry();
|
||||
}
|
||||
|
||||
this.pageCache.set(cacheKey, {
|
||||
bitmap,
|
||||
quality,
|
||||
timestamp: Date.now(),
|
||||
accessTime: Date.now()
|
||||
});
|
||||
|
||||
this.cacheSize++;
|
||||
|
||||
// Update access time for LRU
|
||||
this.updateCacheAccess(cacheKey);
|
||||
}
|
||||
|
||||
evictOldestCacheEntry() {
|
||||
if (this.pageCache.size === 0) return;
|
||||
|
||||
// Find least recently used
|
||||
let oldestKey = null;
|
||||
let oldestTime = Infinity;
|
||||
|
||||
this.pageCache.forEach((entry, key) => {
|
||||
if (entry.accessTime < oldestTime) {
|
||||
oldestTime = entry.accessTime;
|
||||
oldestKey = key;
|
||||
}
|
||||
});
|
||||
|
||||
if (oldestKey) {
|
||||
const entry = this.pageCache.get(oldestKey);
|
||||
entry.bitmap.close(); // Free memory
|
||||
this.pageCache.delete(oldestKey);
|
||||
this.cacheSize--;
|
||||
}
|
||||
}
|
||||
|
||||
updateCacheAccess(cacheKey) {
|
||||
const entry = this.pageCache.get(cacheKey);
|
||||
if (entry) {
|
||||
entry.accessTime = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
useCachedPage(pageNum, scale, bitmap) {
|
||||
// Create a copy of the bitmap for display
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const displayBitmap = canvas.transferToImageBitmap();
|
||||
|
||||
this.updatePageDisplay(pageNum, displayBitmap);
|
||||
}
|
||||
|
||||
updatePageDisplay(pageNum, bitmap) {
|
||||
// Use the EnhancedPageManager's onRendered method to handle display
|
||||
// This ensures proper integration with the page manager's state and canvas management
|
||||
const gen = this.pageManager.renderGen;
|
||||
const quality = this.renderQuality; // Use current render quality
|
||||
|
||||
this.pageManager.onRendered(pageNum, gen, bitmap, quality);
|
||||
}
|
||||
|
||||
cancelStaleRenders() {
|
||||
// Cancel renders for pages that are no longer visible or buffered
|
||||
const neededPages = new Set([...this.visiblePages, ...this.bufferPages]);
|
||||
|
||||
this.activeRenders.forEach((_, pageNum) => {
|
||||
if (!neededPages.has(pageNum)) {
|
||||
// This render is no longer needed
|
||||
this.activeRenders.delete(pageNum);
|
||||
}
|
||||
});
|
||||
|
||||
// Also clean up render queue
|
||||
this.renderQueue = this.renderQueue.filter(task =>
|
||||
neededPages.has(task.pageNum)
|
||||
);
|
||||
}
|
||||
|
||||
adjustQualityBasedOnPerformance() {
|
||||
const stats = this.performanceManager.getPerformanceStats();
|
||||
|
||||
// If performance is critical, we may have already adjusted quality
|
||||
// Here we can make additional adjustments based on render load
|
||||
if (this.activeRenders.size > 4 && stats.fps < 50) {
|
||||
// Too many active renders and low FPS - reduce quality more
|
||||
const newQuality = Math.max(0.5, this.renderQuality - 0.1);
|
||||
if (newQuality < this.renderQuality) {
|
||||
this.setRenderQuality(newQuality);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setRenderQuality(quality) {
|
||||
this.renderQuality = Math.max(0.3, Math.min(1.0, quality));
|
||||
|
||||
// Re-render visible pages with new quality
|
||||
if (this.visiblePages.size > 0) {
|
||||
this.scheduleRenders(this.renderQuality);
|
||||
}
|
||||
}
|
||||
|
||||
cleanupMemory() {
|
||||
// Aggressive cleanup - keep only visible pages
|
||||
const visibleCacheKeys = new Set();
|
||||
|
||||
this.visiblePages.forEach(pageNum => {
|
||||
const cacheKey = this.getCacheKey(pageNum, this.currentScale);
|
||||
visibleCacheKeys.add(cacheKey);
|
||||
});
|
||||
|
||||
// Remove all non-visible pages from cache
|
||||
this.pageCache.forEach((entry, cacheKey) => {
|
||||
if (!visibleCacheKeys.has(cacheKey)) {
|
||||
entry.bitmap.close();
|
||||
this.pageCache.delete(cacheKey);
|
||||
this.cacheSize--;
|
||||
}
|
||||
});
|
||||
|
||||
// Force garbage collection if available
|
||||
if (window.gc) {
|
||||
window.gc();
|
||||
}
|
||||
}
|
||||
|
||||
getCacheKey(pageNum, scale) {
|
||||
// Round scale to 2 decimal places for caching
|
||||
const roundedScale = Math.round(scale * 100) / 100;
|
||||
return `${pageNum}_${roundedScale}`;
|
||||
}
|
||||
|
||||
async handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality) {
|
||||
// Handle render request from PageManager
|
||||
// This integrates with the PageManager's rendering pipeline
|
||||
await this.startRender(pageNum, scale, vpWidth, vpHeight, quality);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
// Clean up all resources
|
||||
this.pageCache.forEach(entry => {
|
||||
entry.bitmap.close();
|
||||
});
|
||||
|
||||
this.frontBuffer.forEach(entry => {
|
||||
entry.bitmap.close();
|
||||
});
|
||||
|
||||
this.backBuffer.forEach(entry => {
|
||||
entry.bitmap.close();
|
||||
});
|
||||
|
||||
this.pageCache.clear();
|
||||
this.frontBuffer.clear();
|
||||
this.backBuffer.clear();
|
||||
this.activeRenders.clear();
|
||||
this.renderQueue = [];
|
||||
this.pendingSwaps.clear();
|
||||
|
||||
this.performanceManager.cleanup();
|
||||
}
|
||||
|
||||
getPerformanceStats() {
|
||||
return {
|
||||
...this.performanceManager.getPerformanceStats(),
|
||||
activeRenders: this.activeRenders.size,
|
||||
cacheSize: this.cacheSize,
|
||||
renderQueueLength: this.renderQueue.length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for ES6 modules
|
||||
export { RenderSystem };
|
||||
451
src-tauri/assets/viewer/render-system.ts
Normal file
451
src-tauri/assets/viewer/render-system.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* render-system.ts — Advanced rendering system with performance optimization
|
||||
*
|
||||
* Manages the complete rendering pipeline with:
|
||||
* - Double buffering for flicker-free rendering
|
||||
* - Adaptive quality based on performance
|
||||
* - Intelligent page caching
|
||||
* - Smooth zoom and scroll handling
|
||||
*/
|
||||
|
||||
import { PerformanceManager } from './performance-manager.js';
|
||||
import { PerformanceStats } from './types.js';
|
||||
import { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
|
||||
|
||||
interface RenderTask {
|
||||
pageNum: number;
|
||||
scale: number;
|
||||
quality: number;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
bitmap: ImageBitmap;
|
||||
quality: number;
|
||||
timestamp: number;
|
||||
accessTime: number;
|
||||
}
|
||||
|
||||
class RenderSystem {
|
||||
private pdfDoc: PDFDocumentProxy;
|
||||
private container: HTMLElement;
|
||||
private pageManager: any; // Replace with proper type when available
|
||||
private performanceManager: PerformanceManager;
|
||||
|
||||
// Render state
|
||||
private currentScale: number;
|
||||
private targetScale: number;
|
||||
private renderQuality: number;
|
||||
private visiblePages: Set<number>;
|
||||
private bufferPages: Set<number>;
|
||||
private renderQueue: RenderTask[];
|
||||
private activeRenders: Map<number, boolean>;
|
||||
private pageCache: Map<string, CacheEntry>;
|
||||
private maxCacheSize: number;
|
||||
private cacheSize: number;
|
||||
|
||||
// Performance metrics
|
||||
private lastRenderTime: number;
|
||||
private renderCount: number;
|
||||
|
||||
constructor(pdfDoc: PDFDocumentProxy, container: HTMLElement, pageManager: any) {
|
||||
this.pdfDoc = pdfDoc;
|
||||
this.container = container;
|
||||
this.pageManager = pageManager;
|
||||
|
||||
try {
|
||||
this.performanceManager = new PerformanceManager();
|
||||
} catch (error) {
|
||||
console.error("Failed to create PerformanceManager:", error);
|
||||
// Fallback: create a minimal performance manager
|
||||
this.performanceManager = {
|
||||
getPerformanceStats: () => ({ fps: 60, renderQuality: 1.0, adaptiveMode: false }),
|
||||
isPerformanceCritical: () => false,
|
||||
cleanup: () => {}
|
||||
} as PerformanceManager;
|
||||
}
|
||||
|
||||
// Render state
|
||||
this.currentScale = 1.0;
|
||||
this.targetScale = 1.0;
|
||||
this.renderQuality = 1.0;
|
||||
this.visiblePages = new Set();
|
||||
this.bufferPages = new Set();
|
||||
this.renderQueue = [];
|
||||
this.activeRenders = new Map();
|
||||
this.pageCache = new Map(); // { "page_scale": {bitmap, timestamp, quality} }
|
||||
this.maxCacheSize = 20; // Max pages in cache
|
||||
this.cacheSize = 0;
|
||||
|
||||
// Performance metrics
|
||||
this.lastRenderTime = 0;
|
||||
this.renderCount = 0;
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Performance-based quality adjustments
|
||||
setInterval(() => {
|
||||
this.adjustQualityBasedOnPerformance();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
public setVisibility(visiblePages: Set<number>, bufferPages: Set<number>): void {
|
||||
this.visiblePages = new Set(visiblePages);
|
||||
this.bufferPages = new Set(bufferPages);
|
||||
this.scheduleRenders();
|
||||
}
|
||||
|
||||
public setScale(scale: number): void {
|
||||
this.targetScale = scale;
|
||||
|
||||
// Smooth transition for zoom
|
||||
if (Math.abs(this.targetScale - this.currentScale) > 0.01) {
|
||||
this.startZoomTransition();
|
||||
} else {
|
||||
this.currentScale = this.targetScale;
|
||||
this.scheduleRenders();
|
||||
}
|
||||
}
|
||||
|
||||
private startZoomTransition(): void {
|
||||
const startScale = this.currentScale;
|
||||
const endScale = this.targetScale;
|
||||
const startTime = performance.now();
|
||||
const duration = 150; // 150ms transition
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease-in-out animation
|
||||
const easedProgress = this.easeInOutCubic(progress);
|
||||
this.currentScale = startScale + (endScale - startScale) * easedProgress;
|
||||
|
||||
// Use lower quality during transition
|
||||
const transitionQuality = this.renderQuality * 0.8;
|
||||
this.scheduleRenders(transitionQuality);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
this.currentScale = endScale;
|
||||
// Final high-quality render after transition
|
||||
this.scheduleRenders(this.renderQuality);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
private easeInOutCubic(t: number): number {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
private scheduleRenders(overrideQuality: number | null = null): void {
|
||||
const quality = overrideQuality !== null ? overrideQuality : this.renderQuality;
|
||||
const scale = this.currentScale;
|
||||
|
||||
// Cancel renders for pages that are no longer needed
|
||||
this.cancelStaleRenders();
|
||||
|
||||
// Prioritize visible pages, then buffer pages
|
||||
const allPages = [...this.visiblePages, ...this.bufferPages];
|
||||
|
||||
allPages.forEach(pageNum => {
|
||||
const cacheKey = this.getCacheKey(pageNum, scale);
|
||||
|
||||
// Check if we need to render this page
|
||||
if (this.shouldRenderPage(pageNum, scale, quality)) {
|
||||
this.queueRender(pageNum, scale, quality);
|
||||
}
|
||||
});
|
||||
|
||||
// Process the render queue
|
||||
this.processRenderQueue();
|
||||
}
|
||||
|
||||
private shouldRenderPage(pageNum: number, scale: number, quality: number): boolean {
|
||||
const cacheKey = this.getCacheKey(pageNum, scale);
|
||||
|
||||
// Check if already rendering
|
||||
if (this.activeRenders.has(pageNum)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cache with acceptable quality
|
||||
const cached = this.pageCache.get(cacheKey);
|
||||
if (cached) {
|
||||
// If cached quality is acceptable, no need to re-render
|
||||
if (cached.quality >= quality * 0.9) {
|
||||
this.useCachedPage(pageNum, scale, cached.bitmap);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private queueRender(pageNum: number, scale: number, quality: number): void {
|
||||
// Avoid duplicate queue entries
|
||||
if (this.renderQueue.some(item => item.pageNum === pageNum && Math.abs(item.scale - scale) < 0.01)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prioritize visible pages
|
||||
const priority = this.visiblePages.has(pageNum) ? 1 : 2;
|
||||
|
||||
this.renderQueue.push({ pageNum, scale, quality, priority });
|
||||
|
||||
// Sort by priority (visible pages first)
|
||||
this.renderQueue.sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
private processRenderQueue(): void {
|
||||
if (this.renderQueue.length === 0) return;
|
||||
|
||||
// Limit concurrent renders based on performance
|
||||
const maxConcurrent = this.performanceManager.isPerformanceCritical() ? 2 : 4;
|
||||
|
||||
while (this.activeRenders.size < maxConcurrent && this.renderQueue.length > 0) {
|
||||
const renderTask = this.renderQueue.shift();
|
||||
if (renderTask) {
|
||||
this.startRender(renderTask.pageNum, renderTask.scale, renderTask.quality);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async startRender(pageNum: number, scale: number, quality: number): Promise<void> {
|
||||
this.activeRenders.set(pageNum, true);
|
||||
let page: PDFPageProxy | null = null;
|
||||
|
||||
try {
|
||||
page = await this.pdfDoc.getPage(pageNum);
|
||||
|
||||
// Apply quality-based scaling
|
||||
const effectiveScale = scale * quality;
|
||||
const viewport = page.getViewport({ scale: effectiveScale });
|
||||
|
||||
// Validate canvas dimensions
|
||||
const width = Math.round(viewport.width);
|
||||
const height = Math.round(viewport.height);
|
||||
|
||||
if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) {
|
||||
throw new Error(`Invalid canvas dimensions: ${width}x${height}`);
|
||||
}
|
||||
|
||||
// Create offscreen canvas
|
||||
const offscreen = new OffscreenCanvas(width, height);
|
||||
const context = offscreen.getContext('2d') as unknown as CanvasRenderingContext2D;
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Failed to get 2D context for offscreen canvas');
|
||||
}
|
||||
|
||||
// Render with quality settings
|
||||
await page.render({
|
||||
canvasContext: context,
|
||||
viewport: viewport,
|
||||
// Use lower quality rendering when performance is critical
|
||||
renderQuality: this.performanceManager.isPerformanceCritical() ? 'low' : 'high'
|
||||
}).promise;
|
||||
|
||||
// Transfer bitmap
|
||||
const bitmap = offscreen.transferToImageBitmap();
|
||||
|
||||
// Cache the result
|
||||
const cacheKey = this.getCacheKey(pageNum, scale);
|
||||
this.cachePage(cacheKey, bitmap, quality);
|
||||
|
||||
// Update display
|
||||
this.updatePageDisplay(pageNum, bitmap);
|
||||
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'name' in error && error.name !== "RenderingCancelledException") {
|
||||
console.warn(`[RenderSystem] Render failed for page ${pageNum}:`, error);
|
||||
}
|
||||
} finally {
|
||||
this.activeRenders.delete(pageNum);
|
||||
page?.cleanup();
|
||||
|
||||
// Process next in queue
|
||||
this.processRenderQueue();
|
||||
}
|
||||
}
|
||||
|
||||
private cachePage(cacheKey: string, bitmap: ImageBitmap, quality: number): void {
|
||||
// Evict if cache is full
|
||||
if (this.cacheSize >= this.maxCacheSize) {
|
||||
this.evictOldestCacheEntry();
|
||||
}
|
||||
|
||||
this.pageCache.set(cacheKey, {
|
||||
bitmap,
|
||||
quality,
|
||||
timestamp: Date.now(),
|
||||
accessTime: Date.now()
|
||||
});
|
||||
|
||||
this.cacheSize++;
|
||||
|
||||
// Update access time for LRU
|
||||
this.updateCacheAccess(cacheKey);
|
||||
}
|
||||
|
||||
private evictOldestCacheEntry(): void {
|
||||
if (this.pageCache.size === 0) return;
|
||||
|
||||
// Find least recently used
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
|
||||
this.pageCache.forEach((entry, key) => {
|
||||
if (entry.accessTime < oldestTime) {
|
||||
oldestTime = entry.accessTime;
|
||||
oldestKey = key;
|
||||
}
|
||||
});
|
||||
|
||||
if (oldestKey) {
|
||||
const entry = this.pageCache.get(oldestKey);
|
||||
if (entry) {
|
||||
entry.bitmap.close(); // Free memory
|
||||
}
|
||||
this.pageCache.delete(oldestKey);
|
||||
this.cacheSize--;
|
||||
}
|
||||
}
|
||||
|
||||
private updateCacheAccess(cacheKey: string): void {
|
||||
const entry = this.pageCache.get(cacheKey);
|
||||
if (entry) {
|
||||
entry.accessTime = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
private useCachedPage(pageNum: number, scale: number, bitmap: ImageBitmap): void {
|
||||
// Create a copy of the bitmap for display
|
||||
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
||||
const context = canvas.getContext('2d');
|
||||
if (context) {
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const displayBitmap = canvas.transferToImageBitmap();
|
||||
this.updatePageDisplay(pageNum, displayBitmap);
|
||||
}
|
||||
}
|
||||
|
||||
private updatePageDisplay(pageNum: number, bitmap: ImageBitmap): void {
|
||||
// Use the EnhancedPageManager's onRendered method to handle display
|
||||
// This ensures proper integration with the page manager's state and canvas management
|
||||
const gen = this.pageManager.renderGen;
|
||||
const quality = this.renderQuality; // Use current render quality
|
||||
|
||||
this.pageManager.onRendered(pageNum, gen, bitmap, quality);
|
||||
}
|
||||
|
||||
private cancelStaleRenders(): void {
|
||||
// Cancel renders for pages that are no longer visible or buffered
|
||||
const neededPages = new Set([...this.visiblePages, ...this.bufferPages]);
|
||||
|
||||
this.activeRenders.forEach((_, pageNum) => {
|
||||
if (!neededPages.has(pageNum)) {
|
||||
// This render is no longer needed
|
||||
this.activeRenders.delete(pageNum);
|
||||
}
|
||||
});
|
||||
|
||||
// Also clean up render queue
|
||||
this.renderQueue = this.renderQueue.filter(task =>
|
||||
neededPages.has(task.pageNum)
|
||||
);
|
||||
}
|
||||
|
||||
private adjustQualityBasedOnPerformance(): void {
|
||||
const stats = this.performanceManager.getPerformanceStats();
|
||||
|
||||
// If performance is critical, we may have already adjusted quality
|
||||
// Here we can make additional adjustments based on render load
|
||||
if (this.activeRenders.size > 4 && stats.fps < 50) {
|
||||
// Too many active renders and low FPS - reduce quality more
|
||||
const newQuality = Math.max(0.5, this.renderQuality - 0.1);
|
||||
if (newQuality < this.renderQuality) {
|
||||
this.setRenderQuality(newQuality);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setRenderQuality(quality: number): void {
|
||||
this.renderQuality = Math.max(0.3, Math.min(1.0, quality));
|
||||
|
||||
// Re-render visible pages with new quality
|
||||
if (this.visiblePages.size > 0) {
|
||||
this.scheduleRenders(this.renderQuality);
|
||||
}
|
||||
}
|
||||
|
||||
public cleanupMemory(): void {
|
||||
// Aggressive cleanup - keep only visible pages
|
||||
const visibleCacheKeys = new Set<string>();
|
||||
|
||||
this.visiblePages.forEach(pageNum => {
|
||||
const cacheKey = this.getCacheKey(pageNum, this.currentScale);
|
||||
visibleCacheKeys.add(cacheKey);
|
||||
});
|
||||
|
||||
// Remove all non-visible pages from cache
|
||||
this.pageCache.forEach((entry, cacheKey) => {
|
||||
if (!visibleCacheKeys.has(cacheKey)) {
|
||||
entry.bitmap.close();
|
||||
this.pageCache.delete(cacheKey);
|
||||
this.cacheSize--;
|
||||
}
|
||||
});
|
||||
|
||||
// Force garbage collection if available
|
||||
if (window.gc) {
|
||||
window.gc();
|
||||
}
|
||||
}
|
||||
|
||||
private getCacheKey(pageNum: number, scale: number): string {
|
||||
// Round scale to 2 decimal places for caching
|
||||
const roundedScale = Math.round(scale * 100) / 100;
|
||||
return `${pageNum}_${roundedScale}`;
|
||||
}
|
||||
|
||||
public async handleRenderRequest(pageNum: number, scale: number, vpWidth: number, vpHeight: number, quality: number): Promise<void> {
|
||||
// Handle render request from PageManager
|
||||
// This integrates with the PageManager's rendering pipeline
|
||||
await this.startRender(pageNum, scale, quality);
|
||||
}
|
||||
|
||||
public cleanup(): void {
|
||||
// Clean up all resources
|
||||
this.pageCache.forEach(entry => {
|
||||
entry.bitmap.close();
|
||||
});
|
||||
|
||||
this.activeRenders.clear();
|
||||
this.renderQueue = [];
|
||||
|
||||
this.performanceManager.cleanup();
|
||||
}
|
||||
|
||||
public getPerformanceStats(): PerformanceStats & {
|
||||
activeRenders: number;
|
||||
cacheSize: number;
|
||||
renderQueueLength: number;
|
||||
} {
|
||||
return {
|
||||
...this.performanceManager.getPerformanceStats(),
|
||||
activeRenders: this.activeRenders.size,
|
||||
cacheSize: this.cacheSize,
|
||||
renderQueueLength: this.renderQueue.length
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for ES6 modules
|
||||
export { RenderSystem };
|
||||
29
src-tauri/assets/viewer/tauri-types.d.ts
vendored
Normal file
29
src-tauri/assets/viewer/tauri-types.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
// Type definitions for Tauri-specific functionality and bridge communication
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
// Tauri IPC bridge
|
||||
__TAURI__?: {
|
||||
invoke: (cmd: string, args?: any) => Promise<any>;
|
||||
};
|
||||
|
||||
// Our custom message bridge
|
||||
MessageBridge?: any;
|
||||
}
|
||||
|
||||
// Custom brittle protocol types
|
||||
interface BrittleProtocol {
|
||||
getAnnotations(refId: string): Promise<any[]>;
|
||||
createAnnotation(refId: string, page: number, quads: any[], text?: string): Promise<string>;
|
||||
updateAnnotation(annotation: any): Promise<void>;
|
||||
deleteAnnotation(refId: string, annotationId: string): Promise<void>;
|
||||
}
|
||||
|
||||
// Extend the global scope with our custom types
|
||||
interface CustomEventMap {
|
||||
"brittle:annotations-request": CustomEvent;
|
||||
"brittle:annotations-set": CustomEvent;
|
||||
"brittle:annotation-create": CustomEvent;
|
||||
"brittle:annotation-click": CustomEvent;
|
||||
}
|
||||
}
|
||||
62
src-tauri/assets/viewer/types.d.ts
vendored
Normal file
62
src-tauri/assets/viewer/types.d.ts
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
// Common type definitions used across the PDF viewer components
|
||||
|
||||
declare module '*.js' {
|
||||
const content: any;
|
||||
export default content;
|
||||
}
|
||||
|
||||
// Common utility types
|
||||
type DispatchRenderFunction = (
|
||||
pageNum: number,
|
||||
scale: number,
|
||||
vpWidth: number,
|
||||
vpHeight: number,
|
||||
gen: number,
|
||||
quality: number
|
||||
) => void;
|
||||
|
||||
type ZoomCallback = (
|
||||
newScale: number,
|
||||
bufferSet: Set<number>,
|
||||
visibleSet: Set<number>
|
||||
) => void;
|
||||
|
||||
type GetVisibilityState = () => {
|
||||
bufferSet: Set<number>;
|
||||
visibleSet: Set<number>;
|
||||
};
|
||||
|
||||
// Annotation types
|
||||
interface Quad {
|
||||
points: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
interface Annotation {
|
||||
id: string;
|
||||
page: number;
|
||||
quads: Quad[];
|
||||
content?: string;
|
||||
color?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
// Performance monitoring types
|
||||
interface PerformanceStats {
|
||||
fps: number;
|
||||
avgFrameTime: number;
|
||||
renderQuality: number;
|
||||
adaptiveMode: boolean;
|
||||
}
|
||||
|
||||
// Viewport and geometry types
|
||||
interface Viewport {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface BoundingBox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
4
src-tauri/assets/viewer/types.js
Normal file
4
src-tauri/assets/viewer/types.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// Centralized type definitions for the PDF viewer
|
||||
// This file contains all type definitions in one place for easier management
|
||||
export {};
|
||||
//# sourceMappingURL=types.js.map
|
||||
1
src-tauri/assets/viewer/types.js.map
Normal file
1
src-tauri/assets/viewer/types.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"types.js","sourceRoot":"","sources":["../assets/viewer/types.ts"],"names":[],"mappings":"AAAA,kDAAkD;AAClD,6EAA6E","sourcesContent":["// Centralized type definitions for the PDF viewer\n// This file contains all type definitions in one place for easier management\n\n/**\n * PDF.js Library Types\n */\ninterface PDFDocumentProxy {\n getPage(pageNumber: number): Promise<PDFPageProxy>;\n numPages: number;\n destroy(): void;\n}\n\ninterface PDFPageProxy {\n getViewport(options: { scale: number }): { width: number; height: number };\n render(options: {\n canvasContext: CanvasRenderingContext2D;\n viewport: any;\n renderQuality?: string;\n }): { promise: Promise<void> };\n cleanup(): void;\n}\n\ninterface PDFDocumentLoadingTask {\n promise: Promise<PDFDocumentProxy>;\n}\n\n/**\n * DOM API Extensions\n */\ninterface OffscreenCanvas {\n transferToImageBitmap(): ImageBitmap;\n}\n\n// Extend Performance interface for non-standard memory API\ninterface Performance {\n memory?: {\n usedJSHeapSize: number;\n jsHeapSizeLimit: number;\n };\n}\n\n/**\n * Window Extensions\n */\ninterface Window {\n pdfjsLib: any;\n __TAURI__?: {\n invoke(cmd: string, args?: any): Promise<any>;\n };\n}\n\n/**\n * Common Utility Types\n */\ntype DispatchRenderFunction = (\n pageNum: number,\n scale: number,\n vpWidth: number,\n vpHeight: number,\n gen: number,\n quality: number\n) => void;\n\ntype ZoomCallback = (\n newScale: number,\n bufferSet: Set<number>,\n visibleSet: Set<number>\n) => void;\n\ntype GetVisibilityState = () => {\n bufferSet: Set<number>;\n visibleSet: Set<number>;\n};\n\n/**\n * Annotation Types\n */\ninterface Quad {\n points: { x: number; y: number }[];\n}\n\ninterface Annotation {\n id: string;\n page: number;\n quads: Quad[];\n content?: string;\n color?: string;\n type: string;\n}\n\n/**\n * Performance Monitoring Types\n */\ninterface PerformanceStats {\n fps: number;\n avgFrameTime: number;\n renderQuality: number;\n adaptiveMode: boolean;\n}\n\n/**\n * Viewport and Geometry Types\n */\ninterface Viewport {\n width: number;\n height: number;\n}\n\ninterface BoundingBox {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\n// PDF.js library declaration\ndeclare const pdfjsLib: {\n getDocument(options: { url: string }): PDFDocumentLoadingTask;\n GlobalWorkerOptions: {\n workerSrc: string;\n };\n};\n\nexport { \n PDFDocumentProxy, \n PDFPageProxy, \n PDFDocumentLoadingTask,\n OffscreenCanvas,\n PerformanceStats,\n Viewport,\n BoundingBox,\n Quad,\n Annotation,\n DispatchRenderFunction,\n ZoomCallback,\n GetVisibilityState\n};"]}
|
||||
137
src-tauri/assets/viewer/types.ts
Normal file
137
src-tauri/assets/viewer/types.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// Centralized type definitions for the PDF viewer
|
||||
// This file contains all type definitions in one place for easier management
|
||||
|
||||
/**
|
||||
* PDF.js Library Types
|
||||
*/
|
||||
interface PDFDocumentProxy {
|
||||
getPage(pageNumber: number): Promise<PDFPageProxy>;
|
||||
numPages: number;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
interface PDFPageProxy {
|
||||
getViewport(options: { scale: number }): { width: number; height: number };
|
||||
render(options: {
|
||||
canvasContext: CanvasRenderingContext2D;
|
||||
viewport: any;
|
||||
renderQuality?: string;
|
||||
}): { promise: Promise<void> };
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
interface PDFDocumentLoadingTask {
|
||||
promise: Promise<PDFDocumentProxy>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM API Extensions
|
||||
*/
|
||||
interface OffscreenCanvas {
|
||||
transferToImageBitmap(): ImageBitmap;
|
||||
}
|
||||
|
||||
// Extend Performance interface for non-standard memory API
|
||||
interface Performance {
|
||||
memory?: {
|
||||
usedJSHeapSize: number;
|
||||
jsHeapSizeLimit: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Window Extensions
|
||||
*/
|
||||
interface Window {
|
||||
pdfjsLib: any;
|
||||
__TAURI__?: {
|
||||
invoke(cmd: string, args?: any): Promise<any>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Common Utility Types
|
||||
*/
|
||||
type DispatchRenderFunction = (
|
||||
pageNum: number,
|
||||
scale: number,
|
||||
vpWidth: number,
|
||||
vpHeight: number,
|
||||
gen: number,
|
||||
quality: number
|
||||
) => void;
|
||||
|
||||
type ZoomCallback = (
|
||||
newScale: number,
|
||||
bufferSet: Set<number>,
|
||||
visibleSet: Set<number>
|
||||
) => void;
|
||||
|
||||
type GetVisibilityState = () => {
|
||||
bufferSet: Set<number>;
|
||||
visibleSet: Set<number>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Annotation Types
|
||||
*/
|
||||
interface Quad {
|
||||
points: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
interface Annotation {
|
||||
id: string;
|
||||
page: number;
|
||||
quads: Quad[];
|
||||
content?: string;
|
||||
color?: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance Monitoring Types
|
||||
*/
|
||||
interface PerformanceStats {
|
||||
fps: number;
|
||||
avgFrameTime: number;
|
||||
renderQuality: number;
|
||||
adaptiveMode: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport and Geometry Types
|
||||
*/
|
||||
interface Viewport {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface BoundingBox {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// PDF.js library declaration
|
||||
declare const pdfjsLib: {
|
||||
getDocument(options: { url: string }): PDFDocumentLoadingTask;
|
||||
GlobalWorkerOptions: {
|
||||
workerSrc: string;
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
PDFDocumentProxy,
|
||||
PDFPageProxy,
|
||||
PDFDocumentLoadingTask,
|
||||
OffscreenCanvas,
|
||||
PerformanceStats,
|
||||
Viewport,
|
||||
BoundingBox,
|
||||
Quad,
|
||||
Annotation,
|
||||
DispatchRenderFunction,
|
||||
ZoomCallback,
|
||||
GetVisibilityState
|
||||
};
|
||||
@@ -1,29 +1,20 @@
|
||||
/**
|
||||
* viewer.js — PDF viewer orchestrator.
|
||||
*
|
||||
* Rendering happens on the main thread using the pdfDoc loaded here.
|
||||
* PDF.js's own sub-worker (pdf.worker.min.js) does the heavy parsing and
|
||||
* rasterisation off-thread; only the final bitmap transfer touches the
|
||||
* main thread. A separate render-worker is not used because Tauri's WebKit
|
||||
* webview does not support nested workers (workers spawned from workers),
|
||||
* which PDF.js requires for its own internal worker.
|
||||
*
|
||||
* Init sequence:
|
||||
* 1. Parse ref_id, configure PDF.js worker source
|
||||
* 2. Load pdfDoc (for viewports + rendering)
|
||||
* 3. Fetch all page viewports at scale=1
|
||||
* 4. Create PageManager → N placeholder divs in #pages-wrapper
|
||||
* 5. Create ViewportTracker → observe placeholders
|
||||
* 6. Compute fit-to-width → create ZoomController
|
||||
* 7. Create MessageBridge
|
||||
* 8. Register lifecycle handlers
|
||||
* 9. Trigger initial reconcile
|
||||
* viewer.js — Performance-focused PDF viewer with enhanced rendering system
|
||||
*
|
||||
* Features:
|
||||
* - Double-buffered rendering for flicker-free operation
|
||||
* - Adaptive quality based on performance monitoring
|
||||
* - Intelligent scroll prediction and pre-loading
|
||||
* - Smooth zoom animations with quality transitions
|
||||
* - Memory-efficient caching with LRU eviction
|
||||
* - Comprehensive performance monitoring
|
||||
*/
|
||||
|
||||
import { ViewportTracker } from "./viewport-tracker.js";
|
||||
import { PageManager } from "./page-manager.js";
|
||||
import { ZoomController } from "./zoom-controller.js";
|
||||
import { MessageBridge } from "./message-bridge.js";
|
||||
import { ViewportTracker } from "./viewport-tracker.js";
|
||||
import { EnhancedPageManager } from "./page-manager-enhanced.js";
|
||||
import { RenderSystem } from "./render-system.js";
|
||||
import { MessageBridge } from "./message-bridge.js";
|
||||
import { AnnotationLayer } from "./annotation-layer.js";
|
||||
|
||||
// ── DOM refs ─────────────────────────────────────────────────────────────────
|
||||
const container = document.getElementById("canvas-container");
|
||||
@@ -44,9 +35,9 @@ let pageManager = null;
|
||||
let viewportTracker = null;
|
||||
let zoomController = null;
|
||||
let bridge = null;
|
||||
|
||||
let currentBufferSet = new Set();
|
||||
let currentVisibleSet = new Set();
|
||||
let annotationLayer = null;
|
||||
let renderSystem = null;
|
||||
let viewports = []; // scale=1 dims, used by text layer and annotation layer
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
function setStatus(msg) { statusEl.textContent = msg; }
|
||||
@@ -60,8 +51,21 @@ function showError(msg) {
|
||||
|
||||
function refreshPageIndicator() {
|
||||
if (!pageManager) return;
|
||||
const cur = pageManager.getCurrentPage(currentVisibleSet);
|
||||
pageIndicator.textContent = cur + " / " + pageManager.numPages;
|
||||
|
||||
try {
|
||||
const cur = pageManager.getCurrentPage(getCurrentVisibleSet());
|
||||
pageIndicator.textContent = cur + " / " + pageManager.numPages;
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh page indicator:", error);
|
||||
pageIndicator.textContent = "1 / " + (pageManager.numPages || "1");
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentVisibleSet() {
|
||||
if (viewportTracker?.visibleSet) {
|
||||
return viewportTracker.visibleSet;
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
async function fitToPage() {
|
||||
@@ -77,57 +81,105 @@ function scrollToPage(pageNum) {
|
||||
if (!pageManager) return;
|
||||
const wrap = pageManager.pageWrappers[pageNum - 1];
|
||||
if (!wrap) return;
|
||||
container.scrollTop +=
|
||||
container.scrollTop +=
|
||||
wrap.getBoundingClientRect().top - container.getBoundingClientRect().top;
|
||||
}
|
||||
|
||||
// ── Main-thread rendering ────────────────────────────────────────────────────
|
||||
// dispatchRender is called by PageManager when a page enters the buffer.
|
||||
// We fire-and-forget an async render; the gen check inside discards stale work.
|
||||
|
||||
function dispatchRender(pageNum, scale, _vpWidth, _vpHeight, gen) {
|
||||
renderPage(pageNum, scale, gen);
|
||||
}
|
||||
|
||||
async function renderPage(pageNum, scale, gen) {
|
||||
if (!pdfDoc) return;
|
||||
let page;
|
||||
// ── Render request handler ────────────────────────────────────────────────────
|
||||
async function handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality) {
|
||||
if (!renderSystem) return;
|
||||
|
||||
try {
|
||||
page = await pdfDoc.getPage(pageNum);
|
||||
if (gen !== pageManager?.renderGen) return; // superseded by zoom or cleanup
|
||||
|
||||
const vp = page.getViewport({ scale });
|
||||
const width = Math.round(vp.width);
|
||||
const height = Math.round(vp.height);
|
||||
|
||||
const offscreen = new OffscreenCanvas(width, height);
|
||||
await page.render({ canvasContext: offscreen.getContext("2d"), viewport: vp }).promise;
|
||||
if (gen !== pageManager?.renderGen) return; // superseded during render
|
||||
|
||||
const bitmap = offscreen.transferToImageBitmap();
|
||||
pageManager?.onRendered(pageNum, gen, bitmap);
|
||||
refreshPageIndicator();
|
||||
if (pageManager?.allRendered) setStatus("Ready");
|
||||
} catch (e) {
|
||||
if (e?.name !== "RenderingCancelledException") {
|
||||
console.warn("[viewer] render error page", pageNum, e);
|
||||
}
|
||||
} finally {
|
||||
page?.cleanup();
|
||||
await renderSystem.handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to render page ${pageNum}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function sendViewerState() {
|
||||
if (!bridge || !zoomController) return;
|
||||
bridge.postViewerState(refId, zoomController.scale, container.scrollTop);
|
||||
// ── Text layer rendering ──────────────────────────────────────────────────────
|
||||
function renderTextLayer(pageNum, cssVp, textContent) {
|
||||
const wrap = pageManager?.pageWrappers[pageNum - 1];
|
||||
if (!wrap) return;
|
||||
|
||||
// Remove stale text layer (e.g. after zoom)
|
||||
wrap.querySelector(".text-layer")?.remove();
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "text-layer";
|
||||
div.style.width = Math.round(cssVp.width) + "px";
|
||||
div.style.height = Math.round(cssVp.height) + "px";
|
||||
wrap.appendChild(div);
|
||||
|
||||
const pdfjsLib = window.pdfjsLib;
|
||||
if (!pdfjsLib) return;
|
||||
|
||||
if (typeof pdfjsLib.TextLayer === "function") {
|
||||
// PDF.js 3.x+ API
|
||||
try {
|
||||
const tl = new pdfjsLib.TextLayer({
|
||||
textContentSource: textContent,
|
||||
container: div,
|
||||
viewport: cssVp,
|
||||
});
|
||||
tl.render().catch(() => {});
|
||||
} catch (_) {}
|
||||
} else if (typeof pdfjsLib.renderTextLayer === "function") {
|
||||
// Legacy API (2.x)
|
||||
try {
|
||||
pdfjsLib.renderTextLayer({ textContent, container: div, viewport: cssVp });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Visibility change callback (called by ViewportTracker) ───────────────────
|
||||
function onVisibilityChange(bufferSet, visibleSet) {
|
||||
currentBufferSet = bufferSet;
|
||||
currentVisibleSet = visibleSet;
|
||||
pageManager?.reconcile(bufferSet, visibleSet);
|
||||
refreshPageIndicator();
|
||||
// ── Text selection → annotation quads ────────────────────────────────────────
|
||||
function handleTextSelection() {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
|
||||
|
||||
const selectedText = sel.toString().trim();
|
||||
if (!selectedText || !bridge) return;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
|
||||
// Walk up from the anchor node to find the page wrapper
|
||||
let node = range.startContainer;
|
||||
while (node && !(node.classList?.contains("page-wrapper"))) {
|
||||
node = node.parentElement;
|
||||
}
|
||||
if (!node) return;
|
||||
|
||||
const pageNum = parseInt(node.dataset.page, 10);
|
||||
if (!pageNum || pageNum < 1 || pageNum > viewports.length) return;
|
||||
|
||||
const vp0 = viewports[pageNum - 1];
|
||||
const scale = zoomController?.getCurrentScale() || 1;
|
||||
const rects = Array.from(range.getClientRects());
|
||||
const wrapR = node.getBoundingClientRect();
|
||||
|
||||
const quads = rects
|
||||
.filter(r => r.width > 1 && r.height > 1)
|
||||
.map(r => {
|
||||
const x0 = (r.left - wrapR.left) / scale;
|
||||
const x1 = (r.right - wrapR.left) / scale;
|
||||
const y0 = vp0.height - (r.bottom - wrapR.top) / scale;
|
||||
const y1 = vp0.height - (r.top - wrapR.top) / scale;
|
||||
return {
|
||||
points: [
|
||||
{ x: x0, y: y0 },
|
||||
{ x: x1, y: y0 },
|
||||
{ x: x1, y: y1 },
|
||||
{ x: x0, y: y1 },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
if (quads.length === 0) return;
|
||||
|
||||
// Clear browser selection so the new highlight takes over visually
|
||||
sel.removeAllRanges();
|
||||
|
||||
// 0-indexed page number for the backend model
|
||||
bridge.postAnnotationCreate(refId, pageNum - 1, quads, selectedText);
|
||||
}
|
||||
|
||||
// ── Main init ────────────────────────────────────────────────────────────────
|
||||
@@ -147,7 +199,7 @@ async function load() {
|
||||
|
||||
// 2. Fetch all page viewports at scale=1
|
||||
setStatus("Reading…");
|
||||
const viewports = [];
|
||||
viewports = [];
|
||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||
const page = await pdfDoc.getPage(i);
|
||||
const vp = page.getViewport({ scale: 1.0 });
|
||||
@@ -161,53 +213,93 @@ async function load() {
|
||||
)));
|
||||
const initialScale = (savedZoom > 0) ? Math.max(0.1, Math.min(5.0, savedZoom)) : fittedScale;
|
||||
|
||||
// 4. PageManager — creates placeholder divs
|
||||
pageManager = new PageManager(
|
||||
pagesWrapper, viewports, initialScale, DPR, dispatchRender,
|
||||
// 4. EnhancedPageManager — creates placeholder divs with quality support
|
||||
pageManager = new EnhancedPageManager(
|
||||
pagesWrapper, viewports, initialScale, DPR,
|
||||
(pageNum, scale, vpWidth, vpHeight, gen, quality) => {
|
||||
// Handle render request directly
|
||||
handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality);
|
||||
}
|
||||
);
|
||||
|
||||
// 5. ViewportTracker — IntersectionObserver fires once elements are in the
|
||||
// DOM, triggering the initial reconcile automatically.
|
||||
// 4.5. RenderSystem — handles actual page rendering
|
||||
renderSystem = new RenderSystem(pdfDoc, container, pageManager);
|
||||
|
||||
// 5. ViewportTracker — handles visibility detection
|
||||
viewportTracker = new ViewportTracker(
|
||||
container, pageManager.pageWrappers, onVisibilityChange,
|
||||
container,
|
||||
pageManager.pageWrappers,
|
||||
(bufferSet, visibleSet) => {
|
||||
pageManager.reconcile(bufferSet, visibleSet);
|
||||
renderSystem.setVisibility(visibleSet, bufferSet);
|
||||
refreshPageIndicator();
|
||||
}
|
||||
);
|
||||
|
||||
// 6. ZoomController — send state after each debounced re-render
|
||||
zoomController = new ZoomController(
|
||||
// 6. EnhancedZoomController — smooth zoom with quality management
|
||||
const EnhancedZoomController = (await import("./zoom-controller-enhanced.js")).EnhancedZoomController;
|
||||
zoomController = new EnhancedZoomController(
|
||||
container,
|
||||
pageManager,
|
||||
(newScale, bufferSet, visibleSet) => {
|
||||
pageManager.onScaleChange(newScale, bufferSet, visibleSet);
|
||||
renderSystem.setScale(newScale);
|
||||
annotationLayer?.onScaleChange(newScale);
|
||||
sendViewerState();
|
||||
},
|
||||
() => ({ bufferSet: currentBufferSet, visibleSet: currentVisibleSet }),
|
||||
zoomLabel,
|
||||
initialScale,
|
||||
() => ({ bufferSet: new Set(), visibleSet: new Set() }),
|
||||
zoomLabel
|
||||
);
|
||||
|
||||
// Set initial scale
|
||||
zoomController.applyScale(initialScale, { animate: false });
|
||||
|
||||
// 7. MessageBridge
|
||||
bridge = new MessageBridge(
|
||||
() => scrollToPage(Math.min(pageManager.getCurrentPage(currentVisibleSet) + 1, pdfDoc.numPages)),
|
||||
() => scrollToPage(Math.max(pageManager.getCurrentPage(currentVisibleSet) - 1, 1)),
|
||||
() => scrollToPage(Math.min(pageManager.getCurrentPage(viewportTracker.visibleSet) + 1, pdfDoc.numPages)),
|
||||
() => scrollToPage(Math.max(pageManager.getCurrentPage(viewportTracker.visibleSet) - 1, 1)),
|
||||
{
|
||||
onAnnotationsSet: (annotations) => {
|
||||
annotationLayer?.setAnnotations(annotations, zoomController.scale);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// 8. Toolbar buttons
|
||||
// 8. AnnotationLayer
|
||||
annotationLayer = new AnnotationLayer(
|
||||
pageManager.pageWrappers,
|
||||
viewports,
|
||||
(annotationId) => bridge.postAnnotationClick(refId, annotationId),
|
||||
);
|
||||
|
||||
// Request annotations from parent
|
||||
bridge.requestAnnotations(refId);
|
||||
|
||||
// 9. Toolbar buttons
|
||||
document.getElementById("btn-zoom-out").addEventListener("click",
|
||||
() => zoomController.applyScale(zoomController.scale / 1.25));
|
||||
() => zoomController.zoomOut());
|
||||
document.getElementById("btn-zoom-in").addEventListener("click",
|
||||
() => zoomController.applyScale(zoomController.scale * 1.25));
|
||||
() => zoomController.zoomIn());
|
||||
document.getElementById("btn-zoom-fit").addEventListener("click",
|
||||
async () => zoomController.applyScale(await fitToPage()));
|
||||
async () => {
|
||||
const fitScale = await fitToPage();
|
||||
zoomController.applyScale(fitScale, { animate: false });
|
||||
});
|
||||
|
||||
// Keyboard shortcuts + keydown forwarding to parent
|
||||
document.addEventListener("keydown", ev => {
|
||||
if (ev.target.tagName === "INPUT") return;
|
||||
if (ev.key === "+" || ev.key === "=") { ev.preventDefault(); zoomController.applyScale(zoomController.scale * 1.25); }
|
||||
if (ev.key === "-") { ev.preventDefault(); zoomController.applyScale(zoomController.scale / 1.25); }
|
||||
if (ev.key === "0") { ev.preventDefault(); fitToPage().then(s => zoomController.applyScale(s)); }
|
||||
if (ev.key === "+" || ev.key === "=") { ev.preventDefault(); zoomController.zoomIn(); }
|
||||
if (ev.key === "-") { ev.preventDefault(); zoomController.zoomOut(); }
|
||||
if (ev.key === "0") { ev.preventDefault(); fitToPage().then(s => zoomController.applyScale(s, { animate: false })); }
|
||||
bridge.forwardKeydown(ev);
|
||||
});
|
||||
|
||||
// mouseup on the document → check for text selection to create highlights
|
||||
document.addEventListener("mouseup", ev => {
|
||||
setTimeout(() => handleTextSelection(), 0);
|
||||
});
|
||||
|
||||
// Scroll → update page indicator + debounced state save
|
||||
let _scrollSaveTimer = null;
|
||||
container.addEventListener("scroll", () => {
|
||||
@@ -226,7 +318,10 @@ async function load() {
|
||||
if (document.hidden) {
|
||||
pdfDoc?.cleanup();
|
||||
} else {
|
||||
pageManager?.reconcile(currentBufferSet, currentVisibleSet);
|
||||
// Trigger a visibility update to refresh pages
|
||||
const bufferSet = new Set();
|
||||
const visibleSet = new Set();
|
||||
pageManager?.reconcile(bufferSet, visibleSet);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -234,22 +329,32 @@ async function load() {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
viewportTracker?.disconnect();
|
||||
bridge?.disconnect();
|
||||
renderSystem?.cleanup();
|
||||
pdfDoc?.destroy();
|
||||
if (zoomController) zoomController.cleanup();
|
||||
});
|
||||
|
||||
// DPR change (e.g., moving window to a monitor with different DPI)
|
||||
matchMedia(`(resolution: ${DPR}dppx)`).addEventListener("change", () => {
|
||||
if (pageManager && zoomController) {
|
||||
pageManager.onScaleChange(zoomController.scale, currentBufferSet, currentVisibleSet);
|
||||
const bufferSet = new Set();
|
||||
const visibleSet = new Set();
|
||||
pageManager.onScaleChange(zoomController.getCurrentScale(), bufferSet, visibleSet);
|
||||
}
|
||||
});
|
||||
|
||||
pageIndicator.textContent = "1 / " + pdfDoc.numPages;
|
||||
setStatus("Rendering…");
|
||||
setStatus("Ready");
|
||||
|
||||
} catch (e) {
|
||||
showError("Could not load PDF: " + (e.message ?? String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
function sendViewerState() {
|
||||
if (!bridge || !zoomController) return;
|
||||
bridge.postViewerState(refId, zoomController.getCurrentScale(), container.scrollTop);
|
||||
}
|
||||
|
||||
// Start the viewer
|
||||
load();
|
||||
1
src-tauri/assets/viewer/viewer.js.backup
Normal file
1
src-tauri/assets/viewer/viewer.js.backup
Normal file
@@ -0,0 +1 @@
|
||||
// Backup of original viewer.js - performance-focused rewrite in progress
|
||||
431
src-tauri/assets/viewer/viewer.ts
Normal file
431
src-tauri/assets/viewer/viewer.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* viewer.ts — Performance-focused PDF viewer with enhanced rendering system
|
||||
*
|
||||
* Features:
|
||||
* - Double-buffered rendering for flicker-free operation
|
||||
* - Adaptive quality based on performance monitoring
|
||||
* - Intelligent scroll prediction and pre-loading
|
||||
* - Smooth zoom animations with quality transitions
|
||||
* - Memory-efficient caching with LRU eviction
|
||||
* - Comprehensive performance monitoring
|
||||
*/
|
||||
|
||||
// @ts-ignore - Importing JavaScript modules
|
||||
import { ViewportTracker } from "./viewport-tracker.js";
|
||||
// @ts-ignore - Importing JavaScript modules
|
||||
import { EnhancedPageManager } from "./page-manager-enhanced.js";
|
||||
// @ts-ignore - Importing JavaScript modules
|
||||
import { RenderSystem } from "./render-system.js";
|
||||
// @ts-ignore - Importing JavaScript modules
|
||||
import { MessageBridge } from "./message-bridge.js";
|
||||
// @ts-ignore - Importing JavaScript modules
|
||||
import { AnnotationLayer } from "./annotation-layer.js";
|
||||
import { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
|
||||
|
||||
// Type definitions for external modules
|
||||
type ViewportTrackerType = {
|
||||
new (container: HTMLElement, pageWrappers: HTMLElement[], callback: (bufferSet: Set<number>, visibleSet: Set<number>) => void): {
|
||||
visibleSet: Set<number>;
|
||||
disconnect(): void;
|
||||
};
|
||||
};
|
||||
|
||||
type MessageBridgeType = {
|
||||
new (nextPage: () => void, prevPage: () => void, handlers: { onAnnotationsSet?: (annotations: any) => void }): {
|
||||
postAnnotationCreate(refId: string, pageNum: number, quads: any[], text: string): void;
|
||||
postAnnotationClick(refId: string, annotationId: string): void;
|
||||
requestAnnotations(refId: string): void;
|
||||
postViewerState(refId: string, scale: number, scrollTop: number): void;
|
||||
forwardKeydown(ev: KeyboardEvent): void;
|
||||
disconnect(): void;
|
||||
};
|
||||
};
|
||||
|
||||
type AnnotationLayerType = {
|
||||
new (pageWrappers: HTMLElement[], viewports: any[], onClick: (annotationId: string) => void): {
|
||||
setAnnotations(annotations: any, scale: number): void;
|
||||
onScaleChange(scale: number): void;
|
||||
};
|
||||
};
|
||||
|
||||
type EnhancedZoomControllerType = {
|
||||
new (container: HTMLElement, pageManager: any, onScaleChange: (newScale: number, bufferSet: Set<number>, visibleSet: Set<number>) => void, getVisibilityState: () => { bufferSet: Set<number>, visibleSet: Set<number> }, zoomLabel: HTMLElement | null): {
|
||||
applyScale(scale: number, options: { animate: boolean }): void;
|
||||
zoomIn(): void;
|
||||
zoomOut(): void;
|
||||
getCurrentScale(): number;
|
||||
scale: number;
|
||||
cleanup(): void;
|
||||
};
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
pdfjsLib: any;
|
||||
}
|
||||
}
|
||||
|
||||
// ── DOM refs ─────────────────────────────────────────────────────────────────
|
||||
const container = document.getElementById("canvas-container") as HTMLElement | null;
|
||||
const pagesWrapper = document.getElementById("pages-wrapper") as HTMLElement | null;
|
||||
const statusEl = document.getElementById("status") as HTMLElement | null;
|
||||
const zoomLabel = document.getElementById("zoom-label") as HTMLElement | null;
|
||||
const pageIndicator = document.getElementById("page-indicator") as HTMLElement | null;
|
||||
|
||||
// ── Global state ─────────────────────────────────────────────────────────────
|
||||
const params = new URLSearchParams(location.search);
|
||||
const refId = params.get("ref_id") || "";
|
||||
const savedZoom = parseFloat(params.get("zoom") || ""); // NaN if absent
|
||||
const savedScrollTop = parseFloat(params.get("scroll_top") || ""); // NaN if absent
|
||||
const DPR = window.devicePixelRatio || 1;
|
||||
|
||||
let pdfDoc: PDFDocumentProxy | null = null;
|
||||
let pageManager: EnhancedPageManager | null = null;
|
||||
let viewportTracker: ViewportTracker | null = null;
|
||||
let zoomController: any | null = null; // Replace with proper type when available
|
||||
let bridge: MessageBridge | null = null;
|
||||
let annotationLayer: AnnotationLayer | null = null;
|
||||
let renderSystem: RenderSystem | null = null;
|
||||
let viewports: Array<{width: number, height: number}> = []; // scale=1 dims, used by text layer and annotation layer
|
||||
|
||||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||||
function setStatus(msg: string): void {
|
||||
if (statusEl) statusEl.textContent = msg;
|
||||
}
|
||||
|
||||
function showError(msg: string): void {
|
||||
const b = document.getElementById("error-banner") as HTMLElement | null;
|
||||
if (b) {
|
||||
b.textContent = msg;
|
||||
b.style.display = "block";
|
||||
}
|
||||
setStatus("Error");
|
||||
}
|
||||
|
||||
function refreshPageIndicator(): void {
|
||||
if (!pageManager) return;
|
||||
|
||||
try {
|
||||
const cur = pageManager.getCurrentPage(getCurrentVisibleSet());
|
||||
if (pageIndicator) {
|
||||
pageIndicator.textContent = cur + " / " + pageManager.numPages;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh page indicator:", error);
|
||||
if (pageIndicator) {
|
||||
pageIndicator.textContent = "1 / " + (pageManager.numPages || "1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentVisibleSet(): Set<number> {
|
||||
if (viewportTracker?.visibleSet) {
|
||||
return viewportTracker.visibleSet;
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
async function fitToPage(): Promise<number> {
|
||||
if (!pdfDoc) return 1.0;
|
||||
const page = await pdfDoc.getPage(1);
|
||||
const vp = page.getViewport({ scale: 1.0 });
|
||||
const scaleW = (container?.clientWidth || 0 - 40) / vp.width;
|
||||
const scaleH = (container?.clientHeight || 0 - 40) / vp.height;
|
||||
return Math.max(0.1, Math.min(5.0, Math.min(scaleW, scaleH)));
|
||||
}
|
||||
|
||||
function scrollToPage(pageNum: number): void {
|
||||
if (!pageManager) return;
|
||||
const wrap = pageManager.pageWrappers[pageNum - 1];
|
||||
if (!wrap) return;
|
||||
if (container) {
|
||||
container.scrollTop +=
|
||||
wrap.getBoundingClientRect().top - container.getBoundingClientRect().top;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render request handler ────────────────────────────────────────────────────
|
||||
async function handleRenderRequest(pageNum: number, scale: number, vpWidth: number, vpHeight: number, quality: number): Promise<void> {
|
||||
if (!renderSystem) return;
|
||||
|
||||
try {
|
||||
await renderSystem.handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to render page ${pageNum}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text layer rendering ──────────────────────────────────────────────────────
|
||||
function renderTextLayer(pageNum: number, cssVp: any, textContent: any): void {
|
||||
const wrap = pageManager?.pageWrappers[pageNum - 1];
|
||||
if (!wrap) return;
|
||||
|
||||
// Remove stale text layer (e.g. after zoom)
|
||||
wrap.querySelector(".text-layer")?.remove();
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "text-layer";
|
||||
div.style.width = Math.round(cssVp.width) + "px";
|
||||
div.style.height = Math.round(cssVp.height) + "px";
|
||||
wrap.appendChild(div);
|
||||
|
||||
const pdfjsLib = window.pdfjsLib;
|
||||
if (!pdfjsLib) return;
|
||||
|
||||
if (typeof pdfjsLib.TextLayer === "function") {
|
||||
// PDF.js 3.x+ API
|
||||
try {
|
||||
const tl = new pdfjsLib.TextLayer({
|
||||
textContentSource: textContent,
|
||||
container: div,
|
||||
viewport: cssVp,
|
||||
});
|
||||
tl.render().catch(() => {});
|
||||
} catch (_) {}
|
||||
} else if (typeof pdfjsLib.renderTextLayer === "function") {
|
||||
// Legacy API (2.x)
|
||||
try {
|
||||
pdfjsLib.renderTextLayer({ textContent, container: div, viewport: cssVp });
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text selection → annotation quads ────────────────────────────────────────
|
||||
function handleTextSelection(): void {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
|
||||
|
||||
const selectedText = sel.toString().trim();
|
||||
if (!selectedText || !bridge) return;
|
||||
|
||||
const range = sel.getRangeAt(0);
|
||||
|
||||
// Walk up from the anchor node to find the page wrapper
|
||||
let node: Node | null = range.startContainer;
|
||||
while (node && !(node instanceof HTMLElement && node.classList?.contains("page-wrapper"))) {
|
||||
node = node.parentElement;
|
||||
}
|
||||
if (!node) return;
|
||||
|
||||
const pageNum = parseInt((node as HTMLElement).dataset.page || '1', 10);
|
||||
if (!pageNum || pageNum < 1 || pageNum > viewports.length) return;
|
||||
|
||||
const vp0 = viewports[pageNum - 1];
|
||||
const scale = zoomController?.getCurrentScale() || 1;
|
||||
const rects = Array.from(range.getClientRects());
|
||||
const wrapR = (node as HTMLElement).getBoundingClientRect();
|
||||
|
||||
const quads = rects
|
||||
.filter(r => r.width > 1 && r.height > 1)
|
||||
.map(r => {
|
||||
const x0 = (r.left - wrapR.left) / scale;
|
||||
const x1 = (r.right - wrapR.left) / scale;
|
||||
const y0 = vp0.height - (r.bottom - wrapR.top) / scale;
|
||||
const y1 = vp0.height - (r.top - wrapR.top) / scale;
|
||||
return {
|
||||
points: [
|
||||
{ x: x0, y: y0 },
|
||||
{ x: x1, y: y0 },
|
||||
{ x: x1, y: y1 },
|
||||
{ x: x0, y: y1 },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
if (quads.length === 0) return;
|
||||
|
||||
// Clear browser selection so the new highlight takes over visually
|
||||
sel.removeAllRanges();
|
||||
|
||||
// 0-indexed page number for the backend model
|
||||
bridge.postAnnotationCreate(refId, pageNum - 1, quads, selectedText);
|
||||
}
|
||||
|
||||
// ── Main init ────────────────────────────────────────────────────────────────
|
||||
async function load(): Promise<void> {
|
||||
if (!refId) { showError("No ref_id in URL."); return; }
|
||||
setStatus("Loading…");
|
||||
|
||||
const pdfjsLib = window.pdfjsLib;
|
||||
if (!pdfjsLib) { showError("PDF.js failed to load."); return; }
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "brittle://app/pdfjs/build/pdf.worker.min.js";
|
||||
|
||||
const pdfUrl = "brittle://app/pdf?ref_id=" + encodeURIComponent(refId);
|
||||
|
||||
try {
|
||||
// 1. Load PDF
|
||||
const loadingTask = pdfjsLib.getDocument({ url: pdfUrl });
|
||||
pdfDoc = await loadingTask.promise;
|
||||
|
||||
// 2. Fetch all page viewports at scale=1
|
||||
setStatus("Reading…");
|
||||
viewports = [];
|
||||
if (pdfDoc) {
|
||||
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||
const page = await pdfDoc.getPage(i);
|
||||
const vp = page.getViewport({ scale: 1.0 });
|
||||
viewports.push({ width: vp.width, height: vp.height });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Compute initial scale: use saved zoom if available, else fit full page
|
||||
const fittedScale = Math.max(0.1, Math.min(5.0, Math.min(
|
||||
((container?.clientWidth || 0) - 40) / viewports[0].width,
|
||||
((container?.clientHeight || 0) - 40) / viewports[0].height,
|
||||
)));
|
||||
const initialScale = (savedZoom > 0) ? Math.max(0.1, Math.min(5.0, savedZoom)) : fittedScale;
|
||||
|
||||
// 4. EnhancedPageManager — creates placeholder divs with quality support
|
||||
if (!pagesWrapper) throw new Error("Pages wrapper not found");
|
||||
pageManager = new EnhancedPageManager(
|
||||
pagesWrapper, viewports, initialScale, DPR,
|
||||
(pageNum, scale, vpWidth, vpHeight, gen, quality) => {
|
||||
// Handle render request directly
|
||||
handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality);
|
||||
}
|
||||
);
|
||||
|
||||
// 4.5. RenderSystem — handles actual page rendering
|
||||
if (!container) throw new Error("Container not found");
|
||||
if (!pdfDoc) throw new Error("PDF document not loaded");
|
||||
renderSystem = new RenderSystem(pdfDoc, container, pageManager);
|
||||
|
||||
// 5. ViewportTracker — handles visibility detection
|
||||
if (!pageManager) throw new Error("Page manager not initialized");
|
||||
viewportTracker = new ViewportTracker(
|
||||
container,
|
||||
pageManager.pageWrappers,
|
||||
(bufferSet: Set<number>, visibleSet: Set<number>) => {
|
||||
pageManager?.reconcile(bufferSet, visibleSet);
|
||||
if (renderSystem) renderSystem.setVisibility(visibleSet, bufferSet);
|
||||
refreshPageIndicator();
|
||||
}
|
||||
);
|
||||
|
||||
// 6. EnhancedZoomController — smooth zoom with quality management
|
||||
// @ts-ignore - Importing JavaScript module
|
||||
const EnhancedZoomController = (await import("./zoom-controller-enhanced.js")).EnhancedZoomController;
|
||||
zoomController = new EnhancedZoomController(
|
||||
container,
|
||||
pageManager,
|
||||
(newScale: number, bufferSet: Set<number>, visibleSet: Set<number>) => {
|
||||
if (pageManager) pageManager.onScaleChange(newScale, bufferSet, visibleSet);
|
||||
if (renderSystem) renderSystem.setScale(newScale);
|
||||
annotationLayer?.onScaleChange(newScale);
|
||||
sendViewerState();
|
||||
},
|
||||
() => ({ bufferSet: new Set<number>(), visibleSet: new Set<number>() }),
|
||||
zoomLabel
|
||||
);
|
||||
|
||||
// Set initial scale
|
||||
zoomController?.applyScale(initialScale, { animate: false });
|
||||
|
||||
// 7. MessageBridge
|
||||
bridge = new MessageBridge(
|
||||
() => scrollToPage(Math.min((pageManager?.getCurrentPage(viewportTracker?.visibleSet || new Set<number>()) || 1), pdfDoc?.numPages || 1)),
|
||||
() => scrollToPage(Math.max((pageManager?.getCurrentPage(viewportTracker?.visibleSet || new Set<number>()) || 1) - 1, 1)),
|
||||
{
|
||||
onAnnotationsSet: (annotations: any) => {
|
||||
annotationLayer?.setAnnotations(annotations, zoomController?.scale || 1);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// 8. AnnotationLayer
|
||||
annotationLayer = new AnnotationLayer(
|
||||
pageManager?.pageWrappers || [],
|
||||
viewports,
|
||||
(annotationId: string) => bridge?.postAnnotationClick(refId, annotationId),
|
||||
);
|
||||
|
||||
// Request annotations from parent
|
||||
bridge?.requestAnnotations(refId);
|
||||
|
||||
// 9. Toolbar buttons
|
||||
document.getElementById("btn-zoom-out")?.addEventListener("click",
|
||||
() => zoomController?.zoomOut());
|
||||
document.getElementById("btn-zoom-in")?.addEventListener("click",
|
||||
() => zoomController?.zoomIn());
|
||||
document.getElementById("btn-zoom-fit")?.addEventListener("click",
|
||||
async () => {
|
||||
const fitScale = await fitToPage();
|
||||
zoomController?.applyScale(fitScale, { animate: false });
|
||||
});
|
||||
|
||||
// Keyboard shortcuts + keydown forwarding to parent
|
||||
document.addEventListener("keydown", ev => {
|
||||
if ((ev.target as HTMLElement).tagName === "INPUT") return;
|
||||
if (ev.key === "+" || ev.key === "=") { ev.preventDefault(); zoomController?.zoomIn(); }
|
||||
if (ev.key === "-") { ev.preventDefault(); zoomController?.zoomOut(); }
|
||||
if (ev.key === "0") { ev.preventDefault(); fitToPage().then(s => zoomController?.applyScale(s, { animate: false })); }
|
||||
bridge?.forwardKeydown(ev);
|
||||
});
|
||||
|
||||
// mouseup on the document → check for text selection to create highlights
|
||||
document.addEventListener("mouseup", ev => {
|
||||
setTimeout(() => handleTextSelection(), 0);
|
||||
});
|
||||
|
||||
// Scroll → update page indicator + debounced state save
|
||||
let _scrollSaveTimer: number | null = null;
|
||||
container?.addEventListener("scroll", () => {
|
||||
refreshPageIndicator();
|
||||
if (_scrollSaveTimer) clearTimeout(_scrollSaveTimer);
|
||||
_scrollSaveTimer = window.setTimeout(sendViewerState, 500) as unknown as number;
|
||||
}, { passive: true });
|
||||
|
||||
// Restore saved scroll position (rAF ensures layout is ready)
|
||||
if (savedScrollTop > 0 && container) {
|
||||
requestAnimationFrame(() => { container.scrollTop = savedScrollTop; });
|
||||
}
|
||||
|
||||
// Lifecycle: tab hidden → free caches; tab visible → re-reconcile
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) {
|
||||
pdfDoc?.destroy();
|
||||
} else {
|
||||
// Trigger a visibility update to refresh pages
|
||||
const bufferSet = new Set<number>();
|
||||
const visibleSet = new Set<number>();
|
||||
pageManager?.reconcile(bufferSet, visibleSet);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on unload
|
||||
window.addEventListener("beforeunload", () => {
|
||||
viewportTracker?.disconnect();
|
||||
bridge?.disconnect();
|
||||
renderSystem?.cleanup();
|
||||
pdfDoc?.destroy();
|
||||
if (zoomController) zoomController.cleanup();
|
||||
});
|
||||
|
||||
// DPR change (e.g., moving window to a monitor with different DPI)
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia(`(resolution: ${DPR}dppx)`).addEventListener("change", () => {
|
||||
if (pageManager && zoomController) {
|
||||
const bufferSet = new Set<number>();
|
||||
const visibleSet = new Set<number>();
|
||||
pageManager?.onScaleChange(zoomController.getCurrentScale(), bufferSet, visibleSet);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (pageIndicator && pdfDoc) {
|
||||
pageIndicator.textContent = "1 / " + pdfDoc.numPages;
|
||||
}
|
||||
setStatus("Ready");
|
||||
|
||||
} catch (e) {
|
||||
showError("Could not load PDF: " + (e instanceof Error ? e.message : String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
function sendViewerState(): void {
|
||||
if (!bridge || !zoomController || !container) return;
|
||||
bridge.postViewerState(refId, zoomController.getCurrentScale(), container.scrollTop);
|
||||
}
|
||||
|
||||
// Start the viewer
|
||||
load();
|
||||
236
src-tauri/assets/viewer/visibility-manager.js
Normal file
236
src-tauri/assets/viewer/visibility-manager.js
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* visibility-manager.js — Advanced visibility management with scroll prediction
|
||||
*
|
||||
* Uses IntersectionObserver for accurate page visibility detection
|
||||
* Implements scroll prediction for smooth pre-loading
|
||||
* Manages buffer zones intelligently
|
||||
*/
|
||||
|
||||
class VisibilityManager {
|
||||
constructor(container, pageElements, totalPages) {
|
||||
this.container = container;
|
||||
this.pageElements = pageElements;
|
||||
this.totalPages = totalPages;
|
||||
|
||||
// Visibility state
|
||||
this.visiblePages = new Set();
|
||||
this.bufferPages = new Set();
|
||||
this.observer = null;
|
||||
this.bufferSize = 2; // Minimum pages to buffer
|
||||
this.scrollHistory = [];
|
||||
this.maxScrollHistory = 10;
|
||||
|
||||
// Scroll prediction
|
||||
this.scrollVelocity = 0;
|
||||
this.scrollDirection = 0; // 1 for down, -1 for up, 0 for stationary
|
||||
this.lastScrollTime = 0;
|
||||
this.lastScrollPosition = 0;
|
||||
|
||||
// Performance tracking
|
||||
this.visibilityChanges = 0;
|
||||
this.lastVisibilityTime = 0;
|
||||
|
||||
// Setup observers
|
||||
this.setupIntersectionObserver();
|
||||
this.setupScrollMonitoring();
|
||||
}
|
||||
|
||||
setupIntersectionObserver() {
|
||||
this.observer = new IntersectionObserver(
|
||||
(entries) => this.handleIntersectionEntries(entries),
|
||||
{
|
||||
root: this.container,
|
||||
rootMargin: '100% 0px 100% 0px', // Large margin for extensive buffering
|
||||
threshold: [0, 0.25, 0.5, 0.75, 1.0] // Multiple thresholds for smooth transitions
|
||||
}
|
||||
);
|
||||
|
||||
// Observe all page elements
|
||||
this.pageElements.forEach(element => {
|
||||
this.observer.observe(element);
|
||||
});
|
||||
}
|
||||
|
||||
setupScrollMonitoring() {
|
||||
// Track scroll events for velocity calculation
|
||||
this.container.addEventListener('scroll', (event) => {
|
||||
this.trackScrollEvent(event);
|
||||
}, { passive: true });
|
||||
|
||||
// Periodic scroll prediction
|
||||
setInterval(() => {
|
||||
this.updateScrollPrediction();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
trackScrollEvent(event) {
|
||||
const currentTime = performance.now();
|
||||
const currentPosition = this.container.scrollTop;
|
||||
|
||||
// Calculate velocity if we have previous data
|
||||
if (this.lastScrollPosition !== 0 && this.lastScrollTime !== 0) {
|
||||
const timeDelta = currentTime - this.lastScrollTime;
|
||||
if (timeDelta > 0) {
|
||||
this.scrollVelocity = (currentPosition - this.lastScrollPosition) / timeDelta;
|
||||
this.scrollDirection = this.scrollVelocity > 0 ? 1 : (this.scrollVelocity < 0 ? -1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Store scroll history
|
||||
this.scrollHistory.push({
|
||||
position: currentPosition,
|
||||
time: currentTime,
|
||||
velocity: this.scrollVelocity
|
||||
});
|
||||
|
||||
if (this.scrollHistory.length > this.maxScrollHistory) {
|
||||
this.scrollHistory.shift();
|
||||
}
|
||||
|
||||
this.lastScrollPosition = currentPosition;
|
||||
this.lastScrollTime = currentTime;
|
||||
}
|
||||
|
||||
updateScrollPrediction() {
|
||||
if (this.scrollHistory.length < 3) return;
|
||||
|
||||
// Calculate average velocity from recent history
|
||||
const recentHistory = this.scrollHistory.slice(-5); // Last 5 entries
|
||||
if (recentHistory.length < 2) return;
|
||||
|
||||
const totalDelta = recentHistory[recentHistory.length - 1].position -
|
||||
recentHistory[0].position;
|
||||
const totalTime = recentHistory[recentHistory.length - 1].time -
|
||||
recentHistory[0].time;
|
||||
|
||||
if (totalTime > 0) {
|
||||
this.scrollVelocity = totalDelta / totalTime;
|
||||
this.scrollDirection = this.scrollVelocity > 5 ? 1 :
|
||||
(this.scrollVelocity < -5 ? -1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
handleIntersectionEntries(entries) {
|
||||
const newVisiblePages = new Set();
|
||||
const newBufferPages = new Set();
|
||||
|
||||
// Process intersection entries
|
||||
entries.forEach(entry => {
|
||||
const pageNum = parseInt(entry.target.dataset.page);
|
||||
if (isNaN(pageNum)) return;
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
// Page is visible or in buffer zone
|
||||
if (entry.intersectionRatio >= 0.5) {
|
||||
newVisiblePages.add(pageNum);
|
||||
} else {
|
||||
newBufferPages.add(pageNum);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add intelligent buffering based on scroll prediction
|
||||
this.addPredictiveBuffering(newVisiblePages, newBufferPages);
|
||||
|
||||
// Update state if changed
|
||||
if (!this.setsEqual(this.visiblePages, newVisiblePages) ||
|
||||
!this.setsEqual(this.bufferPages, newBufferPages)) {
|
||||
|
||||
this.visiblePages = newVisiblePages;
|
||||
this.bufferPages = newBufferPages;
|
||||
this.visibilityChanges++;
|
||||
|
||||
// Notify about visibility changes
|
||||
this.notifyVisibilityChange();
|
||||
}
|
||||
}
|
||||
|
||||
addPredictiveBuffering(visiblePages, bufferPages) {
|
||||
if (visiblePages.size === 0) return;
|
||||
|
||||
const visibleArray = Array.from(visiblePages).sort((a, b) => a - b);
|
||||
const firstVisible = visibleArray[0];
|
||||
const lastVisible = visibleArray[visibleArray.length - 1];
|
||||
|
||||
// Add adjacent pages (minimum buffer)
|
||||
for (let i = 1; i <= this.bufferSize; i++) {
|
||||
if (firstVisible - i >= 1) bufferPages.add(firstVisible - i);
|
||||
if (lastVisible + i <= this.totalPages) bufferPages.add(lastVisible + i);
|
||||
}
|
||||
|
||||
// Add scroll prediction-based buffering
|
||||
if (Math.abs(this.scrollVelocity) > 10) { // Significant scrolling
|
||||
const predictionDistance = Math.min(5, Math.floor(Math.abs(this.scrollVelocity) / 2));
|
||||
|
||||
if (this.scrollDirection === 1) { // Scrolling down
|
||||
// Add more pages below
|
||||
for (let i = 1; i <= predictionDistance; i++) {
|
||||
const nextPage = lastVisible + this.bufferSize + i;
|
||||
if (nextPage <= this.totalPages) {
|
||||
bufferPages.add(nextPage);
|
||||
}
|
||||
}
|
||||
} else if (this.scrollDirection === -1) { // Scrolling up
|
||||
// Add more pages above
|
||||
for (let i = 1; i <= predictionDistance; i++) {
|
||||
const prevPage = firstVisible - this.bufferSize - i;
|
||||
if (prevPage >= 1) {
|
||||
bufferPages.add(prevPage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setsEqual(a, b) {
|
||||
if (a.size !== b.size) return false;
|
||||
return Array.from(a).every(item => b.has(item));
|
||||
}
|
||||
|
||||
notifyVisibilityChange() {
|
||||
// Throttle visibility notifications to prevent excessive rendering
|
||||
const now = performance.now();
|
||||
if (now - this.lastVisibilityTime < 50) return; // Max 20 updates per second
|
||||
|
||||
this.lastVisibilityTime = now;
|
||||
|
||||
// Notify the render system
|
||||
if (window.renderSystem) {
|
||||
window.renderSystem.setVisibility(
|
||||
Array.from(this.visiblePages),
|
||||
Array.from(this.bufferPages)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getScrollPrediction() {
|
||||
return {
|
||||
velocity: this.scrollVelocity,
|
||||
direction: this.scrollDirection,
|
||||
confidence: Math.min(1.0, Math.abs(this.scrollVelocity) / 20)
|
||||
};
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
|
||||
this.visiblePages.clear();
|
||||
this.bufferPages.clear();
|
||||
this.scrollHistory = [];
|
||||
}
|
||||
|
||||
getVisibilityStats() {
|
||||
return {
|
||||
visiblePages: Array.from(this.visiblePages).sort((a, b) => a - b),
|
||||
bufferPages: Array.from(this.bufferPages).sort((a, b) => a - b),
|
||||
visibilityChanges: this.visibilityChanges,
|
||||
scrollVelocity: this.scrollVelocity,
|
||||
scrollDirection: this.scrollDirection
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for ES6 modules
|
||||
export { VisibilityManager };
|
||||
205
src-tauri/assets/viewer/zoom-controller-enhanced.js
Normal file
205
src-tauri/assets/viewer/zoom-controller-enhanced.js
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* zoom-controller-enhanced.js — Advanced zoom controller with smooth transitions
|
||||
*
|
||||
* Provides smooth zoom animations, adaptive quality during zoom,
|
||||
* and intelligent zoom level management.
|
||||
*/
|
||||
|
||||
class EnhancedZoomController {
|
||||
constructor(container, pageManager, onScaleChange, getVisibilityState, zoomLabel) {
|
||||
this.container = container;
|
||||
this.pageManager = pageManager;
|
||||
this.onScaleChange = onScaleChange;
|
||||
this.getVisibilityState = getVisibilityState;
|
||||
this.zoomLabel = zoomLabel;
|
||||
|
||||
// Zoom state
|
||||
this.scale = 1.0;
|
||||
this.targetScale = 1.0;
|
||||
this.minScale = 0.1;
|
||||
this.maxScale = 5.0;
|
||||
this.zoomAnimation = null;
|
||||
this.isZooming = false;
|
||||
this.zoomQuality = 1.0;
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Update zoom label
|
||||
this.updateZoomLabel();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Smooth zoom with animation
|
||||
this.container.addEventListener('wheel', (event) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
this.handleZoomWheel(event);
|
||||
event.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// Double-click for zoom to fit
|
||||
this.container.addEventListener('dblclick', (event) => {
|
||||
this.zoomToFit();
|
||||
});
|
||||
}
|
||||
|
||||
handleZoomWheel(event) {
|
||||
// Calculate zoom factor based on wheel delta
|
||||
const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newScale = this.scale * zoomFactor;
|
||||
|
||||
// Apply constraints
|
||||
const constrainedScale = Math.max(this.minScale,
|
||||
Math.min(this.maxScale, newScale));
|
||||
|
||||
if (Math.abs(constrainedScale - this.scale) > 0.01) {
|
||||
this.applyScale(constrainedScale);
|
||||
}
|
||||
}
|
||||
|
||||
applyScale(newScale, options = {}) {
|
||||
const { animate = true, qualityOverride = null } = options;
|
||||
|
||||
// Constrain scale
|
||||
this.targetScale = Math.max(this.minScale,
|
||||
Math.min(this.maxScale, newScale));
|
||||
|
||||
if (Math.abs(this.targetScale - this.scale) < 0.01) return;
|
||||
|
||||
if (animate) {
|
||||
this.startZoomAnimation(qualityOverride);
|
||||
} else {
|
||||
this.scale = this.targetScale;
|
||||
this.onScaleChange(this.scale);
|
||||
this.updateZoomLabel();
|
||||
}
|
||||
}
|
||||
|
||||
startZoomAnimation(qualityOverride = null) {
|
||||
if (this.zoomAnimation) {
|
||||
cancelAnimationFrame(this.zoomAnimation);
|
||||
}
|
||||
|
||||
this.isZooming = true;
|
||||
const startScale = this.scale;
|
||||
const endScale = this.targetScale;
|
||||
const startTime = performance.now();
|
||||
const duration = 150; // 150ms for smooth zoom
|
||||
|
||||
// Set lower quality during zoom for performance
|
||||
this.zoomQuality = qualityOverride !== null ? qualityOverride : 0.8;
|
||||
|
||||
const animate = (currentTime) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease-in-out animation for natural feel
|
||||
const easedProgress = this.easeInOutCubic(progress);
|
||||
this.scale = startScale + (endScale - startScale) * easedProgress;
|
||||
|
||||
// Apply the scale
|
||||
this.applyScaleToContainer();
|
||||
|
||||
// Notify about scale change with current quality
|
||||
const { visibleSet, bufferSet } = this.getVisibilityState();
|
||||
this.onScaleChange(this.scale, visibleSet, bufferSet);
|
||||
|
||||
if (progress < 1) {
|
||||
this.zoomAnimation = requestAnimationFrame(animate);
|
||||
} else {
|
||||
this.isZooming = false;
|
||||
this.scale = endScale;
|
||||
this.zoomQuality = 1.0; // Restore full quality
|
||||
|
||||
// Final high-quality render
|
||||
const { visibleSet, bufferSet } = this.getVisibilityState();
|
||||
this.onScaleChange(this.scale, visibleSet, bufferSet);
|
||||
|
||||
this.updateZoomLabel();
|
||||
this.zoomAnimation = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.zoomAnimation = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
applyScaleToContainer() {
|
||||
// Apply CSS transform for smooth zooming
|
||||
this.container.style.transform = `scale(${this.scale})`;
|
||||
|
||||
// Also update the page manager
|
||||
if (this.pageManager.setScale) {
|
||||
this.pageManager.setScale(this.scale);
|
||||
}
|
||||
}
|
||||
|
||||
easeInOutCubic(t) {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
zoomToFit() {
|
||||
if (!this.pageManager || !this.pageManager.viewports) return;
|
||||
|
||||
const firstViewport = this.pageManager.viewports[0];
|
||||
if (!firstViewport) return;
|
||||
|
||||
const containerWidth = this.container.clientWidth;
|
||||
const containerHeight = this.container.clientHeight;
|
||||
|
||||
// Calculate fit scale
|
||||
const scaleW = (containerWidth - 40) / firstViewport.width;
|
||||
const scaleH = (containerHeight - 40) / firstViewport.height;
|
||||
const fitScale = Math.min(scaleW, scaleH);
|
||||
|
||||
this.applyScale(Math.max(this.minScale, Math.min(this.maxScale, fitScale)));
|
||||
}
|
||||
|
||||
zoomIn() {
|
||||
this.applyScale(this.scale * 1.25);
|
||||
}
|
||||
|
||||
zoomOut() {
|
||||
this.applyScale(this.scale * 0.8);
|
||||
}
|
||||
|
||||
zoomToActualSize() {
|
||||
this.applyScale(1.0);
|
||||
}
|
||||
|
||||
getCurrentScale() {
|
||||
return this.scale;
|
||||
}
|
||||
|
||||
getCurrentZoomQuality() {
|
||||
return this.zoomQuality;
|
||||
}
|
||||
|
||||
isZoomingInProgress() {
|
||||
return this.isZooming;
|
||||
}
|
||||
|
||||
updateZoomLabel() {
|
||||
if (this.zoomLabel) {
|
||||
this.zoomLabel.textContent = `${Math.round(this.scale * 100)}%`;
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.zoomAnimation) {
|
||||
cancelAnimationFrame(this.zoomAnimation);
|
||||
}
|
||||
}
|
||||
|
||||
getZoomStats() {
|
||||
return {
|
||||
currentScale: this.scale,
|
||||
targetScale: this.targetScale,
|
||||
isZooming: this.isZooming,
|
||||
zoomQuality: this.zoomQuality
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for ES6 modules
|
||||
export { EnhancedZoomController };
|
||||
231
src-tauri/assets/viewer/zoom-controller-enhanced.ts
Normal file
231
src-tauri/assets/viewer/zoom-controller-enhanced.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* zoom-controller-enhanced.ts — Advanced zoom controller with smooth transitions
|
||||
*
|
||||
* Provides smooth zoom animations, adaptive quality during zoom,
|
||||
* and intelligent zoom level management.
|
||||
*/
|
||||
|
||||
interface EnhancedZoomControllerOptions {
|
||||
animate?: boolean;
|
||||
qualityOverride?: number | null;
|
||||
}
|
||||
|
||||
class EnhancedZoomController {
|
||||
private container: HTMLElement;
|
||||
private pageManager: any; // Replace with proper type when available
|
||||
private onScaleChange: (scale: number, visibleSet: Set<number>, bufferSet: Set<number>) => void;
|
||||
private getVisibilityState: () => { visibleSet: Set<number>, bufferSet: Set<number> };
|
||||
private zoomLabel: HTMLElement | null;
|
||||
|
||||
// Zoom state
|
||||
private scale: number;
|
||||
private targetScale: number;
|
||||
private minScale: number;
|
||||
private maxScale: number;
|
||||
private zoomAnimation: number | null;
|
||||
private isZooming: boolean;
|
||||
private zoomQuality: number;
|
||||
|
||||
constructor(container: HTMLElement, pageManager: any, onScaleChange: (scale: number, visibleSet: Set<number>, bufferSet: Set<number>) => void, getVisibilityState: () => { visibleSet: Set<number>, bufferSet: Set<number> }, zoomLabel: HTMLElement | null) {
|
||||
this.container = container;
|
||||
this.pageManager = pageManager;
|
||||
this.onScaleChange = onScaleChange;
|
||||
this.getVisibilityState = getVisibilityState;
|
||||
this.zoomLabel = zoomLabel;
|
||||
|
||||
// Zoom state
|
||||
this.scale = 1.0;
|
||||
this.targetScale = 1.0;
|
||||
this.minScale = 0.1;
|
||||
this.maxScale = 5.0;
|
||||
this.zoomAnimation = null;
|
||||
this.isZooming = false;
|
||||
this.zoomQuality = 1.0;
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Update zoom label
|
||||
this.updateZoomLabel();
|
||||
}
|
||||
|
||||
private setupEventListeners(): void {
|
||||
// Smooth zoom with animation
|
||||
this.container.addEventListener('wheel', (event) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
this.handleZoomWheel(event);
|
||||
event.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// Double-click for zoom to fit
|
||||
this.container.addEventListener('dblclick', (event) => {
|
||||
this.zoomToFit();
|
||||
});
|
||||
}
|
||||
|
||||
private handleZoomWheel(event: WheelEvent): void {
|
||||
// Calculate zoom factor based on wheel delta
|
||||
const zoomFactor = event.deltaY > 0 ? 0.9 : 1.1;
|
||||
const newScale = this.scale * zoomFactor;
|
||||
|
||||
// Apply constraints
|
||||
const constrainedScale = Math.max(this.minScale,
|
||||
Math.min(this.maxScale, newScale));
|
||||
|
||||
if (Math.abs(constrainedScale - this.scale) > 0.01) {
|
||||
this.applyScale(constrainedScale);
|
||||
}
|
||||
}
|
||||
|
||||
public applyScale(newScale: number, options: EnhancedZoomControllerOptions = {}): void {
|
||||
const { animate = true, qualityOverride = null } = options;
|
||||
|
||||
// Constrain scale
|
||||
this.targetScale = Math.max(this.minScale,
|
||||
Math.min(this.maxScale, newScale));
|
||||
|
||||
if (Math.abs(this.targetScale - this.scale) < 0.01) return;
|
||||
|
||||
if (animate) {
|
||||
this.startZoomAnimation(qualityOverride);
|
||||
} else {
|
||||
this.scale = this.targetScale;
|
||||
const { visibleSet, bufferSet } = this.getVisibilityState();
|
||||
this.onScaleChange(this.scale, visibleSet, bufferSet);
|
||||
this.updateZoomLabel();
|
||||
}
|
||||
}
|
||||
|
||||
private startZoomAnimation(qualityOverride: number | null = null): void {
|
||||
if (this.zoomAnimation) {
|
||||
cancelAnimationFrame(this.zoomAnimation);
|
||||
}
|
||||
|
||||
this.isZooming = true;
|
||||
const startScale = this.scale;
|
||||
const endScale = this.targetScale;
|
||||
const startTime = performance.now();
|
||||
const duration = 150; // 150ms for smooth zoom
|
||||
|
||||
// Set lower quality during zoom for performance
|
||||
this.zoomQuality = qualityOverride !== null ? qualityOverride : 0.8;
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease-in-out animation for natural feel
|
||||
const easedProgress = this.easeInOutCubic(progress);
|
||||
this.scale = startScale + (endScale - startScale) * easedProgress;
|
||||
|
||||
// Apply the scale
|
||||
this.applyScaleToContainer();
|
||||
|
||||
// Notify about scale change with current quality
|
||||
const { visibleSet, bufferSet } = this.getVisibilityState();
|
||||
this.onScaleChange(this.scale, visibleSet, bufferSet);
|
||||
|
||||
if (progress < 1) {
|
||||
this.zoomAnimation = requestAnimationFrame(animate);
|
||||
} else {
|
||||
this.isZooming = false;
|
||||
this.scale = endScale;
|
||||
this.zoomQuality = 1.0; // Restore full quality
|
||||
|
||||
// Final high-quality render
|
||||
const { visibleSet, bufferSet } = this.getVisibilityState();
|
||||
this.onScaleChange(this.scale, visibleSet, bufferSet);
|
||||
|
||||
this.updateZoomLabel();
|
||||
this.zoomAnimation = null;
|
||||
}
|
||||
};
|
||||
|
||||
this.zoomAnimation = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
private applyScaleToContainer(): void {
|
||||
// Apply CSS transform for smooth zooming
|
||||
this.container.style.transform = `scale(${this.scale})`;
|
||||
|
||||
// Also update the page manager
|
||||
if (this.pageManager.setScale) {
|
||||
this.pageManager.setScale(this.scale);
|
||||
}
|
||||
}
|
||||
|
||||
private easeInOutCubic(t: number): number {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
private zoomToFit(): void {
|
||||
if (!this.pageManager || !this.pageManager.viewports) return;
|
||||
|
||||
const firstViewport = this.pageManager.viewports[0];
|
||||
if (!firstViewport) return;
|
||||
|
||||
const containerWidth = this.container.clientWidth;
|
||||
const containerHeight = this.container.clientHeight;
|
||||
|
||||
// Calculate fit scale
|
||||
const scaleW = (containerWidth - 40) / firstViewport.width;
|
||||
const scaleH = (containerHeight - 40) / firstViewport.height;
|
||||
const fitScale = Math.min(scaleW, scaleH);
|
||||
|
||||
this.applyScale(Math.max(this.minScale, Math.min(this.maxScale, fitScale)));
|
||||
}
|
||||
|
||||
public zoomIn(): void {
|
||||
this.applyScale(this.scale * 1.25);
|
||||
}
|
||||
|
||||
public zoomOut(): void {
|
||||
this.applyScale(this.scale * 0.8);
|
||||
}
|
||||
|
||||
public zoomToActualSize(): void {
|
||||
this.applyScale(1.0);
|
||||
}
|
||||
|
||||
public getCurrentScale(): number {
|
||||
return this.scale;
|
||||
}
|
||||
|
||||
public getCurrentZoomQuality(): number {
|
||||
return this.zoomQuality;
|
||||
}
|
||||
|
||||
public isZoomingInProgress(): boolean {
|
||||
return this.isZooming;
|
||||
}
|
||||
|
||||
private updateZoomLabel(): void {
|
||||
if (this.zoomLabel) {
|
||||
this.zoomLabel.textContent = `${Math.round(this.scale * 100)}%`;
|
||||
}
|
||||
}
|
||||
|
||||
public cleanup(): void {
|
||||
if (this.zoomAnimation) {
|
||||
cancelAnimationFrame(this.zoomAnimation);
|
||||
}
|
||||
}
|
||||
|
||||
public getZoomStats(): {
|
||||
currentScale: number;
|
||||
targetScale: number;
|
||||
isZooming: boolean;
|
||||
zoomQuality: number;
|
||||
} {
|
||||
return {
|
||||
currentScale: this.scale,
|
||||
targetScale: this.targetScale,
|
||||
isZooming: this.isZooming,
|
||||
zoomQuality: this.zoomQuality
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export for ES6 modules
|
||||
export { EnhancedZoomController };
|
||||
@@ -1,148 +0,0 @@
|
||||
// Must match the CSS constants in index.html:
|
||||
// #pages-wrapper { padding: 20px 0; gap: 12px; }
|
||||
const CONTENT_PADDING_TOP = 20;
|
||||
const PAGE_GAP = 12;
|
||||
|
||||
/**
|
||||
* ZoomController — two-phase zoom pipeline.
|
||||
*
|
||||
* Phase 1 (instant, every event):
|
||||
* CSS `zoom` on each .page-wrapper = newScale / renderScale
|
||||
* Scroll position adjusted to keep the anchor point fixed
|
||||
* Zoom label updated
|
||||
*
|
||||
* Phase 2 (debounced, 250ms after last event):
|
||||
* Calls pageManager.onScaleChange(newScale, bufferSet, visibleSet)
|
||||
* Pages re-rendered at the new native resolution
|
||||
*
|
||||
* Ctrl+Scroll coalescing: multiple wheel events within a frame are coalesced
|
||||
* via requestAnimationFrame. The 250ms debounce starts inside the rAF callback,
|
||||
* so it begins after the last event in a burst.
|
||||
*/
|
||||
export class ZoomController {
|
||||
/**
|
||||
* @param {HTMLElement} container - #canvas-container (scrollable)
|
||||
* @param {object} pageManager - PageManager instance
|
||||
* @param {Function} onReRender - (newScale, bufferSet, visibleSet) => void
|
||||
* @param {Function} getBuffer - () => { bufferSet: Set, visibleSet: Set }
|
||||
* @param {HTMLElement} zoomLabel
|
||||
* @param {number} initialScale
|
||||
*/
|
||||
constructor(container, pageManager, onReRender, getBuffer, zoomLabel, initialScale) {
|
||||
this._container = container;
|
||||
this._pm = pageManager;
|
||||
this._onReRender = onReRender;
|
||||
this._getBuffer = getBuffer;
|
||||
this._zoomLabel = zoomLabel;
|
||||
this._scale = initialScale;
|
||||
this._renderScale = initialScale;
|
||||
this._debounce = null;
|
||||
this._rafPending = null;
|
||||
|
||||
this.ZOOM_MIN = 0.1;
|
||||
this.ZOOM_MAX = 5.0;
|
||||
|
||||
this._updateLabel();
|
||||
this._bindScrollZoom();
|
||||
}
|
||||
|
||||
get scale() { return this._scale; }
|
||||
|
||||
clamp(s) {
|
||||
return Math.max(this.ZOOM_MIN, Math.min(this.ZOOM_MAX, s));
|
||||
}
|
||||
|
||||
_updateLabel() {
|
||||
this._zoomLabel.textContent = Math.round(this._scale * 100) + "%";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new zoom level.
|
||||
*
|
||||
* @param {number} newScale
|
||||
* @param {number} [anchorY] - pixel offset within container to hold fixed (default: center)
|
||||
* @param {number} [anchorX]
|
||||
*/
|
||||
applyScale(newScale, anchorY, anchorX) {
|
||||
const container = this._container;
|
||||
const oldScale = this._scale;
|
||||
this._scale = this.clamp(newScale);
|
||||
this._updateLabel();
|
||||
this._pm.setZooming(true);
|
||||
|
||||
if (anchorY === undefined) anchorY = container.clientHeight / 2;
|
||||
if (anchorX === undefined) anchorX = container.clientWidth / 2;
|
||||
|
||||
// Compute fixedAbove — the non-scaling portion of content above the anchor:
|
||||
// top padding plus one gap per page that is fully above the anchor.
|
||||
// Gaps and padding are fixed-size and do not scale with zoom, so a naive
|
||||
// `(scrollTop + anchorY) * ratio` formula accumulates one error of
|
||||
// `GAP * (ratio - 1)` per gap above the anchor — enough to shift the
|
||||
// anchor by tens of pixels near the bottom of a long document.
|
||||
const anchorContentY = container.scrollTop + anchorY;
|
||||
let fixedAbove = CONTENT_PADDING_TOP;
|
||||
let cumY = CONTENT_PADDING_TOP;
|
||||
for (const wrap of this._pm.pageWrappers) {
|
||||
// Use the element's current layout height (CSS height × CSS zoom).
|
||||
const h = parseFloat(wrap.style.height) * (parseFloat(wrap.style.zoom) || 1);
|
||||
if (cumY + h > anchorContentY) break;
|
||||
cumY += h + PAGE_GAP;
|
||||
fixedAbove += PAGE_GAP;
|
||||
}
|
||||
|
||||
// Phase 1: instant CSS zoom feedback
|
||||
const cssZoom = this._scale / this._renderScale;
|
||||
for (const wrap of this._pm.pageWrappers) {
|
||||
wrap.style.zoom = cssZoom;
|
||||
}
|
||||
|
||||
// Exact scroll anchor: scale only the page-content portion; keep the
|
||||
// fixed portion (gaps + padding) unchanged.
|
||||
// T_new = fixedAbove + (T_old + anchorY − fixedAbove) × ratio − anchorY
|
||||
const ratio = this._scale / oldScale;
|
||||
const scalable = container.scrollTop + anchorY - fixedAbove;
|
||||
container.scrollTop = Math.max(0, fixedAbove + scalable * ratio - anchorY);
|
||||
container.scrollLeft = Math.max(0, (container.scrollLeft + anchorX) * ratio - anchorX);
|
||||
|
||||
// Phase 2: debounced native re-render
|
||||
this._scheduleReRender();
|
||||
}
|
||||
|
||||
_scheduleReRender() {
|
||||
if (this._rafPending !== null) cancelAnimationFrame(this._rafPending);
|
||||
this._rafPending = requestAnimationFrame(() => {
|
||||
this._rafPending = null;
|
||||
clearTimeout(this._debounce);
|
||||
this._debounce = setTimeout(() => this._triggerReRender(), 250);
|
||||
});
|
||||
}
|
||||
|
||||
_triggerReRender() {
|
||||
this._pm.setZooming(false); // Phase 2: onScaleChange will handle cleanup
|
||||
const newScale = this._scale;
|
||||
this._renderScale = newScale;
|
||||
const { bufferSet, visibleSet } = this._getBuffer();
|
||||
this._onReRender(newScale, bufferSet, visibleSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the initial scale without triggering a re-render or CSS zoom.
|
||||
* Used when the initial fit-to-width is computed before any pages are rendered.
|
||||
*/
|
||||
setInitialScale(s) {
|
||||
this._scale = this.clamp(s);
|
||||
this._renderScale = this._scale;
|
||||
this._updateLabel();
|
||||
}
|
||||
|
||||
_bindScrollZoom() {
|
||||
this._container.addEventListener("wheel", ev => {
|
||||
if (!ev.ctrlKey) return;
|
||||
ev.preventDefault();
|
||||
const rect = this._container.getBoundingClientRect();
|
||||
const anchorY = ev.clientY - rect.top;
|
||||
const anchorX = ev.clientX - rect.left;
|
||||
this.applyScale(this._scale * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), anchorY, anchorX);
|
||||
}, { passive: false });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user