Spaces:
Sleeping
Sleeping
| /* Front-end logic: full placeholder list + background image/data loading + buffered highlights */ | |
| console.log("[app] build version:", window.APP_CONFIG?.buildVersion); | |
| /* ---------- DOM Elements ---------- */ | |
| const pdfInput = document.getElementById('pdfInput'); | |
| const fileInfo = document.getElementById('fileInfo'); | |
| const wordsInput = document.getElementById('wordsInput'); | |
| const searchBtn = document.getElementById('searchBtn'); | |
| const resultsList = document.getElementById('resultsList'); | |
| const pageText = document.getElementById('pageText'); | |
| const legend = document.getElementById('legend'); | |
| const pagesDiv = document.getElementById('pages'); | |
| const statusMsg = document.getElementById('statusMsg'); | |
| const zoomIn = document.getElementById('zoomIn'); | |
| const zoomOut = document.getElementById('zoomOut'); | |
| const zoomVal = document.getElementById('zoomVal'); | |
| const divider = document.getElementById('divider'); | |
| const loadAllBtn = document.getElementById('loadAllBtn'); | |
| const ocrToggle = document.getElementById('ocrToggle'); | |
| const ocrLang = document.getElementById('ocrLang'); | |
| const downloadOcrLink = document.getElementById('downloadOcrLink'); | |
| const ocrStatusNote = document.getElementById('ocrStatusNote'); | |
| /* Overlay */ | |
| const processingOverlay = document.getElementById('processingOverlay'); | |
| const processingTitle = document.getElementById('processingTitle'); | |
| const processingDetail = document.getElementById('processingDetail'); | |
| const processingHint = document.getElementById('processingHint'); | |
| const processingError = document.getElementById('processingError'); | |
| const overlayCloseBtn = document.getElementById('overlayCloseBtn'); | |
| const processingSpinner = document.getElementById('processingSpinner'); | |
| /* ---------- Config ---------- */ | |
| ocrToggle.checked = false; | |
| ocrLang.value = 'eng'; | |
| const HIGHLIGHT_BUFFER_BEFORE = 2; | |
| const HIGHLIGHT_BUFFER_AFTER = 2; | |
| const PREFETCH_EXTRA_AHEAD = 1; | |
| let bufferedHighlightMode = true; | |
| const ALWAYS_BACKGROUND_FULL_LOAD = true; | |
| const BG_CONCURRENCY_BASE = 8; | |
| const BG_CONCURRENCY_ACCEL = 24; | |
| const LARGE_DOC_THRESHOLD = 80; | |
| const PREVIEW_PAGES_LARGE = 10; | |
| const PREVIEW_PAGES_SMALL = Infinity; | |
| let currentScale = 1.0; | |
| const MIN_SCALE = 0.5; | |
| const MAX_SCALE = 2.5; | |
| const SCALE_STEP = 0.15; | |
| /* ---------- State ---------- */ | |
| let currentDoc = null; | |
| let currentWords = []; | |
| let searchResults = []; | |
| let matchPageSet = new Set(); | |
| let pageCache = {}; // pageNum -> { tokens, text, imageLoadedPromise, overlay } | |
| let pageLoadPromises = {}; // guard | |
| let placeholderBuilt = false; | |
| let seamlessHighlightActive = false; | |
| let currentCenterPage = null; | |
| let highlightedPages = new Set(); | |
| let scrollDirection = 0; | |
| let programmaticScrollInProgress = false; | |
| let pageObserver = null; | |
| let jumpGeneration = 0; | |
| let bgActive = false; | |
| let bgAccelerated = false; | |
| let bgCompleted = false; | |
| let bgLoadedCount = 0; | |
| let bgTotalToLoad = 0; | |
| let bgConcurrency = BG_CONCURRENCY_BASE; | |
| let bgStatusTimer = null; | |
| /* ---------- Utility ---------- */ | |
| function setStatus(msg) { statusMsg.textContent = msg; } | |
| function parseWords(raw) { | |
| return raw.trim() | |
| .split(/[,\s;]+/) | |
| .filter(Boolean) | |
| .map(w => w.toLowerCase()) | |
| .filter((v,i,a)=>a.indexOf(v)===i); | |
| } | |
| /* ---------- Overlay Helpers ---------- */ | |
| function showProcessingOverlay(title, detail, showHint=true) { | |
| processingTitle.textContent = title; | |
| processingDetail.textContent = detail || ''; | |
| processingHint.style.display = showHint ? 'block' : 'none'; | |
| processingError.style.display = 'none'; | |
| overlayCloseBtn.style.display = 'none'; | |
| processingSpinner.style.display = 'block'; | |
| processingOverlay.classList.remove('hidden'); | |
| } | |
| function markOverlayCompleted(msg) { | |
| processingTitle.textContent = 'Completed'; | |
| processingDetail.textContent = msg || 'Done.'; | |
| processingHint.style.display = 'none'; | |
| processingSpinner.style.display = 'none'; | |
| setTimeout(()=>overlayCloseBtn.style.display = 'inline-flex', 2000); | |
| } | |
| function showOverlayError(msg) { | |
| processingError.textContent = msg; | |
| processingError.style.display = 'block'; | |
| processingSpinner.style.display = 'none'; | |
| processingTitle.textContent = 'Error'; | |
| processingHint.style.display = 'none'; | |
| overlayCloseBtn.style.display = 'inline-flex'; | |
| } | |
| function hideProcessingOverlay() { | |
| processingOverlay.classList.add('hidden'); | |
| } | |
| overlayCloseBtn.addEventListener('click', hideProcessingOverlay); | |
| /* ---------- Upload Flow ---------- */ | |
| pdfInput.addEventListener('change', async (e) => { | |
| const f = e.target.files[0]; | |
| if (!f) return; | |
| resetAll(); | |
| const wantsOCR = ocrToggle.checked; | |
| showProcessingOverlay( | |
| wantsOCR ? 'Performing OCR...' : 'Processing PDF...', | |
| wantsOCR ? 'Running OCR (deskew + rotation). Please wait...' : 'Processing PDF text...', | |
| wantsOCR | |
| ); | |
| setStatus("Uploading..."); | |
| const fd = new FormData(); | |
| fd.append("pdf", f); | |
| fd.append("ocr", String(wantsOCR)); | |
| fd.append("lang", ocrLang.value.trim() || 'eng'); | |
| let json; | |
| try { | |
| const res = await fetch("/api/upload", {method:"POST", body:fd}); | |
| json = await res.json(); | |
| if (!res.ok) throw new Error(json.error || "Upload failed"); | |
| } catch (err) { | |
| console.error(err); | |
| showOverlayError(err.message); | |
| setStatus(err.message); | |
| return; | |
| } | |
| currentDoc = json; | |
| fileInfo.textContent = `${json.filename} (${json.pages} pages)`; | |
| enableLoadAllIfNeeded(); | |
| enableZoom(); | |
| // OCR status | |
| if (json.ocr_performed) { | |
| ocrStatusNote.style.display = 'block'; | |
| ocrStatusNote.textContent = json.ocr_failed | |
| ? `OCR failed: ${json.ocr_message || 'Unknown error.'}` | |
| : (json.ocr_message || 'OCR completed.'); | |
| } else { | |
| ocrStatusNote.style.display = 'none'; | |
| } | |
| // Download OCR link | |
| if (json.ocr_performed && !json.ocr_failed && json.used_ocr_pdf) { | |
| try { | |
| const metaRes = await fetch(`/api/doc/${json.doc_id}/meta`); | |
| const metaJ = await metaRes.json(); | |
| if (metaRes.ok && metaJ.download_ocr_url) { | |
| downloadOcrLink.href = metaJ.download_ocr_url; | |
| downloadOcrLink.style.display = 'inline-flex'; | |
| } | |
| } catch(e) {} | |
| } | |
| markOverlayCompleted("Preview rendering..."); | |
| try { | |
| await renderPreviewPages(); | |
| buildAllPlaceholders(); // create placeholders for ALL pages (if not built) | |
| if (ALWAYS_BACKGROUND_FULL_LOAD) { | |
| startBackgroundLoading(); // load every remaining page automatically | |
| } | |
| setStatus("Preview ready. You can search now."); | |
| } catch (e2) { | |
| showOverlayError("Render error: " + e2.message); | |
| setStatus("Render error: " + e2.message); | |
| return; | |
| } finally { | |
| setTimeout(hideProcessingOverlay, 500); | |
| } | |
| }); | |
| /* ---------- Preview Pages ---------- */ | |
| async function renderPreviewPages() { | |
| if (!currentDoc) return; | |
| const total = currentDoc.pages; | |
| const limit = (total > LARGE_DOC_THRESHOLD) ? PREVIEW_PAGES_LARGE : PREVIEW_PAGES_SMALL; | |
| const count = Math.min(limit, total); | |
| for (let p = 1; p <= count; p++) { | |
| await ensurePageLoaded(p); | |
| if (p % 3 === 0 || p === count) { | |
| setStatus(`Loaded preview pages ${p}/${count}${count < total ? '...' : ''}`); | |
| } | |
| } | |
| } | |
| /* ---------- Placeholder Construction ---------- */ | |
| function buildAllPlaceholders() { | |
| if (!currentDoc || placeholderBuilt) return; | |
| const total = currentDoc.pages; | |
| // We keep already loaded preview pages; build placeholders for any missing pages + also create placeholders for those already loaded? prefer consistent DOM order. | |
| // Strategy: If a page DOM exists skip; else create placeholder. | |
| const frag = document.createDocumentFragment(); | |
| for (let p = 1; p <= total; p++) { | |
| if (pagesDiv.querySelector(`.page[data-page="${p}"]`)) continue; | |
| const ph = document.createElement('div'); | |
| ph.className = 'page placeholder'; | |
| ph.dataset.page = p; | |
| ph.innerHTML = ` | |
| <div class="page-inner"> | |
| <div class="placeholder-label">Page ${p}</div> | |
| <div class="placeholder-spinner"></div> | |
| </div> | |
| `; | |
| frag.appendChild(ph); | |
| } | |
| // Insert placeholders maintaining numeric order (append because existing pages are lowest numbers already) | |
| pagesDiv.appendChild(frag); | |
| placeholderBuilt = true; | |
| } | |
| /* ---------- Background Loading (All Pages) ---------- */ | |
| async function startBackgroundLoading(accelerate = false) { | |
| if (!currentDoc) return; | |
| if (bgCompleted) return; | |
| if (!bgActive) { | |
| bgActive = true; | |
| bgConcurrency = accelerate ? BG_CONCURRENCY_ACCEL : BG_CONCURRENCY_BASE; | |
| } else if (accelerate) { | |
| bgAccelerated = true; | |
| bgConcurrency = BG_CONCURRENCY_ACCEL; | |
| } | |
| const pending = []; | |
| for (let p = 1; p <= currentDoc.pages; p++) { | |
| if (!pageCache[p]) pending.push(p); | |
| } | |
| bgTotalToLoad = pending.length; | |
| bgLoadedCount = 0; | |
| if (!pending.length) { | |
| bgCompleted = true; | |
| bgActive = false; | |
| enableLoadAllIfNeeded(); | |
| setStatus("All pages already loaded."); | |
| return; | |
| } | |
| if (!bgStatusTimer) { | |
| bgStatusTimer = setInterval(() => { | |
| if (!bgActive) return; | |
| const pct = ((bgLoadedCount / bgTotalToLoad) * 100).toFixed(1); | |
| setStatus(`Loading all pages (${bgLoadedCount}/${bgTotalToLoad}) ${pct}%`); | |
| }, 1200); | |
| } | |
| let nextIndex = 0; | |
| async function worker() { | |
| while (true) { | |
| if (nextIndex >= pending.length) break; | |
| const i = nextIndex++; | |
| const pageNum = pending[i]; | |
| try { | |
| await ensurePageLoaded(pageNum); | |
| } catch (e) { | |
| console.warn("[bg] page load error", pageNum, e); | |
| } finally { | |
| bgLoadedCount++; | |
| } | |
| } | |
| } | |
| const workers = []; | |
| for (let i = 0; i < bgConcurrency; i++) workers.push(worker()); | |
| await Promise.all(workers); | |
| if (bgAccelerated && !accelerate) { | |
| // If we were asked to accelerate after initial start, spawn extra workers now | |
| // (Simplify: we already adjust concurrency variable; new acceleration triggers call again) | |
| } | |
| clearInterval(bgStatusTimer); | |
| bgStatusTimer = null; | |
| bgActive = false; | |
| bgCompleted = true; | |
| enableLoadAllIfNeeded(); | |
| setStatus("All pages loaded."); | |
| } | |
| /* Load All button -> accelerate */ | |
| loadAllBtn.addEventListener('click', async () => { | |
| if (!currentDoc) return; | |
| if (bgCompleted) { | |
| setStatus("All pages already loaded."); | |
| return; | |
| } | |
| setStatus("Accelerating full load..."); | |
| await startBackgroundLoading(true); | |
| // If background already active, above call only bumps concurrency. | |
| if (bgActive) { | |
| const wait = setInterval(() => { | |
| if (bgCompleted) clearInterval(wait); | |
| }, 400); | |
| } | |
| }); | |
| /* ---------- Search ---------- */ | |
| searchBtn.addEventListener('click', runSearch); | |
| wordsInput.addEventListener('keydown', e => { if (e.key === 'Enter') runSearch(); }); | |
| async function runSearch() { | |
| if (!currentDoc) { setStatus("Upload a PDF first."); return; } | |
| const raw = wordsInput.value; | |
| const words = parseWords(raw); | |
| currentWords = words; | |
| updateLegend(words); | |
| clearAllHighlights(); | |
| seamlessHighlightActive = false; | |
| matchPageSet.clear(); | |
| highlightedPages.clear(); | |
| currentCenterPage = null; | |
| if (!words.length) { | |
| resultsList.innerHTML = ''; | |
| pageText.value = ''; | |
| setStatus("No words entered."); | |
| return; | |
| } | |
| setStatus("Searching..."); | |
| let data; | |
| try { | |
| const res = await fetch(`/api/doc/${currentDoc.doc_id}/search`, { | |
| method:"POST", | |
| headers: {"Content-Type":"application/json"}, | |
| body: JSON.stringify({words: raw}) | |
| }); | |
| data = await res.json(); | |
| if (!res.ok) throw new Error(data.error || "Search failed"); | |
| } catch (e) { | |
| setStatus(e.message); | |
| return; | |
| } | |
| searchResults = data.results || []; | |
| populateResults(); | |
| if (!searchResults.length) { | |
| setStatus("No pages found."); | |
| pageText.value = ''; | |
| return; | |
| } | |
| matchPageSet = new Set(searchResults.map(r=>r.page)); | |
| const first = searchResults[0].page; | |
| await ensurePageLoaded(first); | |
| await preloadHighlightWindow(first); | |
| setCenterPage(first, {fromClick:true}); | |
| seamlessHighlightActive = true; | |
| selectResultIndex(0, {preserveHighlights:true, skipScroll:true}); | |
| scrollPageIntoView(first); | |
| setStatus(`Ready. Highlight window centered at page ${first}.`); | |
| } | |
| function updateLegend(words) { | |
| legend.innerHTML = ''; | |
| if (!words.length) { | |
| legend.innerHTML = '<span class="dim">No words</span>'; | |
| return; | |
| } | |
| const sw = document.createElement('div'); | |
| sw.className = 'swatch'; | |
| legend.appendChild(sw); | |
| const txt = document.createElement('div'); | |
| txt.textContent = words.join(', '); | |
| legend.appendChild(txt); | |
| } | |
| function populateResults() { | |
| resultsList.innerHTML = ''; | |
| if (!searchResults.length) { | |
| const li = document.createElement('li'); | |
| li.textContent = '[No pages]'; | |
| li.classList.add('dim'); | |
| resultsList.appendChild(li); | |
| return; | |
| } | |
| searchResults.forEach((r, idx) => { | |
| const li = document.createElement('li'); | |
| const parts = []; | |
| currentWords.forEach(w => { | |
| const c = r.counts[w] || 0; | |
| if (c) parts.push(`${w}:${c}`); | |
| }); | |
| li.innerHTML = `<span>Pg ${r.page}</span><span style="opacity:.7">${parts.join(', ')}</span>`; | |
| li.addEventListener('click', () => jumpToResultPage(idx, r.page)); | |
| resultsList.appendChild(li); | |
| }); | |
| } | |
| /* ---------- Jump to Far Page ---------- */ | |
| async function jumpToResultPage(idx, pageNum) { | |
| if (!currentDoc) return; | |
| jumpGeneration++; | |
| const myGen = jumpGeneration; | |
| setStatus(`Jumping to page ${pageNum}...`); | |
| programmaticScrollInProgress = true; | |
| await ensurePageLoaded(pageNum); | |
| if (myGen !== jumpGeneration) return; | |
| const preloadPromise = preloadHighlightWindow(pageNum); | |
| setCenterPage(pageNum, {fromClick:true}); | |
| selectResultIndex(idx, {preserveHighlights:true, skipScroll:true}); | |
| scrollPageIntoView(pageNum); | |
| let timedOut = false; | |
| const timeout = new Promise(r=>setTimeout(()=>{ timedOut = true; r(); }, 2000)); | |
| await Promise.race([preloadPromise, timeout]); | |
| setStatus(timedOut ? `Page ${pageNum} ready (loading nearby...)` : `Centered on page ${pageNum}.`); | |
| setTimeout(()=> programmaticScrollInProgress = false, 600); | |
| } | |
| async function preloadHighlightWindow(center) { | |
| const start = Math.max(1, center - HIGHLIGHT_BUFFER_BEFORE); | |
| const end = Math.min(currentDoc.pages, center + HIGHLIGHT_BUFFER_AFTER); | |
| const tasks = []; | |
| for (let p = start; p <= end; p++) { | |
| if (!pageCache[p]) tasks.push(ensurePageLoaded(p)); | |
| } | |
| if (tasks.length) await Promise.all(tasks); | |
| } | |
| /* ---------- Selecting Result ---------- */ | |
| async function selectResultIndex(idx, opts={}) { | |
| if (idx < 0 || idx >= searchResults.length) return; | |
| [...resultsList.children].forEach((li,i)=>li.classList.toggle('active', i===idx)); | |
| const entry = searchResults[idx]; | |
| currentSelectedPage = entry.page; | |
| await ensurePageLoaded(entry.page); | |
| showPageText(entry.page); | |
| if (!bufferedHighlightMode) { | |
| if (seamlessHighlightActive) highlightPageMatches(entry.page, {append:true}); | |
| else if (!opts.preserveHighlights) { | |
| clearAllHighlights(); | |
| highlightPageMatches(entry.page); | |
| } | |
| } | |
| if (!opts.skipScroll) scrollPageIntoView(entry.page); | |
| } | |
| function showPageText(pageNum) { | |
| const cache = pageCache[pageNum]; | |
| if (!cache) return; | |
| const entry = searchResults.find(r=>r.page===pageNum); | |
| let summary = ''; | |
| if (entry) { | |
| const parts = currentWords | |
| .map(w => `${w}=${entry.counts[w] || 0}`) | |
| .filter(x=>!x.endsWith('=0')); | |
| if (parts.length) summary = 'Matches: '+parts.join(', ') + '\n' + '-'.repeat(40) + '\n'; | |
| } | |
| pageText.value = summary + cache.text; | |
| } | |
| function scrollPageIntoView(pageNum) { | |
| const el = pagesDiv.querySelector(`.page[data-page="${pageNum}"]`); | |
| if (el) el.scrollIntoView({behavior:'smooth', block:'start'}); | |
| } | |
| /* ---------- Intersection Observer (Center Detection) ---------- */ | |
| function ensurePageObserver() { | |
| if (pageObserver) return; | |
| pageObserver = new IntersectionObserver(handleIO, { | |
| root: document.getElementById('pagesWrap'), | |
| rootMargin: '0px', | |
| threshold: [0.25,0.5,0.75] | |
| }); | |
| // Observe all page elements (placeholders included) | |
| pagesDiv.querySelectorAll('.page').forEach(p => pageObserver.observe(p)); | |
| } | |
| function handleIO(entries) { | |
| if (!bufferedHighlightMode || programmaticScrollInProgress) return; | |
| let best = null; | |
| for (const e of entries) { | |
| if (!e.isIntersecting) continue; | |
| if (!best || e.intersectionRatio > best.intersectionRatio) best = e; | |
| } | |
| if (!best) return; | |
| const num = parseInt(best.target.dataset.page,10); | |
| if (currentCenterPage !== num) { | |
| if (currentCenterPage != null) scrollDirection = num > currentCenterPage ? 1 : -1; | |
| setCenterPage(num); | |
| } | |
| } | |
| function setCenterPage(pageNum, {fromClick=false} = {}) { | |
| currentCenterPage = pageNum; | |
| updateHighlightWindow(); | |
| if (fromClick) { | |
| programmaticScrollInProgress = true; | |
| setTimeout(()=> programmaticScrollInProgress = false, 800); | |
| } | |
| } | |
| /* ---------- Highlight Window Logic ---------- */ | |
| function updateHighlightWindow() { | |
| if (!currentDoc || !bufferedHighlightMode || currentCenterPage == null) return; | |
| const start = Math.max(1, currentCenterPage - HIGHLIGHT_BUFFER_BEFORE); | |
| const end = Math.min(currentDoc.pages, currentCenterPage + HIGHLIGHT_BUFFER_AFTER); | |
| for (const p of Array.from(highlightedPages)) { | |
| if (p < start || p > end) { | |
| clearHighlightsOnPage(p); | |
| highlightedPages.delete(p); | |
| } | |
| } | |
| const tasks = []; | |
| for (let p = start; p <= end; p++) { | |
| if (matchPageSet.has(p) && !highlightedPages.has(p)) { | |
| if (pageCache[p]) { | |
| highlightPageMatches(p,{append:false}); | |
| highlightedPages.add(p); | |
| } else { | |
| tasks.push(ensurePageLoaded(p).then(()=>{ | |
| if (matchPageSet.has(p)) { | |
| highlightPageMatches(p,{append:false}); | |
| highlightedPages.add(p); | |
| } | |
| })); | |
| } | |
| } | |
| } | |
| if (scrollDirection !== 0) { | |
| const aheadStart = scrollDirection > 0 ? end + 1 : start - PREFETCH_EXTRA_AHEAD; | |
| const aheadEnd = scrollDirection > 0 | |
| ? Math.min(currentDoc.pages, end + PREFETCH_EXTRA_AHEAD) | |
| : Math.max(1, start - 1); | |
| for (let p = aheadStart; scrollDirection > 0 ? p <= aheadEnd : p >= aheadEnd; p += scrollDirection>0?1:-1) { | |
| if (matchPageSet.has(p) && !pageCache[p]) { | |
| tasks.push(ensurePageLoaded(p)); | |
| } | |
| } | |
| } | |
| if (tasks.length) { | |
| Promise.all(tasks).catch(e=>console.warn('[highlight-window]', e)); | |
| } | |
| } | |
| /* ---------- Page Loading ---------- */ | |
| async function ensurePageLoaded(pageNum) { | |
| if (pageCache[pageNum]) return; | |
| if (pageLoadPromises[pageNum]) return pageLoadPromises[pageNum]; | |
| pageLoadPromises[pageNum] = (async () => { | |
| if (!currentDoc) return; | |
| // Reuse placeholder element | |
| let pageEl = pagesDiv.querySelector(`.page[data-page="${pageNum}"]`); | |
| if (!pageEl) { | |
| // Should not happen if placeholders built, but fallback | |
| pageEl = document.createElement('div'); | |
| pageEl.className = 'page placeholder'; | |
| pageEl.dataset.page = pageNum; | |
| pageEl.innerHTML = ` | |
| <div class="page-inner"> | |
| <div class="placeholder-label">Page ${pageNum}</div> | |
| <div class="placeholder-spinner"></div> | |
| </div> | |
| `; | |
| // Insert in numeric order | |
| let inserted = false; | |
| const existing = [...pagesDiv.querySelectorAll('.page')]; | |
| for (const el of existing) { | |
| const n = parseInt(el.dataset.page,10); | |
| if (pageNum < n) { | |
| pagesDiv.insertBefore(pageEl, el); | |
| inserted = true; | |
| break; | |
| } | |
| } | |
| if (!inserted) pagesDiv.appendChild(pageEl); | |
| } | |
| // Fetch page meta | |
| const res = await fetch(`/api/doc/${currentDoc.doc_id}/page/${pageNum}`); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.error || `Failed to load page ${pageNum}`); | |
| // Replace placeholder inner content with actual page image + overlay only if not already replaced | |
| if (!pageEl.classList.contains('loaded')) { | |
| pageEl.classList.remove('placeholder'); | |
| pageEl.classList.add('loaded'); | |
| pageEl.innerHTML = ''; // clear placeholder | |
| const img = document.createElement('img'); | |
| img.src = data.image_url; | |
| img.alt = `Page ${pageNum}`; | |
| img.loading = 'lazy'; | |
| img.decoding = 'async'; | |
| pageEl.appendChild(img); | |
| const label = document.createElement('div'); | |
| label.className = 'page-label'; | |
| label.textContent = `Page ${pageNum}`; | |
| pageEl.appendChild(label); | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'overlay'; | |
| overlay.style.position = 'absolute'; | |
| overlay.style.inset = '0'; | |
| overlay.style.pointerEvents = 'none'; | |
| pageEl.appendChild(overlay); | |
| pageCache[pageNum] = { | |
| tokens: data.tokens, | |
| text: data.text, | |
| imageLoadedPromise: new Promise(resolve => { | |
| img.onload = () => resolve(); | |
| img.onerror = () => resolve(); | |
| }), | |
| overlay | |
| }; | |
| await pageCache[pageNum].imageLoadedPromise; | |
| ensurePageObserver(); | |
| if (pageObserver) pageObserver.observe(pageEl); | |
| if (bufferedHighlightMode && matchPageSet.has(pageNum)) { | |
| if (currentCenterPage != null && | |
| pageNum >= currentCenterPage - HIGHLIGHT_BUFFER_BEFORE && | |
| pageNum <= currentCenterPage + HIGHLIGHT_BUFFER_AFTER) { | |
| highlightPageMatches(pageNum); | |
| highlightedPages.add(pageNum); | |
| } | |
| } else if (seamlessHighlightActive && !bufferedHighlightMode && matchPageSet.has(pageNum)) { | |
| highlightPageMatches(pageNum, {append:true}); | |
| } | |
| } else { | |
| // Already loaded (race) | |
| } | |
| })(); | |
| try { | |
| await pageLoadPromises[pageNum]; | |
| } finally { | |
| delete pageLoadPromises[pageNum]; | |
| } | |
| } | |
| /* ---------- Highlighting ---------- */ | |
| function clearAllHighlights() { | |
| document.querySelectorAll('.hl-box').forEach(el => el.remove()); | |
| } | |
| function clearHighlightsOnPage(pageNum) { | |
| const pageEl = pagesDiv.querySelector(`.page[data-page="${pageNum}"]`); | |
| if (!pageEl) return; | |
| pageEl.querySelectorAll('.hl-box').forEach(el=>el.remove()); | |
| } | |
| function highlightPageMatches(pageNum, {append=false} = {}) { | |
| const cache = pageCache[pageNum]; | |
| if (!cache || !currentWords.length) return; | |
| if (!append) clearHighlightsOnPage(pageNum); | |
| const targets = new Set(currentWords); | |
| const overlay = cache.overlay; | |
| const frag = document.createDocumentFragment(); | |
| for (const tok of cache.tokens) { | |
| const lt = tok.text.toLowerCase(); | |
| if (targets.has(lt)) { | |
| const [x0,y0,x1,y1] = tok.bbox; | |
| const div = document.createElement('div'); | |
| div.className = 'hl-box'; | |
| div.style.left = (x0*100)+'%'; | |
| div.style.top = (y0*100)+'%'; | |
| div.style.width = ((x1 - x0)*100)+'%'; | |
| div.style.height = ((y1 - y0)*100)+'%'; | |
| frag.appendChild(div); | |
| } | |
| } | |
| overlay.appendChild(frag); | |
| } | |
| /* ---------- Zoom / Resize ---------- */ | |
| window.addEventListener('resize', () => { /* percentage boxes auto-scale */ }); | |
| function enableZoom() { | |
| zoomIn.disabled = false; | |
| zoomOut.disabled = false; | |
| } | |
| function disableZoom() { | |
| zoomIn.disabled = true; | |
| zoomOut.disabled = true; | |
| currentScale = 1.0; | |
| zoomVal.textContent = '100%'; | |
| pagesDiv.style.transform = ''; | |
| } | |
| zoomIn.addEventListener('click', () => applyZoom(currentScale + SCALE_STEP)); | |
| zoomOut.addEventListener('click', () => applyZoom(currentScale - SCALE_STEP)); | |
| function applyZoom(newScale) { | |
| if (!currentDoc) return; | |
| newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale)); | |
| if (Math.abs(newScale - currentScale) < 0.001) return; | |
| currentScale = newScale; | |
| zoomVal.textContent = Math.round(newScale*100) + '%'; | |
| pagesDiv.style.transformOrigin = 'top center'; | |
| pagesDiv.style.transform = `scale(${newScale})`; | |
| } | |
| /* ---------- Sidebar Resize ---------- */ | |
| (function enableDivider() { | |
| let dragging = false; | |
| divider.addEventListener('mousedown', () => { | |
| dragging = true; | |
| document.body.style.userSelect = 'none'; | |
| document.documentElement.style.cursor = 'col-resize'; | |
| }); | |
| window.addEventListener('mouseup', () => { | |
| if (dragging) { | |
| dragging = false; | |
| document.body.style.userSelect = ''; | |
| document.documentElement.style.cursor = ''; | |
| } | |
| }); | |
| window.addEventListener('mousemove', e => { | |
| if (!dragging) return; | |
| const min = 220; | |
| const max = Math.min(window.innerWidth * 0.6, 700); | |
| const w = Math.max(min, Math.min(max, e.clientX)); | |
| document.documentElement.style.setProperty('--sidebar-width', w + 'px'); | |
| }); | |
| })(); | |
| /* ---------- Load All Button State ---------- */ | |
| function enableLoadAllIfNeeded() { | |
| if (!currentDoc) { loadAllBtn.disabled = true; return; } | |
| loadAllBtn.disabled = bgCompleted; | |
| } | |
| /* ---------- Reset ---------- */ | |
| function resetAll() { | |
| currentDoc = null; | |
| currentWords = []; | |
| searchResults = []; | |
| matchPageSet.clear(); | |
| pageCache = {}; | |
| pageLoadPromises = {}; | |
| placeholderBuilt = false; | |
| seamlessHighlightActive = false; | |
| currentCenterPage = null; | |
| highlightedPages.clear(); | |
| scrollDirection = 0; | |
| jumpGeneration = 0; | |
| bgActive = false; | |
| bgAccelerated = false; | |
| bgCompleted = false; | |
| if (bgStatusTimer) { clearInterval(bgStatusTimer); bgStatusTimer = null; } | |
| fileInfo.textContent = ''; | |
| resultsList.innerHTML = ''; | |
| pageText.value = ''; | |
| legend.innerHTML = '<span class="dim">No words</span>'; | |
| pagesDiv.innerHTML = ''; | |
| disableZoom(); | |
| downloadOcrLink.style.display = 'none'; | |
| ocrStatusNote.style.display = 'none'; | |
| setStatus("Ready."); | |
| if (pageObserver) { | |
| pageObserver.disconnect(); | |
| pageObserver = null; | |
| } | |
| } | |
| /* ---------- Init ---------- */ | |
| setStatus("Ready."); |