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

361
src-tauri/dist/page-manager-enhanced.js vendored Normal file
View File

@@ -0,0 +1,361 @@
/**
* page-manager-enhanced.ts — Enhanced PageManager with performance optimizations
*
* Extends the original PageManager with:
* - Better integration with render system
* - Adaptive quality support
* - Memory-efficient canvas management
* - Smooth transitions during quality changes
*/
const MAX_CANVAS_PIXELS = 16777216; // 4096 × 4096
/**
* Clamp render scale so the canvas pixel count stays within the budget.
* CSS dimensions stay correct — pages appear at the right size, just at
* reduced effective DPI when zoomed very high.
*/
function clampedRenderScale(vpWidth, vpHeight, desiredScale, dpr, quality = 1.0) {
const effectiveScale = desiredScale * quality;
const w = vpWidth * effectiveScale * dpr;
const h = vpHeight * effectiveScale * dpr;
const pixels = w * h;
if (pixels <= MAX_CANVAS_PIXELS)
return effectiveScale * dpr;
return effectiveScale * dpr * Math.sqrt(MAX_CANVAS_PIXELS / pixels);
}
const State = Object.freeze({
PLACEHOLDER: 0,
RENDERING: 1,
RENDERED: 2,
QUALITY_ADJUSTING: 3 // New state for quality transitions
});
export class EnhancedPageManager {
/**
* @param wrapper - #pages-wrapper container (already in DOM)
* @param viewports - scale=1 viewports (0-indexed)
* @param initialScale - initial display scale
* @param dpr - devicePixelRatio
* @param dispatchRender - (pageNum, scale, vpWidth, vpHeight, gen, quality) => void
*/
constructor(wrapper, viewports, initialScale, dpr, dispatchRender) {
this._wrapper = wrapper;
this._viewports = viewports;
this._scale = initialScale;
this._dpr = dpr;
this._dispatchRender = dispatchRender;
this._renderGen = 0;
this._inFlight = 0;
this._zooming = false;
this._states = new Array(viewports.length).fill(State.PLACEHOLDER);
this._canvases = new Array(viewports.length).fill(null);
this._wrappers = [];
this._currentQualities = new Array(viewports.length).fill(1.0); // Track quality per page
this._targetQualities = new Array(viewports.length).fill(1.0); // Target quality for transitions
this._needsQualityUpdate = false;
this._buildPlaceholders();
}
get pageWrappers() { return this._wrappers; }
get numPages() { return this._viewports.length; }
get renderGen() { return this._renderGen; }
/** Called by ZoomController to suppress canvas teardown during CSS zoom. */
setZooming(z) { this._zooming = z; }
setScale(scale) {
this._scale = scale;
}
setQualityForPage(pageNum, quality) {
const i = pageNum - 1;
if (i >= 0 && i < this._viewports.length) {
this._targetQualities[i] = quality;
// If page is rendered and quality changed significantly, trigger re-render
if (this._states[i] === State.RENDERED &&
Math.abs(quality - this._currentQualities[i]) > 0.1) {
this._startQualityAdjustment(i);
}
}
}
setGlobalQuality(quality) {
for (let i = 0; i < this._targetQualities.length; i++) {
this._targetQualities[i] = quality;
}
// Re-render visible pages with new quality
this._needsQualityUpdate = true;
}
_buildPlaceholders() {
for (let i = 0; i < this._viewports.length; i++) {
const vp = this._viewports[i];
const wrap = document.createElement("div");
wrap.className = "page-wrapper";
wrap.dataset.page = String(i + 1);
wrap.style.width = vp.width * this._scale + "px";
wrap.style.height = vp.height * this._scale + "px";
this._wrapper.appendChild(wrap);
this._wrappers.push(wrap);
}
}
/**
* Called on each IntersectionObserver tick.
* Renders pages entering the buffer; cleans up pages leaving it.
* Visible pages are prioritized in the render queue.
*
* @param bufferSet - 1-based page numbers in the buffer zone
* @param visibleSet - 1-based page numbers currently on screen
*/
reconcile(bufferSet, visibleSet) {
const gen = this._renderGen;
// Prioritize visible pages, then the rest of the buffer
const toRender = [
...[...visibleSet].sort((a, b) => a - b),
...[...bufferSet].filter(p => !visibleSet.has(p)).sort((a, b) => a - b),
];
for (const pageNum of toRender) {
const i = pageNum - 1;
if (i < 0 || i >= this._viewports.length)
continue;
if (this._states[i] === State.PLACEHOLDER) {
this._startRender(i, gen);
}
else if (this._states[i] === State.QUALITY_ADJUSTING) {
// Already handling quality transition
}
else if (this._needsQualityUpdate && this._states[i] === State.RENDERED) {
// Apply quality updates if needed
if (Math.abs(this._targetQualities[i] - this._currentQualities[i]) > 0.05) {
this._startQualityAdjustment(i);
}
}
}
// Clean up pages no longer in the buffer
for (let i = 0; i < this._viewports.length; i++) {
const pageNum = i + 1;
if (!this._zooming && !bufferSet.has(pageNum) && this._states[i] === State.RENDERED) {
this._cleanup(i);
}
// Cancel stale RENDERING pages outside the buffer
if (!bufferSet.has(pageNum) && (this._states[i] === State.RENDERING || this._states[i] === State.QUALITY_ADJUSTING)) {
if (this._states[i] === State.RENDERING)
this._inFlight--;
this._states[i] = State.PLACEHOLDER;
}
}
this._needsQualityUpdate = false;
}
get allRendered() { return this._inFlight === 0; }
getPageElement(pageNum) {
const i = pageNum - 1;
if (i >= 0 && i < this._wrappers.length) {
return this._wrappers[i];
}
return null;
}
_startRender(i, gen) {
const vp = this._viewports[i];
const quality = this._targetQualities[i];
const scale = clampedRenderScale(vp.width, vp.height, this._scale, this._dpr, quality);
this._states[i] = State.RENDERING;
this._inFlight++;
this._dispatchRender(i + 1, this._scale, vp.width, vp.height, gen, quality);
}
_startQualityAdjustment(i) {
// Transition to quality adjustment state
this._states[i] = State.QUALITY_ADJUSTING;
const pageNum = i + 1;
const gen = ++this._renderGen; // Increment gen to cancel any pending renders
const vp = this._viewports[i];
const quality = this._targetQualities[i];
const scale = clampedRenderScale(vp.width, vp.height, this._scale, this._dpr, quality);
this._inFlight++;
this._dispatchRender(pageNum, this._scale, vp.width, vp.height, gen, quality);
}
_cleanup(i) {
if (this._states[i] === State.RENDERING || this._states[i] === State.QUALITY_ADJUSTING) {
this._inFlight--;
}
this._states[i] = State.PLACEHOLDER;
const canvas = this._canvases[i];
if (canvas) {
canvas.remove();
this._canvases[i] = null;
}
// Also remove the text and annotation layers
const wrap = this._wrappers[i];
wrap.querySelector(".text-layer")?.remove();
wrap.querySelector(".annot-layer")?.remove();
}
/**
* Called when the worker returns a rendered bitmap.
*
* @param pageNum
* @param gen - render generation the bitmap was rendered for
* @param bitmap
* @param quality - quality at which this bitmap was rendered
*/
onRendered(pageNum, gen, bitmap, quality) {
if (gen !== this._renderGen) {
bitmap.close();
return;
}
const i = pageNum - 1;
if (i < 0 || i >= this._viewports.length ||
(this._states[i] !== State.RENDERING && this._states[i] !== State.QUALITY_ADJUSTING)) {
bitmap.close();
return;
}
this._inFlight--;
this._currentQualities[i] = quality;
const vp = this._viewports[i];
const wrap = this._wrappers[i];
const cssW = vp.width * this._scale;
const cssH = vp.height * this._scale;
// For quality adjustments, we want smooth transitions
const wasQualityAdjustment = this._states[i] === State.QUALITY_ADJUSTING;
this._states[i] = State.RENDERED;
// Remove the old canvas
const old = wrap.querySelector("canvas");
if (old) {
if (wasQualityAdjustment) {
// For quality adjustments, fade out the old canvas for smooth transition
old.style.transition = "opacity 0.15s ease-out";
old.style.opacity = "0";
setTimeout(() => old.remove(), 150);
}
else {
old.remove();
}
}
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.style.width = cssW + "px";
canvas.style.height = cssH + "px";
// Zero-copy display — fall back to drawImage if bitmaprenderer unavailable
const ctx = canvas.getContext("bitmaprenderer");
if (ctx) {
ctx.transferFromImageBitmap(bitmap);
}
else {
const ctx2d = canvas.getContext("2d");
if (ctx2d) {
ctx2d.drawImage(bitmap, 0, 0);
bitmap.close();
}
}
// For quality adjustments, fade in the new canvas
if (wasQualityAdjustment) {
canvas.style.opacity = "0";
canvas.style.transition = "opacity 0.15s ease-in";
wrap.appendChild(canvas);
requestAnimationFrame(() => {
canvas.style.opacity = "1";
});
}
else {
wrap.appendChild(canvas);
}
this._canvases[i] = canvas;
}
/**
* Get the canvas element for a specific page
*/
getPageCanvas(pageNum) {
const i = pageNum - 1;
if (i >= 0 && i < this._canvases.length) {
return this._canvases[i];
}
return null;
}
/**
* Called after the zoom debounce fires.
* Increments renderGen (cancels stale work), resizes all placeholders,
* then re-renders the buffer pages at the new resolution.
*
* @param newScale
* @param bufferSet
* @param visibleSet
*/
onScaleChange(newScale, bufferSet, visibleSet) {
this._scale = newScale;
this._renderGen++;
for (let i = 0; i < this._viewports.length; i++) {
const pageNum = i + 1;
const vp = this._viewports[i];
const wrap = this._wrappers[i];
const cssW = vp.width * newScale;
const cssH = vp.height * newScale;
wrap.style.width = cssW + "px";
wrap.style.height = cssH + "px";
wrap.style.zoom = "1";
if (bufferSet.has(pageNum)) {
// Keep the old canvas visible (stretched to the new CSS size) so there
// is no blank flash while the new render is in flight.
const canvas = this._canvases[i];
if (canvas) {
canvas.style.width = cssW + "px";
canvas.style.height = cssH + "px";
}
if (this._states[i] === State.RENDERING || this._states[i] === State.QUALITY_ADJUSTING) {
this._inFlight--;
}
this._states[i] = State.PLACEHOLDER;
}
else {
// Off-screen: clean up immediately — not visible, so no flash.
const canvas = this._canvases[i];
if (canvas) {
canvas.remove();
this._canvases[i] = null;
}
this._states[i] = State.PLACEHOLDER;
}
}
this.reconcile(bufferSet, visibleSet);
}
/**
* Returns the 1-based number of the topmost visible page.
*
* @param visibleSet
*/
getCurrentPage(visibleSet) {
// Defensive programming: handle null/undefined visibleSet
if (!visibleSet || visibleSet.size === 0) {
// Fall back to scroll position
const top = this._wrapper.parentElement?.getBoundingClientRect().top ?? 0;
for (const wrap of this._wrappers) {
if (wrap.getBoundingClientRect().bottom > top + 4) {
return parseInt(wrap.dataset.page || '1', 10);
}
}
return 1;
}
return Math.min(...visibleSet);
}
/**
* Get the current quality for a specific page
*/
getPageQuality(pageNum) {
const i = pageNum - 1;
if (i >= 0 && i < this._currentQualities.length) {
return this._currentQualities[i];
}
return 1.0;
}
/**
* Get the current render state for a page
*/
getPageState(pageNum) {
const i = pageNum - 1;
if (i >= 0 && i < this._states.length) {
return this._states[i];
}
return State.PLACEHOLDER;
}
/**
* Force cleanup of all resources
*/
cleanup() {
for (let i = 0; i < this._viewports.length; i++) {
this._cleanup(i);
}
this._inFlight = 0;
this._renderGen = 0;
}
}
//# sourceMappingURL=page-manager-enhanced.js.map

