Replace js by ts

This commit is contained in:
2026-04-01 01:27:51 +02:00
parent 4613b8e5dd
commit 6306c73f26
291 changed files with 501210 additions and 525 deletions

View 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
View 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;
}
}

View 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>;
};
}
}

View File

@@ -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>

View File

@@ -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;

View 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;
}
}

View 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;
}
}

View File

@@ -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
View 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;
}
}

View 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

File diff suppressed because one or more lines are too long

View 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 };

View 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 };

View 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 };

View 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
View 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;
}

View 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

View 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};"]}

View 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
};

View File

@@ -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();

View File

@@ -0,0 +1 @@
// Backup of original viewer.js - performance-focused rewrite in progress

View 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();

View 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 };

View 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 };

View 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 };

View File

@@ -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 });
}
}