/* Front-end logic with buffered, on-demand highlight lifecycle + duplicate load guard + fast jump preloading */
console.log("[app] build version:", window.APP_CONFIG?.buildVersion);
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');
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');
ocrToggle.checked = false;
ocrLang.value = 'eng';
let currentDoc = null;
let currentWords = [];
let searchResults = [];
let currentSelectedPage = null;
let pageCache = {};
let currentScale = 1.0;
const MIN_SCALE = 0.5;
const MAX_SCALE = 2.5;
const SCALE_STEP = 0.15;
/* Track in-flight page loads to prevent duplicates */
const pageLoadPromises = {}; // pageNum -> Promise
let matchPageSet = new Set();
let seamlessHighlightActive = false;
/* Buffered Highlight Configuration */
const HIGHLIGHT_BUFFER_BEFORE = 2;
const HIGHLIGHT_BUFFER_AFTER = 2;
const PREFETCH_EXTRA_AHEAD = 1;
let bufferedHighlightMode = true;
/* Buffered highlight state */
let currentCenterPage = null;
let highlightedPages = new Set();
let scrollDirection = 0; // -1 up, +1 down
let programmaticScrollInProgress = false;
let pageObserver = null;
/* Jump control */
let currentJumpToken = 0;
/* Loading strategy */
const LARGE_DOC_THRESHOLD = 80;
const AUTO_LOAD_PAGES_LARGE = 10;
const AUTO_LOAD_PAGES_SMALL = Infinity;
/* Overlay state */
let overlayCompletedTimestamp = null;
let overlayForceHideTimer = null;
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');
overlayCompletedTimestamp = null;
if (overlayForceHideTimer) {
clearTimeout(overlayForceHideTimer);
overlayForceHideTimer = null;
}
}
function markOverlayCompleted(successMsg) {
processingTitle.textContent = 'Completed';
processingDetail.textContent = successMsg || 'Done.';
processingHint.style.display = 'none';
processingSpinner.style.display = 'none';
overlayCompletedTimestamp = performance.now();
setTimeout(() => {
if (!processingOverlay.classList.contains('hidden')) {
overlayCloseBtn.style.display = 'inline-flex';
}
}, 2500);
}
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');
if (overlayForceHideTimer) {
clearTimeout(overlayForceHideTimer);
overlayForceHideTimer = null;
}
}
overlayCloseBtn.addEventListener('click', hideProcessingOverlay);
/* Upload */
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 + text extraction). Please wait...'
: 'Indexing document text. Please wait...',
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 / processing failed");
}
} catch (err) {
console.error("[upload] error:", err, json);
setStatus(err.message || "Upload error");
showOverlayError((json && json.error) ? json.error : err.message);
return;
}
currentDoc = json;
fileInfo.textContent = `${json.filename} (${json.pages} pages)`;
enableZoom();
enableLoadAllIfNeeded();
if (json.ocr_performed) {
ocrStatusNote.style.display = 'block';
if (json.ocr_failed) {
ocrStatusNote.textContent = `OCR failed: ${json.ocr_message || 'Unknown error.'}`;
} else {
ocrStatusNote.textContent = json.ocr_message || 'OCR completed.';
}
} else {
ocrStatusNote.style.display = 'none';
ocrStatusNote.textContent = '';
}
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) {
console.warn("[meta] fetch failed:", e);
}
} else {
downloadOcrLink.style.display = 'none';
}
markOverlayCompleted(
(json.ocr_performed && !json.ocr_failed)
? `OCR finished in ${(json.ocr_time_seconds || 0).toFixed(1)}s. Rendering pages...`
: (json.ocr_performed && json.ocr_failed)
? `Rendering original pages (OCR failed).`
: `Rendering pages...`
);
try {
setStatus("Rendering pages...");
await autoRenderInitialPages();
setStatus("Pages ready. Enter words & press Search.");
} catch (renderErr) {
console.error("[render] error:", renderErr);
setStatus("Render error: " + renderErr.message);
showOverlayError("Render error: " + renderErr.message);
return;
} finally {
setTimeout(hideProcessingOverlay, 400);
overlayForceHideTimer = setTimeout(() => {
if (!processingOverlay.classList.contains('hidden')) {
console.warn("[overlay] force hiding after timeout");
hideProcessingOverlay();
}
}, 15000);
}
});
/* Load All Pages */
loadAllBtn.addEventListener('click', async () => {
if (!currentDoc) return;
loadAllBtn.disabled = true;
setStatus("Loading remaining pages...");
const start = performance.now();
for (let p = 1; p <= currentDoc.pages; p++) {
await safeEnsurePage(p);
if (p % 5 === 0) setStatus(`Loading remaining pages ${p}/${currentDoc.pages}...`);
}
const dur = (performance.now() - start)/1000;
setStatus(`All pages loaded (${dur.toFixed(1)}s).`);
});
function enableLoadAllIfNeeded() {
if (!currentDoc) {
loadAllBtn.disabled = true;
return;
}
loadAllBtn.disabled = currentDoc.pages <= LARGE_DOC_THRESHOLD;
}
/* Initial pages */
async function autoRenderInitialPages() {
if (!currentDoc) return;
const total = currentDoc.pages;
const limit = (total > LARGE_DOC_THRESHOLD) ? AUTO_LOAD_PAGES_LARGE : AUTO_LOAD_PAGES_SMALL;
const toLoad = Math.min(limit, total);
for (let p = 1; p <= toLoad; p++) {
await safeEnsurePage(p);
if (p % 3 === 0 || p === toLoad) {
setStatus(`Rendering pages ${p}/${toLoad}${toLoad < total ? ' (preview)' : ''}...`);
}
}
if (toLoad < total) {
setStatus(`Preview loaded (${toLoad}/${total}). Load All Pages or search.`);
}
}
/* Preload surrounding buffer for fast jumps */
async function preloadJumpWindow(centerPage) {
const tasks = [];
const start = Math.max(1, centerPage - HIGHLIGHT_BUFFER_BEFORE);
const end = Math.min(currentDoc.pages, centerPage + HIGHLIGHT_BUFFER_AFTER);
for (let p = start; p <= end; p++) {
if (!pageCache[p]) {
tasks.push(safeEnsurePage(p));
}
}
if (tasks.length) {
await Promise.all(tasks);
}
}
async function safeEnsurePage(pageNum) {
try {
await ensurePageLoaded(pageNum);
} catch (e) {
console.error(`[page ${pageNum}] load error:`, e);
setStatus(`Page ${pageNum} load error: ${e.message}`);
}
}
/* 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 (err) {
console.error("[search] error:", err, data);
setStatus(err.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 firstPage = searchResults[0].page;
await safeEnsurePage(firstPage);
currentJumpToken++; // reset jump token context
await preloadJumpWindow(firstPage);
setCenterPage(firstPage, { fromClick:true });
seamlessHighlightActive = true;
selectResultIndex(0, {preserveHighlights:true, skipScroll:true}); // we'll scroll explicitly after window built
scrollPageIntoView(firstPage);
setStatus(`Ready. Highlight window centered at page ${firstPage}.`);
}
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', async () => {
const token = ++currentJumpToken;
setStatus(`Jumping to page ${r.page}...`);
// Load target + its highlight window first to avoid layout shift AFTER scroll
await safeEnsurePage(r.page);
await preloadJumpWindow(r.page);
if (token !== currentJumpToken) return; // aborted by newer click
await selectResultIndex(idx, {preserveHighlights:true, skipScroll:true});
setCenterPage(r.page, { fromClick:true });
// highlight window already loaded; updateHighlightWindow will just highlight
scrollPageIntoView(r.page);
setStatus(`Centered on page ${r.page}.`);
});
resultsList.appendChild(li);
});
}
async function selectResultIndex(idx, opts = {}) {
if (idx < 0 || idx >= searchResults.length) return;
[...resultsList.children].forEach((li,i)=>li.classList.toggle('active', i===idx));
const r = searchResults[idx];
currentSelectedPage = r.page;
await safeEnsurePage(r.page);
showPageText(r.page);
if (!bufferedHighlightMode) {
if (seamlessHighlightActive) {
highlightPageMatches(r.page, {append:true});
} else if (!opts.preserveHighlights) {
clearAllHighlights();
highlightPageMatches(r.page);
}
}
if (!opts.skipScroll) {
scrollPageIntoView(r.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 = document.querySelector(`.page[data-page="${pageNum}"]`);
if (el) {
el.scrollIntoView({behavior:'smooth', block:'start'});
}
}
/* Observer */
function ensurePageObserver() {
if (pageObserver) return;
pageObserver = new IntersectionObserver(handlePageIntersections, {
root: document.getElementById('pagesWrap'),
rootMargin: '0px',
threshold: [0.25, 0.5, 0.75]
});
}
function handlePageIntersections(entries) {
if (!bufferedHighlightMode || !entries.length) return;
if (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 pageNum = parseInt(best.target.dataset.page, 10);
if (currentCenterPage !== pageNum) {
if (currentCenterPage != null) {
scrollDirection = pageNum > currentCenterPage ? 1 : -1;
}
setCenterPage(pageNum);
}
}
function setCenterPage(pageNum, { fromClick=false } = {}) {
currentCenterPage = pageNum;
updateHighlightWindow();
if (fromClick) {
programmaticScrollInProgress = true;
setTimeout(() => { programmaticScrollInProgress = false; }, 800);
}
}
function updateHighlightWindow() {
if (!currentDoc || !bufferedHighlightMode) return;
if (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 activatePage = async (p) => {
if (!matchPageSet.has(p)) return;
await safeEnsurePage(p);
highlightPageMatches(p, { append:false });
highlightedPages.add(p);
};
const promises = [];
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 {
promises.push(activatePage(p));
}
} else if (!pageCache[p] && matchPageSet.has(p)) {
promises.push(safeEnsurePage(p).then(()=>{
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]) {
promises.push(safeEnsurePage(p));
}
}
}
Promise.all(promises).catch(e=>console.warn('[buffer] window update error', e));
}
/* Duplicate prevention + dedupe logic */
function dedupePageDom(pageNum) {
const nodes = pagesDiv.querySelectorAll(`.page[data-page="${pageNum}"]`);
if (nodes.length <= 1) return;
for (let i = 0; i < nodes.length - 1; i++) nodes[i].remove();
}
async function ensurePageLoaded(pageNum) {
if (pageCache[pageNum]) return;
if (pageLoadPromises[pageNum]) return pageLoadPromises[pageNum];
pageLoadPromises[pageNum] = (async () => {
if (!currentDoc) return;
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}`);
const pageEl = document.createElement('div');
pageEl.className = 'page';
pageEl.dataset.page = pageNum;
const img = document.createElement('img');
img.src = data.image_url;
img.alt = `Page ${pageNum}`;
img.decoding = 'async';
img.loading = 'lazy';
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);
insertPageInOrder(pageEl);
dedupePageDom(pageNum);
pageCache[pageNum] = {
tokens: data.tokens,
text: data.text,
imageLoadedPromise: new Promise(resolve => {
img.onload = () => resolve();
img.onerror = () => resolve();
}),
overlay
};
await pageCache[pageNum].imageLoadedPromise;
ensurePageObserver();
pageObserver.observe(pageEl);
if (bufferedHighlightMode && matchPageSet.has(pageNum)) {
const inWindow =
currentCenterPage != null &&
pageNum >= currentCenterPage - HIGHLIGHT_BUFFER_BEFORE &&
pageNum <= currentCenterPage + HIGHLIGHT_BUFFER_AFTER;
if (inWindow) {
highlightPageMatches(pageNum, { append:false });
highlightedPages.add(pageNum);
}
} else if (seamlessHighlightActive && !bufferedHighlightMode && matchPageSet.has(pageNum)) {
highlightPageMatches(pageNum, {append:true});
}
})();
try {
await pageLoadPromises[pageNum];
} finally {
delete pageLoadPromises[pageNum];
}
}
function insertPageInOrder(pageEl) {
const num = parseInt(pageEl.dataset.page,10);
const existing = [...pagesDiv.querySelectorAll('.page')];
if (!existing.length) {
pagesDiv.appendChild(pageEl);
return;
}
for (let el of existing) {
const p = parseInt(el.dataset.page,10);
if (num < p) {
pagesDiv.insertBefore(pageEl, el);
return;
}
}
pagesDiv.appendChild(pageEl);
}
/* Highlighting */
function clearAllHighlights() {
document.querySelectorAll('.hl-box').forEach(el => el.remove());
}
function clearHighlightsOnPage(pageNum) {
const pageEl = document.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 box = document.createElement('div');
box.className = 'hl-box';
box.style.left = (x0 * 100) + '%';
box.style.top = (y0 * 100) + '%';
box.style.width = ((x1 - x0) * 100) + '%';
box.style.height = ((y1 - y0) * 100) + '%';
frag.appendChild(box);
}
}
overlay.appendChild(frag);
}
/* Resize (no-op) */
window.addEventListener('resize', () => {});
/* Zoom */
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(currentScale * 100) + '%';
pagesDiv.style.transformOrigin = 'top center';
pagesDiv.style.transform = `scale(${currentScale})`;
}
/* 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');
});
})();
/* Reset */
function resetAll() {
currentDoc = null;
currentWords = [];
searchResults = [];
currentSelectedPage = null;
pageCache = {};
matchPageSet.clear();
seamlessHighlightActive = false;
highlightedPages.clear();
currentCenterPage = null;
fileInfo.textContent = '';
resultsList.innerHTML = '';
pageText.value = '';
legend.innerHTML = 'No words';
pagesDiv.innerHTML = '';
disableZoom();
loadAllBtn.disabled = true;
pagesDiv.style.transform = '';
downloadOcrLink.style.display = 'none';
ocrStatusNote.style.display = 'none';
setStatus("Ready.");
if (pageObserver) {
pageObserver.disconnect();
pageObserver = null;
}
for (const k in pageLoadPromises) {
// Best-effort; cannot actually cancel fetch.
}
}
setStatus("Ready.");