/* 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 = `
Page ${p}
`; 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 = 'No words'; 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 = `Pg ${r.page}${parts.join(', ')}`; 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 = `
Page ${pageNum}
`; // 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 = 'No words'; pagesDiv.innerHTML = ''; disableZoom(); downloadOcrLink.style.display = 'none'; ocrStatusNote.style.display = 'none'; setStatus("Ready."); if (pageObserver) { pageObserver.disconnect(); pageObserver = null; } } /* ---------- Init ---------- */ setStatus("Ready.");