File diff suppressed because one or more lines are too long

115
src-tauri/dist/performance-manager.js vendored Normal file
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

339
src-tauri/dist/render-system.js vendored Normal file
View File

@@ -0,0 +1,339 @@
/**
* 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';
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();
if (renderTask) {
this.startRender(renderTask.pageNum, renderTask.scale, renderTask.quality);
}
}
}
async startRender(pageNum, scale, 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');
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();
}
}
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);
if (entry) {
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');
if (context) {
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, quality);
}
cleanup() {
// Clean up all resources
this.pageCache.forEach(entry => {
entry.bitmap.close();
});
this.activeRenders.clear();
this.renderQueue = [];
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 };
//# sourceMappingURL=render-system.js.map

1
src-tauri/dist/render-system.js.map vendored Normal file

File diff suppressed because one or more lines are too long

4
src-tauri/dist/types.js vendored Normal file
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

1
src-tauri/dist/types.js.map vendored Normal file
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};"]}

353
src-tauri/dist/viewer.js vendored Normal file
View File

@@ -0,0 +1,353 @@
/**
* 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";
// ── DOM refs ─────────────────────────────────────────────────────────────────
const container = document.getElementById("canvas-container");
const pagesWrapper = document.getElementById("pages-wrapper");
const statusEl = document.getElementById("status");
const zoomLabel = document.getElementById("zoom-label");
const pageIndicator = document.getElementById("page-indicator");
// ── 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 = null;
let pageManager = null;
let viewportTracker = null;
let zoomController = null; // Replace with proper type when available
let bridge = null;
let annotationLayer = null;
let renderSystem = null;
let viewports = []; // scale=1 dims, used by text layer and annotation layer
// ── Utilities ────────────────────────────────────────────────────────────────
function setStatus(msg) {
if (statusEl)
statusEl.textContent = msg;
}
function showError(msg) {
const b = document.getElementById("error-banner");
if (b) {
b.textContent = msg;
b.style.display = "block";
}
setStatus("Error");
}
function refreshPageIndicator() {
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() {
if (viewportTracker?.visibleSet) {
return viewportTracker.visibleSet;
}
return new Set();
}
async function fitToPage() {
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) {
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, scale, vpWidth, vpHeight, quality) {
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, 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 (_) { }
}
}
// ── 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 instanceof HTMLElement && node.classList?.contains("page-wrapper"))) {
node = node.parentElement;
}
if (!node)
return;
const pageNum = parseInt(node.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.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() {
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, visibleSet) => {
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, bufferSet, visibleSet) => {
if (pageManager)
pageManager.onScaleChange(newScale, bufferSet, visibleSet);
if (renderSystem)
renderSystem.setScale(newScale);
annotationLayer?.onScaleChange(newScale);
sendViewerState();
}, () => ({ 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(viewportTracker?.visibleSet || new Set()) || 1), pdfDoc?.numPages || 1)), () => scrollToPage(Math.max((pageManager?.getCurrentPage(viewportTracker?.visibleSet || new Set()) || 1) - 1, 1)), {
onAnnotationsSet: (annotations) => {
annotationLayer?.setAnnotations(annotations, zoomController?.scale || 1);
},
});
// 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?.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.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 = null;
container?.addEventListener("scroll", () => {
refreshPageIndicator();
if (_scrollSaveTimer)
clearTimeout(_scrollSaveTimer);
_scrollSaveTimer = window.setTimeout(sendViewerState, 500);
}, { 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();
const visibleSet = new Set();
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();
const visibleSet = new Set();
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() {
if (!bridge || !zoomController || !container)
return;
bridge.postViewerState(refId, zoomController.getCurrentScale(), container.scrollTop);
}
// Start the viewer
load();
//# sourceMappingURL=viewer.js.map

1
src-tauri/dist/viewer.js.map vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,168 @@
/**
* zoom-controller-enhanced.ts — 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;
const { visibleSet, bufferSet } = this.getVisibilityState();
this.onScaleChange(this.scale, visibleSet, bufferSet);
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 };
//# sourceMappingURL=zoom-controller-enhanced.js.map

File diff suppressed because one or more lines are too long