Updated `envelope-viewer.css` to enhance the layout and responsiveness of the PDF toolbar: - Adjusted padding, gap, width, and alignment for better usability. - Improved zoom section and slider styles for flexibility and consistency. Enhanced `pdf-viewer.js` to handle concurrent rendering tasks: - Added checks to prevent overlapping render tasks on the same canvas. - Implemented error handling for rendering operations to ensure stability. These changes improve the user experience and robustness of the PDF viewer.
420 lines
14 KiB
JavaScript
420 lines
14 KiB
JavaScript
// PDF.js Viewer for Blazor WASM
|
|
window.pdfViewer = {
|
|
pdfDoc: null,
|
|
pageNum: 1,
|
|
pageRendering: false,
|
|
pageNumPending: null,
|
|
scale: 1.5,
|
|
canvas: null,
|
|
ctx: null,
|
|
totalPages: 0,
|
|
currentRenderTask: null,
|
|
dotNetReference: null,
|
|
wheelEventAttached: false,
|
|
|
|
// Quality options (configurable from appsettings.json)
|
|
qualityOptions: {
|
|
thumbnailBaseScale: 0.75,
|
|
thumbnailEnableHiDPI: true,
|
|
thumbnailMaxDPR: 2.0,
|
|
mainCanvasEnableHiDPI: true,
|
|
mainCanvasMaxDPR: 2.0,
|
|
enableSmoothZoom: true,
|
|
zoomTransitionDuration: 150,
|
|
renderingOpacity: 0.85,
|
|
zoomStepPercentage: 5
|
|
},
|
|
|
|
setQualityOptions(options) {
|
|
this.qualityOptions = { ...this.qualityOptions, ...options };
|
|
console.log('PDF Viewer quality options updated:', this.qualityOptions);
|
|
|
|
// Apply CSS variables for dynamic styling
|
|
document.documentElement.style.setProperty('--zoom-transition-duration', `${options.zoomTransitionDuration}ms`);
|
|
document.documentElement.style.setProperty('--rendering-opacity', options.renderingOpacity);
|
|
},
|
|
|
|
async initialize(canvasId, pdfDataUrl, dotNetRef) {
|
|
try {
|
|
this.dotNetReference = dotNetRef;
|
|
|
|
if (typeof window.pdfjsLib === 'undefined') {
|
|
await this.waitForPdfJs();
|
|
}
|
|
|
|
const pdfjsLib = window.pdfjsLib;
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
|
|
|
this.canvas = document.getElementById(canvasId);
|
|
if (!this.canvas) {
|
|
return false;
|
|
}
|
|
|
|
this.ctx = this.canvas.getContext('2d');
|
|
this.attachWheelEvent();
|
|
|
|
const uint8Array = this.base64ToUint8Array(pdfDataUrl);
|
|
const loadingTask = pdfjsLib.getDocument({ data: uint8Array });
|
|
this.pdfDoc = await loadingTask.promise;
|
|
this.totalPages = this.pdfDoc.numPages;
|
|
|
|
await this.renderPage(this.pageNum);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('PDF viewer initialization failed:', error);
|
|
return false;
|
|
}
|
|
},
|
|
|
|
attachWheelEvent() {
|
|
if (this.wheelEventAttached) return;
|
|
|
|
// Attach to the entire document body for global zoom control
|
|
document.body.addEventListener('wheel', (e) => {
|
|
// Check if Ctrl key is pressed (zoom gesture)
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
|
|
const step = this.qualityOptions.zoomStepPercentage / 100; // Convert to decimal
|
|
|
|
if (e.deltaY < 0) {
|
|
// Scroll up = Zoom In
|
|
this.scale = Math.min(this.scale + step, 3.0);
|
|
this.queueRenderPage(this.pageNum);
|
|
if (this.dotNetReference) {
|
|
this.dotNetReference.invokeMethodAsync('OnZoomChanged', this.scale);
|
|
}
|
|
} else {
|
|
// Scroll down = Zoom Out
|
|
this.scale = Math.max(this.scale - step, 0.5);
|
|
this.queueRenderPage(this.pageNum);
|
|
if (this.dotNetReference) {
|
|
this.dotNetReference.invokeMethodAsync('OnZoomChanged', this.scale);
|
|
}
|
|
}
|
|
}
|
|
}, { passive: false });
|
|
|
|
this.wheelEventAttached = true;
|
|
},
|
|
|
|
async waitForPdfJs() {
|
|
for (let i = 0; i < 50; i++) {
|
|
if (typeof window.pdfjsLib !== 'undefined') {
|
|
return;
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
}
|
|
throw new Error('PDF.js failed to load after 5 seconds');
|
|
},
|
|
|
|
base64ToUint8Array(base64) {
|
|
// Remove data URL prefix if present
|
|
const base64String = base64.includes(',') ? base64.split(',')[1] : base64;
|
|
const raw = atob(base64String);
|
|
const uint8Array = new Uint8Array(raw.length);
|
|
for (let i = 0; i < raw.length; i++) {
|
|
uint8Array[i] = raw.charCodeAt(i);
|
|
}
|
|
return uint8Array;
|
|
},
|
|
|
|
async renderPage(num) {
|
|
this.pageRendering = true;
|
|
|
|
// Add rendering class for smooth transition (if enabled)
|
|
if (this.qualityOptions.enableSmoothZoom) {
|
|
this.canvas.classList.add('rendering');
|
|
}
|
|
|
|
try {
|
|
// Get scroll container
|
|
const container = this.canvas.closest('.pdf-canvas-wrapper');
|
|
|
|
// Store scroll position and viewport center BEFORE rendering
|
|
let scrollLeft = 0, scrollTop = 0;
|
|
let centerX = 0, centerY = 0;
|
|
let oldWidth = this.canvas.width;
|
|
let oldHeight = this.canvas.height;
|
|
|
|
if (container) {
|
|
scrollLeft = container.scrollLeft;
|
|
scrollTop = container.scrollTop;
|
|
centerX = scrollLeft + container.clientWidth / 2;
|
|
centerY = scrollTop + container.clientHeight / 2;
|
|
}
|
|
|
|
const page = await this.pdfDoc.getPage(num);
|
|
|
|
// HiDPI support for main canvas (configurable)
|
|
const dpr = this.qualityOptions.mainCanvasEnableHiDPI
|
|
? Math.min(window.devicePixelRatio || 1, this.qualityOptions.mainCanvasMaxDPR)
|
|
: 1.0;
|
|
const viewport = page.getViewport({ scale: this.scale * dpr });
|
|
|
|
// Set internal canvas resolution (high quality)
|
|
this.canvas.width = viewport.width;
|
|
this.canvas.height = viewport.height;
|
|
|
|
// Set CSS display size (visual size)
|
|
this.canvas.style.width = `${viewport.width / dpr}px`;
|
|
this.canvas.style.height = `${viewport.height / dpr}px`;
|
|
|
|
const renderContext = {
|
|
canvasContext: this.ctx,
|
|
viewport: viewport
|
|
};
|
|
|
|
if (this.currentRenderTask) {
|
|
this.currentRenderTask.cancel();
|
|
}
|
|
|
|
// Enable high-quality rendering
|
|
this.ctx.imageSmoothingEnabled = true;
|
|
this.ctx.imageSmoothingQuality = 'high';
|
|
|
|
this.currentRenderTask = page.render(renderContext);
|
|
await this.currentRenderTask.promise;
|
|
|
|
// Restore viewport center position AFTER rendering
|
|
if (container && oldWidth > 0 && oldHeight > 0) {
|
|
const scaleX = this.canvas.width / oldWidth;
|
|
const scaleY = this.canvas.height / oldHeight;
|
|
|
|
const newCenterX = centerX * scaleX;
|
|
const newCenterY = centerY * scaleY;
|
|
|
|
container.scrollLeft = newCenterX - container.clientWidth / 2;
|
|
container.scrollTop = newCenterY - container.clientHeight / 2;
|
|
}
|
|
|
|
// Remove rendering class after completion
|
|
if (this.qualityOptions.enableSmoothZoom) {
|
|
this.canvas.classList.remove('rendering');
|
|
}
|
|
|
|
this.currentRenderTask = null;
|
|
this.pageRendering = false;
|
|
|
|
if (this.pageNumPending !== null) {
|
|
this.renderPage(this.pageNumPending);
|
|
this.pageNumPending = null;
|
|
}
|
|
} catch (error) {
|
|
if (error.name !== 'RenderingCancelledException') {
|
|
console.error('Render error:', error);
|
|
}
|
|
this.canvas.classList.remove('rendering');
|
|
this.currentRenderTask = null;
|
|
this.pageRendering = false;
|
|
}
|
|
},
|
|
|
|
queueRenderPage(num) {
|
|
if (this.pageRendering) {
|
|
this.pageNumPending = num;
|
|
} else {
|
|
this.renderPage(num);
|
|
}
|
|
},
|
|
|
|
nextPage() {
|
|
if (this.pageNum >= this.totalPages) {
|
|
return false;
|
|
}
|
|
this.pageNum++;
|
|
this.queueRenderPage(this.pageNum);
|
|
return true;
|
|
},
|
|
|
|
previousPage() {
|
|
if (this.pageNum <= 1) {
|
|
return false;
|
|
}
|
|
this.pageNum--;
|
|
this.queueRenderPage(this.pageNum);
|
|
return true;
|
|
},
|
|
|
|
goToPage(num) {
|
|
if (num < 1 || num > this.totalPages) {
|
|
return false;
|
|
}
|
|
this.pageNum = num;
|
|
this.queueRenderPage(this.pageNum);
|
|
return true;
|
|
},
|
|
|
|
zoomIn() {
|
|
const step = this.qualityOptions.zoomStepPercentage / 100;
|
|
this.scale = Math.min(this.scale + step, 3.0);
|
|
this.queueRenderPage(this.pageNum);
|
|
},
|
|
|
|
zoomOut() {
|
|
const step = this.qualityOptions.zoomStepPercentage / 100;
|
|
this.scale = Math.max(this.scale - step, 0.5);
|
|
this.queueRenderPage(this.pageNum);
|
|
},
|
|
|
|
setScale(scale) {
|
|
if (scale >= 0.5 && scale <= 3.0) {
|
|
this.scale = scale;
|
|
this.queueRenderPage(this.pageNum);
|
|
}
|
|
},
|
|
|
|
async fitToWidth() {
|
|
const container = this.canvas.closest('.pdf-frame');
|
|
if (!container || !this.pdfDoc) return;
|
|
|
|
try {
|
|
const page = await this.pdfDoc.getPage(this.pageNum);
|
|
const viewport = page.getViewport({ scale: 1.0 });
|
|
|
|
const containerWidth = container.clientWidth - 80;
|
|
const optimalScale = containerWidth / viewport.width;
|
|
|
|
this.scale = Math.min(Math.max(optimalScale, 0.5), 3.0);
|
|
this.queueRenderPage(this.pageNum);
|
|
} catch (error) {
|
|
console.error('Error fitting to width:', error);
|
|
}
|
|
},
|
|
|
|
async renderThumbnail(pageNum, canvasId) {
|
|
if (!this.pdfDoc) {
|
|
console.error('PDF document not loaded for thumbnail:', pageNum);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Wait for canvas to be in DOM
|
|
let canvas = document.getElementById(canvasId);
|
|
let retries = 0;
|
|
while (!canvas && retries < 10) {
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
canvas = document.getElementById(canvasId);
|
|
retries++;
|
|
}
|
|
|
|
if (!canvas) {
|
|
console.error('Canvas not found after retries:', canvasId);
|
|
return;
|
|
}
|
|
|
|
// Check if canvas is already being used by a render task
|
|
if (canvas._renderTask) {
|
|
try {
|
|
await canvas._renderTask;
|
|
} catch (e) {
|
|
// Ignore cancellation errors
|
|
}
|
|
}
|
|
|
|
const page = await this.pdfDoc.getPage(pageNum);
|
|
|
|
// High-quality rendering with HiDPI support (configurable)
|
|
const dpr = this.qualityOptions.thumbnailEnableHiDPI
|
|
? Math.min(window.devicePixelRatio || 1, this.qualityOptions.thumbnailMaxDPR)
|
|
: 1.0;
|
|
const baseScale = this.qualityOptions.thumbnailBaseScale;
|
|
const scale = baseScale * dpr;
|
|
|
|
const viewport = page.getViewport({ scale: scale });
|
|
|
|
// Set actual canvas pixel dimensions (internal resolution)
|
|
canvas.width = viewport.width;
|
|
canvas.height = viewport.height;
|
|
|
|
// Remove any inline styles - let CSS handle display size
|
|
canvas.style.width = '';
|
|
canvas.style.height = '';
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Enable maximum quality rendering
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
|
|
const renderContext = {
|
|
canvasContext: ctx,
|
|
viewport: viewport
|
|
};
|
|
|
|
// Store render task on canvas to track active renders
|
|
const renderTask = page.render(renderContext);
|
|
canvas._renderTask = renderTask.promise;
|
|
|
|
await renderTask.promise;
|
|
|
|
// Clear the task when done
|
|
delete canvas._renderTask;
|
|
} catch (error) {
|
|
console.error('Error rendering thumbnail', pageNum, ':', error);
|
|
}
|
|
},
|
|
|
|
getCurrentPage() {
|
|
return this.pageNum;
|
|
},
|
|
|
|
getTotalPages() {
|
|
return this.totalPages;
|
|
},
|
|
|
|
getScale() {
|
|
return this.scale;
|
|
},
|
|
|
|
dispose() {
|
|
if (this.wheelEventAttached) {
|
|
this.wheelEventAttached = false;
|
|
this.dotNetReference = null;
|
|
}
|
|
this.detachResizeListeners();
|
|
},
|
|
|
|
// Resizable splitter functionality
|
|
isResizing: false,
|
|
resizeMouseMoveHandler: null,
|
|
resizeMouseUpHandler: null,
|
|
|
|
attachResizeListeners(dotNetRef) {
|
|
this.dotNetReference = dotNetRef;
|
|
|
|
this.resizeMouseMoveHandler = (e) => {
|
|
if (this.isResizing && this.dotNetReference) {
|
|
this.dotNetReference.invokeMethodAsync('OnSplitterMouseMove', e.clientX);
|
|
}
|
|
};
|
|
|
|
this.resizeMouseUpHandler = () => {
|
|
if (this.isResizing && this.dotNetReference) {
|
|
this.isResizing = false;
|
|
this.dotNetReference.invokeMethodAsync('OnSplitterMouseUp');
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousemove', this.resizeMouseMoveHandler);
|
|
document.addEventListener('mouseup', this.resizeMouseUpHandler);
|
|
},
|
|
|
|
detachResizeListeners() {
|
|
if (this.resizeMouseMoveHandler) {
|
|
document.removeEventListener('mousemove', this.resizeMouseMoveHandler);
|
|
this.resizeMouseMoveHandler = null;
|
|
}
|
|
if (this.resizeMouseUpHandler) {
|
|
document.removeEventListener('mouseup', this.resizeMouseUpHandler);
|
|
this.resizeMouseUpHandler = null;
|
|
}
|
|
this.isResizing = false;
|
|
},
|
|
|
|
startResize() {
|
|
this.isResizing = true;
|
|
}
|
|
};
|
|
|