Spaces:
Running
Running
| <html lang="en" dir="ltr"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | |
| <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"> | |
| <!-- Bootstrap CSS (only for Toast styling, scoped to avoid breaking layout) --> | |
| <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; | |
| } | |
| 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); | |
| } | |
| #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; /* Ensure offsetTop works reliably */ | |
| } | |
| #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; /* Fixed width for thumbnails */ | |
| 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='); | |
| } | |
| #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; | |
| } | |
| .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; /* Bootstrap dark toast bg */ | |
| 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') | |
| }; | |
| // ========================================== | |
| // 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; | |
| // Unpack if wrapped (new format) or use raw (legacy) | |
| const data = record.data || record; | |
| // If it's the new format, update timestamp (async fire-and-forget) | |
| 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); | |
| // Calculate usage and collect entries for eviction | |
| let currentSize = 0; | |
| const entries = []; | |
| await new Promise((resolve) => { | |
| const req = store.openCursor(); | |
| req.onsuccess = (e) => { | |
| const cursor = e.target.result; | |
| if (cursor) { | |
| // Skip if it's the current file (we are overwriting anyway) | |
| if (cursor.key !== url) { | |
| const val = cursor.value; | |
| const itemSize = (val.data || val).byteLength || 0; | |
| const itemTime = val.timestamp || 0; // Legacy = 0 (evict first) | |
| currentSize += itemSize; | |
| entries.push({ key: cursor.key, size: itemSize, timestamp: itemTime }); | |
| } | |
| cursor.continue(); | |
| } else { | |
| resolve(); | |
| } | |
| }; | |
| req.onerror = () => resolve(); | |
| }); | |
| // Evict if needed | |
| if (currentSize + newSize > this.MAX_TOTAL_SIZE) { | |
| // Sort oldest first | |
| 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.`); | |
| } | |
| } | |
| // Save new record | |
| 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; // Expose for close button | |
| // ========================================== | |
| // 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(); // Show toast if cached | |
| } | |
| // Config for fonts to prevent warnings | |
| 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) { | |
| // Save to cache for next time | |
| state.pdfDoc.getData().then(pdfData => { | |
| // Limit cache to 100MB per file | |
| 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 | |
| const lastPage = 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}`; | |
| pageDiv.style.width = '612px'; | |
| pageDiv.style.height = '792px'; | |
| elements.viewer.appendChild(pageDiv); | |
| state.pages.push(pageDiv); | |
| } | |
| setupIntersectionObserver(); | |
| generateThumbnails(); | |
| // Scroll to last viewed page after a short delay | |
| 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); | |
| // Save current page to localStorage | |
| 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}`; | |
| // Placeholder dimensions until render | |
| 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); | |
| } | |
| // Render thumbnails lazily when sidebar is open or idle | |
| // For now, render them sequentially to avoid UI freeze | |
| renderThumbnailsQueue(); | |
| } | |
| async function renderThumbnailsQueue() { | |
| for (let i = 1; i <= state.pdfDoc.numPages; i++) { | |
| // Check if we should render (could add visibility check here) | |
| await renderSingleThumbnail(i); | |
| } | |
| } | |
| async function renderSingleThumbnail(i) { | |
| try { | |
| const page = await state.pdfDoc.getPage(i); | |
| const viewport = page.getViewport({ scale: 0.2 }); // Small scale | |
| 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) { | |
| // FIXED: Use scrollTop instead of scrollIntoView to prevent layout breaking | |
| // Calculate offset relative to the container | |
| 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'); | |
| // Manual Scroll Logic for Sidebar (Replaces scrollIntoView) | |
| const thumbTop = thumb.offsetTop; | |
| const thumbHeight = thumb.clientHeight; | |
| const containerHeight = elements.sidebarContent.clientHeight; | |
| const containerScroll = elements.sidebarContent.scrollTop; | |
| // If above visible area | |
| if (thumbTop < containerScroll) { | |
| elements.sidebarContent.scrollTop = thumbTop - 10; | |
| } | |
| // If below visible area | |
| 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) || 1.0; | |
| const newScale = (current + 0.25).toString(); | |
| elements.scaleSelect.value = newScale; | |
| config.scale += 0.25; | |
| config.currentScaleValue = config.scale.toString(); | |
| initViewer(); | |
| }); | |
| if (elements.zoomOut) elements.zoomOut.addEventListener('click', () => { | |
| if (config.scale <= 0.5) return; | |
| config.scale -= 0.25; | |
| config.currentScaleValue = config.scale.toString(); | |
| 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(); | |
| }); | |
| // Start loading | |
| if (typeof pdfjsLib !== 'undefined') { | |
| loadPDF(); | |
| } else { | |
| // Wait for script load if needed (though position at end of body usually avoids this) | |
| window.addEventListener('load', loadPDF); | |
| } | |
| </script> | |
| </body> | |
| </html> | |