codebook / potato /static /js /pdf-viewer.js
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
10.3 kB
/**
* 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;