/** * PDF Viewer Component * * Initializes PDF.js viewers for PDF display fields. * Supports text layer for span annotation. */ // PDF.js library is loaded from CDN const PDFJS_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174'; // Track initialized viewers const pdfViewers = new Map(); /** * Initialize all PDF viewers on the page. */ function initPDFViewers() { const containers = document.querySelectorAll('.pdf-display[data-pdf-source]'); containers.forEach(container => { if (!pdfViewers.has(container)) { const viewer = new PDFViewer(container); pdfViewers.set(container, viewer); } }); } /** * PDF Viewer class */ class PDFViewer { constructor(container) { this.container = container; this.pdfSource = container.dataset.pdfSource; this.viewMode = container.dataset.viewMode || 'scroll'; this.textLayerEnabled = container.dataset.textLayer === 'true'; this.initialPage = parseInt(container.dataset.initialPage) || 1; this.zoom = container.dataset.zoom || 'auto'; this.pdfDoc = null; this.currentPage = this.initialPage; this.totalPages = 0; this.scale = 1.0; this.rendering = false; this.pageNumPending = null; // DOM elements this.canvas = container.querySelector('.pdf-canvas'); this.canvasContainer = container.querySelector('.pdf-canvas-container'); this.textLayer = container.querySelector('.pdf-text-layer'); this.loadingEl = container.querySelector('.pdf-loading'); this.errorEl = container.querySelector('.pdf-error'); this.currentPageEl = container.querySelector('.pdf-current-page'); this.totalPagesEl = container.querySelector('.pdf-total-pages'); this.prevBtn = container.querySelector('.pdf-prev-btn'); this.nextBtn = container.querySelector('.pdf-next-btn'); this.zoomSelect = container.querySelector('.pdf-zoom-select'); this.ctx = this.canvas ? this.canvas.getContext('2d') : null; this.init(); } async init() { try { await this.loadPDFJS(); await this.loadDocument(); } catch (error) { this.showError(error.message); } } /** * Load PDF.js library dynamically if not already loaded. */ async loadPDFJS() { if (window.pdfjsLib) { return; } return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = `${PDFJS_CDN}/pdf.min.js`; script.onload = () => { // Set worker source window.pdfjsLib.GlobalWorkerOptions.workerSrc = `${PDFJS_CDN}/pdf.worker.min.js`; resolve(); }; script.onerror = () => reject(new Error('Failed to load PDF.js')); document.head.appendChild(script); }); } /** * Load the PDF document. */ async loadDocument() { this.showLoading(true); try { const loadingTask = pdfjsLib.getDocument(this.pdfSource); this.pdfDoc = await loadingTask.promise; this.totalPages = this.pdfDoc.numPages; // Update UI if (this.totalPagesEl) { this.totalPagesEl.textContent = this.totalPages; } // Set up controls this.setupControls(); // Render initial page await this.renderPage(this.currentPage); this.showLoading(false); } catch (error) { console.error('PDF load error:', error); throw new Error(`Failed to load PDF: ${error.message}`); } } /** * Set up page navigation and zoom controls. */ setupControls() { if (this.prevBtn) { this.prevBtn.addEventListener('click', () => this.prevPage()); } if (this.nextBtn) { this.nextBtn.addEventListener('click', () => this.nextPage()); } if (this.zoomSelect) { this.zoomSelect.value = this.zoom; this.zoomSelect.addEventListener('change', (e) => { this.setZoom(e.target.value); }); } // Keyboard navigation this.container.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft' || e.key === 'PageUp') { this.prevPage(); } else if (e.key === 'ArrowRight' || e.key === 'PageDown') { this.nextPage(); } }); } /** * Render a specific page. */ async renderPage(pageNum) { if (!this.pdfDoc || !this.canvas) return; if (this.rendering) { this.pageNumPending = pageNum; return; } this.rendering = true; this.currentPage = pageNum; try { const page = await this.pdfDoc.getPage(pageNum); // Calculate scale const containerWidth = this.canvasContainer?.offsetWidth || 600; let scale = this.calculateScale(page, containerWidth); const viewport = page.getViewport({ scale }); // Set canvas dimensions this.canvas.height = viewport.height; this.canvas.width = viewport.width; // Render page const renderContext = { canvasContext: this.ctx, viewport: viewport }; await page.render(renderContext).promise; // Render text layer if enabled if (this.textLayerEnabled && this.textLayer) { await this.renderTextLayer(page, viewport); } // Update page display if (this.currentPageEl) { this.currentPageEl.textContent = pageNum; } // Update button states this.updateButtonStates(); } catch (error) { console.error('Page render error:', error); } finally { this.rendering = false; // Render pending page if any if (this.pageNumPending !== null) { const pending = this.pageNumPending; this.pageNumPending = null; this.renderPage(pending); } } } /** * Calculate scale based on zoom setting. */ calculateScale(page, containerWidth) { const viewport = page.getViewport({ scale: 1 }); switch (this.zoom) { case 'auto': case 'page-width': return containerWidth / viewport.width; case 'page-fit': const containerHeight = this.container.offsetHeight || 600; const scaleX = containerWidth / viewport.width; const scaleY = containerHeight / viewport.height; return Math.min(scaleX, scaleY); default: const parsed = parseFloat(this.zoom); return isNaN(parsed) ? 1.0 : parsed; } } /** * Render the text layer for selection and annotation. */ async renderTextLayer(page, viewport) { const textContent = await page.getTextContent(); // Clear existing text layer this.textLayer.innerHTML = ''; this.textLayer.style.width = `${viewport.width}px`; this.textLayer.style.height = `${viewport.height}px`; // Use PDF.js text layer rendering const textLayerFragment = document.createDocumentFragment(); textContent.items.forEach((item, index) => { const tx = pdfjsLib.Util.transform( viewport.transform, item.transform ); const span = document.createElement('span'); span.textContent = item.str; span.style.left = `${tx[4]}px`; span.style.top = `${tx[5]}px`; span.style.fontSize = `${Math.abs(tx[0])}px`; span.style.fontFamily = item.fontName || 'sans-serif'; span.dataset.index = index; textLayerFragment.appendChild(span); }); this.textLayer.appendChild(textLayerFragment); } /** * Navigate to previous page. */ prevPage() { if (this.currentPage > 1) { this.renderPage(this.currentPage - 1); } } /** * Navigate to next page. */ nextPage() { if (this.currentPage < this.totalPages) { this.renderPage(this.currentPage + 1); } } /** * Go to a specific page. */ goToPage(pageNum) { if (pageNum >= 1 && pageNum <= this.totalPages) { this.renderPage(pageNum); } } /** * Set zoom level. */ setZoom(zoom) { this.zoom = zoom; this.renderPage(this.currentPage); } /** * Update navigation button states. */ updateButtonStates() { if (this.prevBtn) { this.prevBtn.disabled = this.currentPage <= 1; } if (this.nextBtn) { this.nextBtn.disabled = this.currentPage >= this.totalPages; } } /** * Show/hide loading indicator. */ showLoading(show) { if (this.loadingEl) { this.loadingEl.style.display = show ? 'flex' : 'none'; } } /** * Show error message. */ showError(message) { this.showLoading(false); if (this.errorEl) { this.errorEl.textContent = message; this.errorEl.style.display = 'block'; } } /** * Clean up resources. */ destroy() { if (this.pdfDoc) { this.pdfDoc.destroy(); this.pdfDoc = null; } pdfViewers.delete(this.container); } } // Auto-initialize on DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initPDFViewers); } else { initPDFViewers(); } // Re-initialize when new content is loaded (for dynamic updates) document.addEventListener('potato:content-loaded', initPDFViewers); // Export for external use window.PDFViewer = PDFViewer; window.initPDFViewers = initPDFViewers;