// 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 }; // 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; // Render text layer for copy-paste functionality await this.renderTextLayer(page, viewport, dpr); // 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; } }, async renderTextLayer(page, viewport, dpr) { try { const textLayerDiv = document.getElementById('pdf-text-layer'); if (!textLayerDiv) { return; } // Clear previous text layer textLayerDiv.innerHTML = ''; // Set text layer dimensions to match canvas display size textLayerDiv.style.width = `${viewport.width / dpr}px`; textLayerDiv.style.height = `${viewport.height / dpr}px`; // Set --scale-factor CSS variable required by PDF.js textLayerDiv.style.setProperty('--scale-factor', this.scale); // Get text content from PDF const textContent = await page.getTextContent(); // Create viewport for text layer (without DPR scaling for positioning) const textViewport = page.getViewport({ scale: this.scale }); // Render text layer using PDF.js built-in function const pdfjsLib = window.pdfjsLib; await pdfjsLib.renderTextLayer({ textContentSource: textContent, container: textLayerDiv, viewport: textViewport, textDivs: [] }).promise; } catch (error) { // Silently handle text layer errors (non-critical feature) } }, 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) { 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) { 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) { // Silently handle thumbnail errors (non-critical) } }, 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; } };