/* 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 = `
`;
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 = `
`;
// 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.");