Report-Generator / templates /pdfjs_viewer.html
t
fix: switch to bullseye base to resolve wkhtmltox dependencies
19344f4
Raw
History Blame
40.5 kB
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>{{ pdf_title }}</title>
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<!-- Inline Styles -->
<style>
/* Mozilla PDF.js Viewer Visual Replication */
:root {
--sidebar-width: 200px;
--toolbar-height: 32px;
--body-bg: #525659; /* Classic PDF.js grey background */
--toolbar-bg: #2a2a2e; /* Dark Toolbar */
--sidebar-bg: #383c40;
--text-color: #f1f1f1;
--icon-color: #c0c0c0;
--icon-hover: #ffffff;
--input-bg: #1d1d1f;
--separator-color: #555;
--toast-bg: #333;
--toast-text: #fff;
}
* {
box-sizing: border-box;
user-select: none;
-webkit-user-select: none;
}
body {
background-color: var(--body-bg);
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow: hidden;
display: flex;
flex-direction: column;
color: var(--text-color);
/* Prevent native pull-to-refresh and other touch behaviors */
overscroll-behavior: none;
}
#outerContainer {
width: 100%;
height: 100%;
display: flex;
}
/* SIDEBAR */
#sidebarContainer {
width: 0;
background-color: var(--sidebar-bg);
border-right: 1px solid #000;
transition: width 0.2s ease;
display: flex;
flex-direction: column;
overflow: hidden;
}
#sidebarContainer.open {
width: var(--sidebar-width);
}
#sidebarContent {
flex: 1;
overflow-y: auto;
padding: 10px;
position: relative;
}
#sidebarContent::-webkit-scrollbar { width: 10px; }
#sidebarContent::-webkit-scrollbar-track { background: var(--sidebar-bg); }
#sidebarContent::-webkit-scrollbar-thumb { background: #666; border-radius: 10px; border: 2px solid var(--sidebar-bg); }
.thumbnail {
margin-bottom: 15px;
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
}
.thumbnail-image-container {
border: 1px solid transparent;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
background: white;
transition: border-color 0.2s;
position: relative;
}
.thumbnail.selected .thumbnail-image-container {
border-color: #63b3ed;
box-shadow: 0 0 0 2px rgba(99, 179, 237, 0.4);
}
.thumbnail-canvas {
display: block;
width: 100px;
height: auto;
}
.thumbnail-label {
font-size: 11px;
margin-top: 4px;
color: #aaa;
}
/* MAIN */
#mainContainer {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
}
/* TOOLBAR */
.toolbar {
height: var(--toolbar-height);
background-color: var(--toolbar-bg);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
box-shadow: 0 1px 0 rgba(0,0,0,0.5);
z-index: 9999;
font-size: 12px;
}
.toolbarGroup {
display: flex;
align-items: center;
gap: 2px;
}
.toolbarButton {
background: transparent;
border: none;
color: var(--icon-color);
padding: 4px 8px;
border-radius: 2px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
}
.toolbarButton:hover {
background-color: rgba(255,255,255,0.1);
color: var(--icon-hover);
}
.toolbarButton.toggled {
background-color: rgba(0,0,0,0.3);
color: var(--icon-hover);
box-shadow: inset 0 1px 0 rgba(0,0,0,0.1);
}
.verticalSeparator {
width: 1px;
height: 16px;
background-color: var(--separator-color);
margin: 0 6px;
}
.toolbarField {
background-color: var(--input-bg);
border: 1px solid transparent;
color: var(--text-color);
padding: 2px 6px;
border-radius: 2px;
font-size: 12px;
text-align: center;
outline: none;
}
.toolbarField:focus {
background-color: #000;
border-color: #63b3ed;
}
.pageNumber { width: 40px; }
.toolbarLabel { color: var(--icon-color); margin: 0 5px; }
/* VIEWER AREA */
#viewerContainer {
flex: 1;
overflow: auto;
position: absolute;
top: var(--toolbar-height);
right: 0; bottom: 0; left: 0;
background-color: var(--body-bg);
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=');
/* Important for touch handling */
touch-action: pan-x pan-y;
-webkit-overflow-scrolling: touch;
}
#viewerContainer::-webkit-scrollbar { width: 14px; height: 14px; }
#viewerContainer::-webkit-scrollbar-track { background: var(--body-bg); border-left: 1px solid rgba(255,255,255,0.1); }
#viewerContainer::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border: 3px solid transparent; background-clip: content-box; border-radius: 8px; }
#viewerContainer::-webkit-scrollbar-thumb:hover { background-color: rgba(255,255,255,0.3); }
#viewer {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
position: relative;
/* Enhance hardware acceleration for smoother pinch */
will-change: transform;
transform-origin: top center;
}
.page {
direction: ltr;
margin: 1px auto 10px auto;
position: relative;
overflow: hidden;
background-color: white;
box-shadow: 0px 4px 6px rgba(0,0,0,0.3);
border: 9px solid transparent;
background-clip: content-box;
}
.page canvas {
display: block;
width: 100%;
height: 100%;
}
/* LOADERS */
#loadingBar {
position: relative; width: 100%; height: 4px;
background: #333; border-bottom: 1px solid #000; display: none;
}
#loadingBar .bar {
position: absolute; top: 0; left: 0; height: 100%;
background-color: #63b3ed; width: 0%; transition: width 0.2s;
}
/* TOAST NOTIFICATION */
.toast-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 10000;
}
.toast {
background-color: #2b3035;
color: #fff;
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
min-width: 250px;
max-width: 350px;
font-size: 0.875rem;
opacity: 0;
transform: translateY(100%);
transition: all 0.3s ease-in-out;
pointer-events: none;
border: 1px solid rgba(255,255,255,0.1);
}
.toast.show {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.toast-header {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
color: #fff;
background-color: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-top-left-radius: calc(0.375rem - 1px);
border-top-right-radius: calc(0.375rem - 1px);
}
.toast-body {
padding: 0.75rem;
word-wrap: break-word;
}
.btn-close-white {
background: transparent url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e") center/1em auto no-repeat;
border: 0;
opacity: 0.5;
padding: 0.5rem;
margin-left: auto;
cursor: pointer;
width: 1em;
height: 1em;
}
.btn-close-white:hover { opacity: 0.75; }
@media (max-width: 600px) {
.hiddenSmall { display: none; }
:root { --sidebar-width: 180px; }
}
@media print {
.toolbar, #sidebarContainer, #loadingBar { display: none; }
#viewerContainer { position: static; overflow: visible; background: none; }
.page { margin: 0; box-shadow: none; border: none; page-break-after: always; }
}
</style>
</head>
<body>
<div id="outerContainer">
<!-- SIDEBAR -->
<div id="sidebarContainer">
<div id="sidebarContent"></div>
</div>
<!-- MAIN -->
<div id="mainContainer">
<!-- TOOLBAR -->
<div class="toolbar">
<div class="toolbarGroup">
<button id="sidebarToggle" class="toolbarButton" title="Toggle Sidebar">
<i class="bi bi-layout-sidebar"></i>
</button>
<div class="verticalSeparator"></div>
<button id="prev" class="toolbarButton" title="Previous Page">
<i class="bi bi-chevron-up"></i>
</button>
<button id="next" class="toolbarButton" title="Next Page">
<i class="bi bi-chevron-down"></i>
</button>
<input type="number" id="pageNumber" class="toolbarField pageNumber" value="1" min="1">
<span id="numPages" class="toolbarLabel">of --</span>
</div>
<div class="toolbarGroup">
<button id="zoomOut" class="toolbarButton" title="Zoom Out">
<i class="bi bi-dash"></i>
</button>
<button id="zoomIn" class="toolbarButton" title="Zoom In">
<i class="bi bi-plus"></i>
</button>
<select id="scaleSelect" class="toolbarField">
<option value="page-actual">Actual Size</option>
<option value="page-fit">Page Fit</option>
<option value="page-width">Page Width</option>
<option value="0.5">50%</option>
<option value="0.75">75%</option>
<option value="1">100%</option>
<option value="1.25">125%</option>
<option value="1.5">150%</option>
<option value="2">200%</option>
</select>
</div>
<div class="toolbarGroup hiddenSmall">
<button id="print" class="toolbarButton" title="Print">
<i class="bi bi-printer"></i>
</button>
<button id="download" class="toolbarButton" title="Download">
<i class="bi bi-download"></i>
</button>
</div>
</div>
<!-- LOADING BAR -->
<div id="loadingBar"><div class="bar" id="progressBar"></div></div>
<!-- VIEWER AREA -->
<div id="viewerContainer">
<div id="viewer" class="pdfViewer"></div>
</div>
</div>
</div>
<!-- TOAST CONTAINER -->
<div class="toast-container">
<div id="cacheToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<i class="bi bi-hdd-fill me-2" style="margin-right: 8px;"></i>
<strong style="margin-right: auto;">System</strong>
<button type="button" class="btn-close-white" onclick="hideToast()" aria-label="Close"></button>
</div>
<div class="toast-body">
File loaded from local cache.
</div>
</div>
</div>
<!-- LOAD PDF.JS BEFORE MAIN SCRIPT -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
// Check if PDF.js loaded correctly
if (typeof pdfjsLib === 'undefined') {
alert("Error: PDF.js library failed to load. Please check your internet connection.");
} else {
// Set worker source
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
}
const config = {
url: "{{ pdf_url }}",
scale: 1.25,
currentScaleValue: '1.25',
pdfIdentifier: "{{ pdf_url }}".split('/').pop() // Use filename as identifier
};
const state = {
pdfDoc: null,
pageNum: 1,
pages: [],
thumbnailsRendered: false
};
// ==========================================
// PAGE POSITION MEMORY
// ==========================================
const PageMemory = {
storageKey: 'pdf_last_pages',
getLastPage(pdfId) {
try {
const data = localStorage.getItem(this.storageKey);
if (data) {
const pages = JSON.parse(data);
return pages[pdfId] || 1;
}
} catch (e) {
console.error('Error reading last page:', e);
}
return 1;
},
saveLastPage(pdfId, pageNum) {
try {
const data = localStorage.getItem(this.storageKey);
const pages = data ? JSON.parse(data) : {};
pages[pdfId] = pageNum;
localStorage.setItem(this.storageKey, JSON.stringify(pages));
} catch (e) {
console.error('Error saving last page:', e);
}
}
};
// DOM Elements Collection
const elements = {
container: document.getElementById('viewerContainer'),
viewer: document.getElementById('viewer'),
prev: document.getElementById('prev'),
next: document.getElementById('next'),
pageNum: document.getElementById('pageNumber'),
pageCount: document.getElementById('numPages'),
zoomIn: document.getElementById('zoomIn'),
zoomOut: document.getElementById('zoomOut'),
scaleSelect: document.getElementById('scaleSelect'),
sidebarToggle: document.getElementById('sidebarToggle'),
sidebar: document.getElementById('sidebarContainer'),
sidebarContent: document.getElementById('sidebarContent'),
loadingBar: document.getElementById('loadingBar'),
progressBar: document.getElementById('progressBar'),
print: document.getElementById('print'),
download: document.getElementById('download'),
toast: document.getElementById('cacheToast')
};
// ==========================================
// PINCH TO ZOOM MODULE
// ==========================================
const PinchZoom = {
state: {
active: false,
startDist: 0,
startScale: 1,
lastRatio: 1
},
init() {
// Attach listeners to the container to capture gestures anywhere on the viewer
elements.container.addEventListener('touchstart', this.onStart.bind(this), { passive: false });
elements.container.addEventListener('touchmove', this.onMove.bind(this), { passive: false });
elements.container.addEventListener('touchend', this.onEnd.bind(this));
},
getDistance(e) {
return Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
},
getCenter(e) {
return {
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
y: (e.touches[0].clientY + e.touches[1].clientY) / 2
};
},
onStart(e) {
if (e.touches.length === 2) {
e.preventDefault(); // Prevent native browser zoom/pan
this.state.active = true;
this.state.startDist = this.getDistance(e);
this.state.startScale = config.scale;
this.state.lastRatio = 1;
// Calculate transform origin based on finger position relative to viewer
const center = this.getCenter(e);
const rect = elements.viewer.getBoundingClientRect();
// The viewer might be scrolled, getBoundingClientRect handles that
const originX = center.x - rect.left;
const originY = center.y - rect.top;
// Set transform origin so we zoom into the space between fingers
elements.viewer.style.transformOrigin = `${originX}px ${originY}px`;
elements.viewer.style.transition = 'none';
}
},
onMove(e) {
if (this.state.active && e.touches.length === 2) {
e.preventDefault();
const dist = this.getDistance(e);
const ratio = dist / this.state.startDist;
this.state.lastRatio = ratio;
// Apply temporary visual scale using CSS transform
// This is performant (60fps) compared to re-rendering canvas
elements.viewer.style.transform = `scale(${ratio})`;
}
},
onEnd(e) {
if (this.state.active && e.touches.length < 2) {
this.state.active = false;
// Calculate new final scale
let newScale = this.state.startScale * this.state.lastRatio;
// Clamp scale to reasonable limits (0.25x to 5.0x)
newScale = Math.max(0.25, Math.min(newScale, 5.0));
// Reset CSS transform
elements.viewer.style.transform = 'none';
elements.viewer.style.transformOrigin = 'center top';
// Apply new scale to config
config.scale = newScale;
config.currentScaleValue = newScale.toString();
// Update UI Dropdown
this.updateScaleDropdown(newScale);
// Re-render PDF at new resolution (crisp text)
initViewer();
}
},
updateScaleDropdown(scaleVal) {
// Check if value exists in dropdown
const rounded = Math.round(scaleVal * 100) / 100;
let exists = false;
// Try to find exact match
for(let option of elements.scaleSelect.options) {
if (Math.abs(parseFloat(option.value) - scaleVal) < 0.05) {
elements.scaleSelect.value = option.value;
exists = true;
break;
}
}
// If no match, add custom option
if (!exists) {
const opt = document.createElement('option');
opt.value = scaleVal;
opt.textContent = `${Math.round(scaleVal * 100)}%`;
elements.scaleSelect.appendChild(opt);
elements.scaleSelect.value = scaleVal;
}
}
};
// ==========================================
// INDEXED DB CACHE
// ==========================================
const PDFCache = {
dbName: 'PDFViewerCache',
storeName: 'pdfs',
MAX_TOTAL_SIZE: 100 * 1024 * 1024, // 100MB limit
async open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName);
}
};
});
},
async get(url) {
try {
const db = await this.open();
const record = await new Promise((resolve, reject) => {
const tx = db.transaction(this.storeName, 'readonly');
const req = tx.objectStore(this.storeName).get(url);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve(req.result);
});
if (!record) return null;
const data = record.data || record;
if (record.data) {
this.touch(url, record);
}
return data;
} catch (err) {
console.warn('PDFCache get error:', err);
return null;
}
},
async touch(url, record) {
try {
const db = await this.open();
const tx = db.transaction(this.storeName, 'readwrite');
record.timestamp = Date.now();
tx.objectStore(this.storeName).put(record, url);
} catch(e) {}
},
async put(url, data) {
try {
const newSize = data.byteLength;
const db = await this.open();
const tx = db.transaction(this.storeName, 'readwrite');
const store = tx.objectStore(this.storeName);
let currentSize = 0;
const entries = [];
await new Promise((resolve) => {
const req = store.openCursor();
req.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
if (cursor.key !== url) {
const val = cursor.value;
const itemSize = (val.data || val).byteLength || 0;
const itemTime = val.timestamp || 0;
currentSize += itemSize;
entries.push({ key: cursor.key, size: itemSize, timestamp: itemTime });
}
cursor.continue();
} else {
resolve();
}
};
req.onerror = () => resolve();
});
if (currentSize + newSize > this.MAX_TOTAL_SIZE) {
entries.sort((a, b) => a.timestamp - b.timestamp);
while (entries.length > 0 && (currentSize + newSize > this.MAX_TOTAL_SIZE)) {
const entry = entries.shift();
store.delete(entry.key);
currentSize -= entry.size;
console.log(`[PDFCache] Evicting ${entry.key} to free space.`);
}
}
const record = { data: data, timestamp: Date.now() };
store.put(record, url);
return new Promise((resolve) => {
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
} catch (err) {
console.warn('PDFCache put error:', err);
}
}
};
function showToast() {
elements.toast.classList.add('show');
setTimeout(hideToast, 4000);
}
function hideToast() {
elements.toast.classList.remove('show');
}
window.hideToast = hideToast;
// ==========================================
// LOADING
// ==========================================
async function loadPDF() {
if (typeof pdfjsLib === 'undefined') return;
elements.loadingBar.style.display = 'block';
elements.progressBar.style.width = '10%';
try {
let data = await PDFCache.get(config.url);
let isCached = !!data;
if (isCached) {
showToast();
}
const params = {
cMapUrl: 'https://cdn.jsdelivr.net/npm/pdf.js@3.11.174/dist/cmaps/',
cMapPacked: true,
standardFontDataUrl: 'https://cdn.jsdelivr.net/npm/pdf.js@3.11.174/dist/standard_fonts/'
};
if (data) {
params.data = data;
} else {
params.url = config.url;
}
const loadingTask = pdfjsLib.getDocument(params);
loadingTask.onProgress = (p) => {
const percent = Math.round((p.loaded / p.total) * 100);
elements.progressBar.style.width = `${percent}%`;
};
state.pdfDoc = await loadingTask.promise;
if (!isCached) {
state.pdfDoc.getData().then(pdfData => {
if (pdfData.byteLength <= 100 * 1024 * 1024) {
PDFCache.put(config.url, pdfData);
} else {
console.warn("PDF too large to cache (>100MB)");
}
}).catch(err => console.warn("Caching failed", err));
}
elements.pageCount.textContent = 'of ' + state.pdfDoc.numPages;
elements.pageNum.max = state.pdfDoc.numPages;
elements.progressBar.style.width = '100%';
setTimeout(() => elements.loadingBar.style.display = 'none', 300);
initViewer();
} catch (error) {
console.error('Error loading PDF:', error);
elements.progressBar.style.background = '#ef4444';
alert('Error loading PDF: ' + error.message);
}
}
function initViewer() {
elements.viewer.innerHTML = '';
state.pages = [];
// Get last viewed page for this PDF to restore scroll position
// If just loaded, use storage. If zoomed, use current state.pageNum
const lastPage = state.pageNum > 1 ? state.pageNum : PageMemory.getLastPage(config.pdfIdentifier);
for (let i = 1; i <= state.pdfDoc.numPages; i++) {
const pageDiv = document.createElement('div');
pageDiv.className = 'page';
pageDiv.setAttribute('data-page-number', i);
pageDiv.id = `pageContainer${i}`;
// Initial size guess (letter) until rendered
pageDiv.style.width = '612px';
pageDiv.style.height = '792px';
elements.viewer.appendChild(pageDiv);
state.pages.push(pageDiv);
}
setupIntersectionObserver();
if (!state.thumbnailsRendered) {
generateThumbnails();
state.thumbnailsRendered = true;
}
// Restore scroll position
if (lastPage > 1) {
setTimeout(() => scrollToPage(lastPage), 100);
}
}
function setupIntersectionObserver() {
const observerOptions = {
root: elements.container,
rootMargin: '500px',
threshold: 0
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const pageDiv = entry.target;
const pageNum = parseInt(pageDiv.getAttribute('data-page-number'));
if (!pageDiv.getAttribute('data-loaded')) {
renderPage(pageNum, pageDiv);
}
}
});
}, observerOptions);
state.pages.forEach(page => observer.observe(page));
const scrollObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
const pageNum = parseInt(entry.target.getAttribute('data-page-number'));
updateUIState(pageNum);
PageMemory.saveLastPage(config.pdfIdentifier, pageNum);
}
});
}, { root: elements.container, threshold: 0.5 });
state.pages.forEach(page => scrollObserver.observe(page));
}
async function renderPage(num, container) {
container.setAttribute('data-loaded', 'true');
try {
const page = await state.pdfDoc.getPage(num);
let scale = config.scale;
const viewport = page.getViewport({ scale: 1 });
if (config.currentScaleValue === 'page-width') {
const containerWidth = elements.container.clientWidth - 40;
scale = containerWidth / viewport.width;
} else if (config.currentScaleValue === 'page-fit') {
const containerHeight = elements.container.clientHeight - 40;
const containerWidth = elements.container.clientWidth - 40;
const scaleH = containerHeight / viewport.height;
const scaleW = containerWidth / viewport.width;
scale = Math.min(scaleH, scaleW);
}
const scaledViewport = page.getViewport({ scale: scale });
container.style.width = `${scaledViewport.width}px`;
container.style.height = `${scaledViewport.height}px`;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.height = scaledViewport.height;
canvas.width = scaledViewport.width;
container.appendChild(canvas);
await page.render({
canvasContext: context,
viewport: scaledViewport
}).promise;
} catch (e) {
console.error(e);
}
}
// ==========================================
// THUMBNAILS
// ==========================================
async function generateThumbnails() {
elements.sidebarContent.innerHTML = '';
for (let i = 1; i <= state.pdfDoc.numPages; i++) {
const thumbContainer = document.createElement('div');
thumbContainer.className = 'thumbnail';
thumbContainer.id = `thumbnail-${i}`;
thumbContainer.onclick = () => scrollToPage(i);
const imgContainer = document.createElement('div');
imgContainer.className = 'thumbnail-image-container';
imgContainer.id = `thumb-img-${i}`;
imgContainer.style.width = '100px';
imgContainer.style.height = '130px';
const label = document.createElement('div');
label.className = 'thumbnail-label';
label.textContent = i;
thumbContainer.appendChild(imgContainer);
thumbContainer.appendChild(label);
elements.sidebarContent.appendChild(thumbContainer);
}
renderThumbnailsQueue();
}
async function renderThumbnailsQueue() {
for (let i = 1; i <= state.pdfDoc.numPages; i++) {
await renderSingleThumbnail(i);
}
}
async function renderSingleThumbnail(i) {
try {
const page = await state.pdfDoc.getPage(i);
const viewport = page.getViewport({ scale: 0.2 });
const canvas = document.createElement('canvas');
canvas.className = 'thumbnail-canvas';
canvas.height = viewport.height;
canvas.width = viewport.width;
const context = canvas.getContext('2d');
await page.render({
canvasContext: context,
viewport: viewport
}).promise;
const container = document.getElementById(`thumb-img-${i}`);
if (container) {
container.innerHTML = '';
container.style.width = 'auto';
container.style.height = 'auto';
container.appendChild(canvas);
}
} catch(e) { console.warn("Thumb render error", e); }
}
function scrollToPage(num) {
if (num < 1 || num > state.pdfDoc.numPages) return;
const pageDiv = document.getElementById(`pageContainer${num}`);
if (pageDiv) {
elements.container.scrollTop = pageDiv.offsetTop;
}
}
function updateUIState(pageNum) {
if (state.pageNum === pageNum) return;
state.pageNum = pageNum;
elements.pageNum.value = pageNum;
document.querySelectorAll('.thumbnail').forEach(t => t.classList.remove('selected'));
const thumb = document.getElementById(`thumbnail-${pageNum}`);
if (thumb) {
thumb.classList.add('selected');
const thumbTop = thumb.offsetTop;
const thumbHeight = thumb.clientHeight;
const containerHeight = elements.sidebarContent.clientHeight;
const containerScroll = elements.sidebarContent.scrollTop;
if (thumbTop < containerScroll) {
elements.sidebarContent.scrollTop = thumbTop - 10;
}
else if (thumbTop + thumbHeight > containerScroll + containerHeight) {
elements.sidebarContent.scrollTop = thumbTop + thumbHeight - containerHeight + 10;
}
}
}
function changeScale(newScaleVal) {
config.currentScaleValue = newScaleVal;
if (!isNaN(parseFloat(newScaleVal))) {
config.scale = parseFloat(newScaleVal);
}
initViewer();
}
// Event Listeners
if (elements.prev) elements.prev.addEventListener('click', () => scrollToPage(state.pageNum - 1));
if (elements.next) elements.next.addEventListener('click', () => scrollToPage(state.pageNum + 1));
if (elements.pageNum) elements.pageNum.addEventListener('change', (e) => {
let num = parseInt(e.target.value);
if (num >= 1 && num <= state.pdfDoc.numPages) {
scrollToPage(num);
}
});
if (elements.sidebarToggle) elements.sidebarToggle.addEventListener('click', () => {
elements.sidebar.classList.toggle('open');
elements.sidebarToggle.classList.toggle('toggled');
});
if (elements.zoomIn) elements.zoomIn.addEventListener('click', () => {
let current = parseFloat(elements.scaleSelect.value);
if(isNaN(current)) current = config.scale;
const newScale = (Math.floor(current * 4) + 1) / 4; // Step to next 0.25 increment
// Check if current was page-fit or similar
if(isNaN(parseFloat(elements.scaleSelect.value))) {
config.scale = config.scale + 0.25;
} else {
config.scale = newScale;
}
config.currentScaleValue = config.scale.toString();
// Sync Dropdown
PinchZoom.updateScaleDropdown(config.scale);
initViewer();
});
if (elements.zoomOut) elements.zoomOut.addEventListener('click', () => {
if (config.scale <= 0.25) return;
let current = parseFloat(elements.scaleSelect.value);
if(isNaN(current)) current = config.scale;
const newScale = (Math.ceil(current * 4) - 1) / 4; // Step to prev 0.25 increment
if(isNaN(parseFloat(elements.scaleSelect.value))) {
config.scale = Math.max(0.25, config.scale - 0.25);
} else {
config.scale = Math.max(0.25, newScale);
}
config.currentScaleValue = config.scale.toString();
// Sync Dropdown
PinchZoom.updateScaleDropdown(config.scale);
initViewer();
});
if (elements.scaleSelect) elements.scaleSelect.addEventListener('change', (e) => {
changeScale(e.target.value);
});
if (elements.print) elements.print.addEventListener('click', () => window.print());
if (elements.download) elements.download.addEventListener('click', () => {
const link = document.createElement('a');
link.href = config.url;
link.download = "document.pdf";
link.click();
});
// Initialize Pinch Zoom
PinchZoom.init();
// Start loading
if (typeof pdfjsLib !== 'undefined') {
loadPDF();
} else {
window.addEventListener('load', loadPDF);
}
</script>
</body>
</html>