'use strict'; // ── PDF.js worker ──────────────────────────────────────────────── if (typeof pdfjsLib !== 'undefined') { pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; } // ── Diary config ───────────────────────────────────────────────── const DIARY_CONFIG = { 'calgary-highlanders': { pdfPath: '../Inputs/Calgary-Highlanders_War-Diary_Sep44.pdf', textSources: [ { label: 'olmOCR — viewer', path: '../outputs_step2_json_to_viewer/Calgary-Highlanders_War-Diary_Sep44_olmocr_viewer.md' }, { label: 'Claude 4.6 — viewer', path: '../outputs_step2_json_to_viewer/step2_Calgary-Highlanders_War-Diary_Sep44_claude-4-6_viewer.md' }, ], }, 'black-watch': { pdfPath: '../Inputs/RHC-Blackwatch_War-Diary_Sep44.pdf', textSources: [ { label: 'olmOCR', path: '../Inputs/ocr-output/RHC-Blackwatch_War-Diary_Sep44_olmocr.md' }, ], }, '5cib': { pdfPath: '../Inputs/5CIB_War-Diary_Sep44.pdf', textSources: [ { label: 'olmOCR', path: '../Inputs/ocr-output/5CIB_War-Diary_Sep44_olmocr.md' }, ], }, }; const diaryId = new URLSearchParams(window.location.search).get('diary') || 'calgary-highlanders'; const _diary = DIARY_CONFIG[diaryId] || DIARY_CONFIG['calgary-highlanders']; // ── Data paths (served by launch_viewer.py) ─────────────────────── const REAL_PDF_PATH = _diary.pdfPath; let REAL_TEXT_PATH = _diary.textSources[0].path; // ── Per-diary storage keys / server endpoints ───────────────────── const LS_FLAGS_KEY = 'p44-flags-' + diaryId; const LS_PASS2_FLAGS_KEY = 'p44-pass2-flags-' + diaryId; const LS_CORRECTIONS_KEY = 'p44-corrections-' + diaryId; const SERVER_FLAGS_FILE = 'viewer/data/flags-' + diaryId + '.json'; const SERVER_PASS2_FLAGS_FILE = 'viewer/data/pass2_flags-' + diaryId + '.json'; const SERVER_CORRECTIONS_FILE = 'viewer/data/corrections-' + diaryId + '.json'; const SERVER_FLAGS_ENDPOINT = '/save-flags?diary=' + diaryId; const SERVER_PASS2_ENDPOINT = '/save-pass2-flags?diary=' + diaryId; const SERVER_CORRECTIONS_END = '/save-corrections?diary=' + diaryId; const SERVER_CLEAR_ENDPOINT = '/clear-all-data?diary=' + diaryId; // ── Diary display names ─────────────────────────────────────────── const DIARY_TITLES = { 'calgary-highlanders': 'Calgary Highlanders — Sep 1944', 'black-watch': 'Black Watch (RHR) — Sep 1944', '5cib': '5th Canadian Infantry Brigade — Sep 1944', }; // ───────────────────────────────────────────────────────────────── // STATE // ───────────────────────────────────────────────────────────────── const state = { mode: 'none', // 'pdf' | 'none' pdfDoc: null, currentPage: 1, totalPages: 0, zoomScale: 1.0, wordData: [], rendering: false, ocrPages: {}, lastRenderTime: 0, flags: {}, corrections: {}, ocrOverlay: false, }; // ───────────────────────────────────────────────────────────────── // DOM REFS // ───────────────────────────────────────────────────────────────── const inputPdf = document.getElementById('input-pdf'); const inputText = document.getElementById('input-text'); const inputJson = document.getElementById('input-json'); const imagePlaceholder = document.getElementById('image-placeholder'); const imgSpinner = document.getElementById('img-spinner'); const imgPhText = document.getElementById('img-ph-text'); const pdfContainer = document.getElementById('pdf-container'); const pdfCanvas = document.getElementById('pdf-canvas'); const overlay = document.getElementById('overlay'); const textPlaceholder = document.getElementById('text-placeholder'); const txtSpinner = document.getElementById('txt-spinner'); const txtPhText = document.getElementById('txt-ph-text'); const btnPrev = document.getElementById('btn-prev'); const btnNext = document.getElementById('btn-next'); const pageInput = document.getElementById('page-input'); const pageLabel = document.getElementById('page-label'); const ocrSyncLabel = document.getElementById('ocr-sync-label'); const statusMsg = document.getElementById('status-msg'); // Phase 2 — panel body refs (used for edit mode layout) const imagePanelBody = document.getElementById('image-panel-body'); const textPanelBody = document.getElementById('text-panel-body'); // Zoom indicator const zoomLabel = document.getElementById('zoom-label'); const btnZoomReset = document.getElementById('btn-zoom-reset'); // Phase 4 — flags const btnFlagDone = document.getElementById('btn-flag-done'); const btnFlagProblem = document.getElementById('btn-flag-problem'); const flagsSummary = document.getElementById('flags-summary'); // Phase 5 — edit surface const editorWrap = document.getElementById('editor-wrap'); const lineNumbers = document.getElementById('line-numbers'); const textEditor = document.getElementById('text-editor'); // Phase 6 — OCR overlay toggle (Pass 2) const btnOcrToggle = document.getElementById('btn-ocr-toggle'); const ocrOverlayPanel = document.getElementById('ocr-overlay-panel'); // ───────────────────────────────────────────────────────────────── // OVERRIDE DROPDOWN // ───────────────────────────────────────────────────────────────── const btnOverride = document.getElementById('btn-override'); const overrideMenu = document.getElementById('override-menu'); btnOverride.addEventListener('click', e => { e.stopPropagation(); overrideMenu.classList.toggle('open'); }); // Close when a file input is activated (user picked a file or cancelled) document.getElementById('input-pdf') .addEventListener('change', () => overrideMenu.classList.remove('open')); document.getElementById('input-text').addEventListener('change', () => overrideMenu.classList.remove('open')); document.getElementById('input-json').addEventListener('change', () => overrideMenu.classList.remove('open')); // Close when clicking anywhere outside the dropdown document.addEventListener('click', () => overrideMenu.classList.remove('open')); // ───────────────────────────────────────────────────────────────── // TOOLBAR BUTTON EVENTS // ───────────────────────────────────────────────────────────────── inputPdf.addEventListener('change', async e => { const file = e.target.files[0]; if (!file) return; setStatus('Loading PDF: ' + file.name + '…'); showImgSpinner(true); const buf = await file.arrayBuffer(); showImgSpinner(false); await loadPDFBuffer(buf); setStatus('PDF: ' + file.name + ' — ' + state.totalPages + ' pages'); }); inputText.addEventListener('change', e => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = ev => { loadOCRText(ev.target.result, true); setStatus('Text: ' + file.name); }; reader.readAsText(file); }); inputJson.addEventListener('change', e => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = ev => { try { loadWordDataJSON(JSON.parse(ev.target.result)); setStatus('JSON: ' + file.name + ' — ' + state.wordData.length + ' words'); } catch (err) { alert('JSON parse error: ' + err.message); } }; reader.readAsText(file); }); btnPrev.addEventListener('click', () => { if (currentRole === 'pass2') { const verified = getVerifiedPages(); if (verified.length) { const prev = [...verified].reverse().find(p => p < state.currentPage); if (prev !== undefined) goToPage(prev); } else { goToPage(state.currentPage - 1); // no queue yet — sequential fallback } } else { goToPage(state.currentPage - 1); } }); btnNext.addEventListener('click', () => { if (currentRole === 'pass2') { const verified = getVerifiedPages(); if (verified.length) { const next = verified.find(p => p > state.currentPage); if (next !== undefined) goToPage(next); } else { goToPage(state.currentPage + 1); // no queue yet — sequential fallback } } else { goToPage(state.currentPage + 1); } }); pageInput.addEventListener('change', () => { const n = parseInt(pageInput.value, 10); if (!isNaN(n)) goToPage(n); }); window.addEventListener('resize', () => { if (state.mode === 'pdf' && state.pdfDoc && !state.rendering) renderCurrentPage(); updateLineNumbers(); }); // ── Auto-load on startup when served from localhost ─────────────── window.addEventListener('DOMContentLoaded', () => { // Set diary title in toolbar and browser tab const diaryTitle = DIARY_TITLES[diaryId] || diaryId; document.title = 'P44 OCR Viewer — ' + diaryTitle; const diaryLabel = document.getElementById('diary-label'); if (diaryLabel) diaryLabel.textContent = diaryTitle; // Populate OCR source dropdown const sourceSelect = document.getElementById('ocr-source-select'); if (sourceSelect && _diary.textSources) { _diary.textSources.forEach((src, i) => { const opt = document.createElement('option'); opt.value = src.path; opt.textContent = src.label; sourceSelect.appendChild(opt); }); // Ensure the dropdown reflects the actual initially-loaded source, overriding // any browser-restored form state that could cause a visual mismatch. sourceSelect.value = REAL_TEXT_PATH; sourceSelect.addEventListener('change', async () => { REAL_TEXT_PATH = sourceSelect.value; txtSpinner.style.display = 'inline-block'; try { const r = await fetch(REAL_TEXT_PATH + '?v=' + Date.now()); if (!r.ok) throw new Error('HTTP ' + r.status); const text = await r.text(); // Clear stale per-page "original" snapshots so the new source content // is displayed instead of the previously-cached text. Object.values(state.corrections).forEach(c => { delete c.original; }); loadOCRText(text, true); if (state.pdfDoc) showOCRPage(state.currentPage || 1); setStatus('OCR source changed — ' + sourceSelect.options[sourceSelect.selectedIndex].text); } catch (err) { showTxtPlaceholder('Could not load OCR source: ' + err.message); } finally { txtSpinner.style.display = 'none'; } }); } if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') { setTimeout(loadWarDiary, 200); } }); // ───────────────────────────────────────────────────────────────── // AUTO-LOAD: fetch PDF + OCR text from the Inputs folder // ───────────────────────────────────────────────────────────────── async function loadWarDiary() { if (typeof pdfjsLib === 'undefined') { setStatus('PDF.js not loaded — check internet connection, then refresh.', true); return; } setStatus('Loading war diary…'); showImgSpinner(true); showTxtSpinner(true); const [textRes, pdfRes] = await Promise.allSettled([ fetch(REAL_TEXT_PATH + '?v=' + Date.now()).then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }), fetch(REAL_PDF_PATH ).then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }), ]); // OCR text showTxtSpinner(false); if (textRes.status === 'fulfilled') { loadOCRText(textRes.value, true); } else { showTxtPlaceholder('Could not load OCR text: ' + textRes.reason + '.
Use the OCR Override picker above.'); } // PDF showImgSpinner(false); if (pdfRes.status === 'fulfilled') { await loadPDFBuffer(pdfRes.value); setStatus( state.totalPages + '-page PDF loaded' + (textRes.status === 'fulfilled' ? ' + OCR text' : '') + '. Use ◀▶ to navigate.' ); } else { showImgPlaceholder('Could not load PDF: ' + pdfRes.reason + '.
Use the PDF Override picker above.'); if (textRes.status === 'fulfilled') { setStatus('OCR text loaded. PDF unavailable — use the PDF Override picker.', true); } } } // ───────────────────────────────────────────────────────────────── // PDF.js — load & render // ───────────────────────────────────────────────────────────────── async function loadPDFBuffer(arrayBuffer) { try { state.pdfDoc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; state.totalPages = state.pdfDoc.numPages; state.mode = 'pdf'; state.currentPage = 1; state.wordData = []; // clear JSON sidecar — use PDF text layer pageLabel.textContent = '/ ' + state.totalPages; // Note: pageInput.value is set by renderCurrentPage() below. // Setting it here would fire a change event → goToPage → duplicate render. btnPrev.disabled = true; btnNext.disabled = state.totalPages <= 1; pdfContainer.style.display = 'inline-block'; imagePlaceholder.style.display = 'none'; await renderCurrentPage(); } catch (err) { showImgPlaceholder('Failed to open PDF: ' + err.message); } } async function renderCurrentPage() { if (!state.pdfDoc || state.rendering) return; // Debounce: ignore spurious re-render calls (e.g. from resize events) that // fire within 100 ms of a render that just completed. if (Date.now() - state.lastRenderTime < 100) return; state.rendering = true; try { const page = await state.pdfDoc.getPage(state.currentPage); // Fit page to panel width, then apply zoomScale. Multiply by DPR for sharp HiDPI rendering. const dpr = window.devicePixelRatio || 1; const panelW = document.getElementById('image-panel-body').clientWidth || 700; const baseVP = page.getViewport({ scale: 1 }); const fitScale = panelW / baseVP.width; const vp = page.getViewport({ scale: fitScale * state.zoomScale * dpr }); // Physical canvas size = DPR × CSS size → crisp on Retina/HiDPI displays pdfCanvas.width = vp.width; pdfCanvas.height = vp.height; pdfCanvas.style.width = (vp.width / dpr) + 'px'; pdfCanvas.style.height = (vp.height / dpr) + 'px'; await page.render({ canvasContext: pdfCanvas.getContext('2d'), viewport: vp }).promise; await buildPDFWordOverlay(page, vp, dpr); pageInput.value = state.currentPage; btnPrev.disabled = state.currentPage <= 1; btnNext.disabled = state.currentPage >= state.totalPages; // Sync right panel try { showOCRPage(state.currentPage); } catch (e) { setStatus('OCR panel error on page ' + state.currentPage + ': ' + e.message, true); console.error(e); } state.lastRenderTime = Date.now(); // debounce: record when this render finished _lastRenderedZoom = state.zoomScale; // keep transform delta in sync } finally { state.rendering = false; // If the user navigated while we were rendering, honour the queued page if (_pendingPage !== null) { const next = _pendingPage; _pendingPage = null; if (next !== state.currentPage) { state.currentPage = next; renderCurrentPage(); } } } } // Build word-box overlay from PDF.js text content async function buildPDFWordOverlay(page, vp, dpr) { dpr = dpr || 1; overlay.innerHTML = ''; // Overlay must match CSS canvas size, not physical pixel size overlay.style.width = (vp.width / dpr) + 'px'; overlay.style.height = (vp.height / dpr) + 'px'; // JSON sidecar in PDF mode — coords are PDF points (bottom-left origin) if (state.wordData.length) { state.wordData.forEach(e => { const [cx, cy] = vp.convertToViewportPoint(e.bbox.x, e.bbox.y + e.bbox.h); const cw = e.bbox.w * vp.scale; const ch = e.bbox.h * vp.scale; overlay.appendChild(makeWordBox(e.id, cx/dpr, cy/dpr, cw/dpr, ch/dpr, e.word)); }); } // No JSON sidecar — overlay intentionally left blank } function makeWordBox(id, x, y, w, h, title) { const div = document.createElement('div'); div.className = 'word-box'; div.dataset.wordId = id; div.style.left = Math.round(x) + 'px'; div.style.top = Math.round(y) + 'px'; div.style.width = Math.max(4, Math.round(w)) + 'px'; div.style.height = Math.max(4, Math.round(h)) + 'px'; if (title) div.title = title; div.addEventListener('mouseenter', () => { div.classList.add('highlight'); }); div.addEventListener('mouseleave', () => { div.classList.remove('highlight'); }); return div; } let _pendingPage = null; // holds the last page requested while a render was in progress // ───────────────────────────────────────────────────────────────── // OCR TEXT RENDERING // ───────────────────────────────────────────────────────────────── function loadOCRText(rawText, isHtmlMarkdown) { textPlaceholder.style.display = 'none'; if (isHtmlMarkdown) { // Parse the olmOCR markdown into a per-page dictionary so we can render // exactly one page at a time — fast, accurate, and avoids the "highlight all" // problem that comes from searching a giant single-document DOM. state.ocrPages = parseOCRByPage(rawText); // Do NOT call showOCRPage() here. If the PDF is already loaded, // renderCurrentPage() will call showOCRPage() as part of its own flow. // If the PDF isn't loaded yet (the common startup path where OCR arrives // first), renderCurrentPage() will call showOCRPage() once it runs. // Calling it here would build the OCR panel twice on every startup. if (state.pdfDoc) { showOCRPage(state.currentPage || 1); } // else: leave the panel blank — renderCurrentPage() will populate it. } else { // Plain-text / sample mode — load directly into editor state.ocrPages = {}; textEditor.value = rawText; updateLineNumbers(); } } // ── Parse olmOCR markdown into per-page dict ────────────────────────────── // Each "## Page N" heading starts a new page entry. Content between two // headings (tables, prose, weather notes) is stored under its page number. function parseOCRByPage(rawText) { const pages = {}; const lines = rawText.split('\n'); let pageNum = null; let buf = []; for (const line of lines) { const m = line.match(/^## Page (\d+)\s*$/); if (m) { if (pageNum !== null) pages[pageNum] = buf.join('\n'); pageNum = parseInt(m[1], 10); buf = []; // do NOT include the ## Page N heading in the content } else if (pageNum !== null) { buf.push(line); } } if (pageNum !== null) pages[pageNum] = buf.join('\n'); return pages; } function showOCROverlay(pageNum) { const corr = state.corrections && state.corrections[pageNum]; const originalText = (corr && corr.original) || state.ocrPages[pageNum] || ''; ocrOverlayPanel.innerHTML = '
' +
    escH(originalText) + '
'; ocrOverlayPanel.style.display = 'block'; pdfContainer.style.display = 'none'; imagePlaceholder.style.display = 'none'; } function hideOCROverlay() { ocrOverlayPanel.style.display = 'none'; if (state.mode === 'pdf') { pdfContainer.style.display = 'block'; } else { imagePlaceholder.style.display = 'flex'; } } function toggleOCROverlay() { state.ocrOverlay = !state.ocrOverlay; btnOcrToggle.classList.toggle('active', state.ocrOverlay); if (state.ocrOverlay) { showOCROverlay(state.currentPage); } else { hideOCROverlay(); } } btnOcrToggle.addEventListener('click', toggleOCROverlay); // ── Render the OCR panel for a single page ──────────────────────────────── function showOCRPage(pageNum) { textEditor.dataset.page = pageNum; if (!Object.keys(state.ocrPages).length) { textEditor.value = ''; ocrSyncLabel.textContent = ''; return; } const content = state.ocrPages[pageNum]; if (!content) { textEditor.value = 'No OCR text for page ' + pageNum + '.'; ocrSyncLabel.textContent = 'page ' + pageNum + ' — no data'; updateLineNumbers(); return; } const corr = state.corrections && state.corrections[pageNum]; const editText = corr ? (currentRole === 'pass2' ? (corr.pass2 || corr.pass1 || corr.original || content) : (corr.pass1 || corr.original || content)) : content; textEditor.value = editText; textEditor.scrollTop = 0; ocrSyncLabel.textContent = 'page ' + pageNum; updateLineNumbers(); updateFlagButtons(pageNum); updateCorrectionIndicator(pageNum); // Snapshot original on first visit if (!state.corrections[pageNum] || !state.corrections[pageNum].original) { if (!state.corrections[pageNum]) state.corrections[pageNum] = {}; state.corrections[pageNum].original = content.trim(); } // Pass 1 done-page lock const doneBanner = document.getElementById('done-banner'); const isDonePage = currentRole === 'pass1' && state.flags[pageNum] === 'done'; if (isDonePage) { textEditor.readOnly = true; textEditor.style.opacity = '0.55'; doneBanner.innerHTML = '✓ Done — submitted to Pass 2 queue. This page is locked.'; doneBanner.style.display = 'flex'; } else { textEditor.readOnly = false; textEditor.style.opacity = ''; doneBanner.style.display = 'none'; } if (currentRole === 'pass2') { updatePass2NavButtons(); if (state.ocrOverlay) showOCROverlay(pageNum); showPass2RevertBar(pageNum); showPass2ChangeSummary(pageNum); } } // ───────────────────────────────────────────────────────────────── // WORD DATA (JSON SIDECAR) // ───────────────────────────────────────────────────────────────── function loadWordDataJSON(data) { state.wordData = data; // PDF mode: re-render so buildPDFWordOverlay picks up the new boxes if (state.mode === 'pdf' && state.pdfDoc) { renderCurrentPage(); return; } // Image mode: re-render overlay & re-link text spans (unchanged behaviour) if (state.mode === 'image' && pdfCanvas.width) { const sx = pdfCanvas.width / state.imageNaturalW; const sy = pdfCanvas.height / state.imageNaturalH; overlay.innerHTML = ''; overlay.style.width = pdfCanvas.width + 'px'; overlay.style.height = pdfCanvas.height + 'px'; data.forEach(e => overlay.appendChild( makeWordBox(e.id, e.bbox.x*sx, e.bbox.y*sy, e.bbox.w*sx, e.bbox.h*sy, e.word) )); } } // ───────────────────────────────────────────────────────────────── // HELPERS // ───────────────────────────────────────────────────────────────── function escH(s) { return s.replace(/&/g,'&').replace(//g,'>'); } function showImgSpinner(on) { imgSpinner.style.display = on ? 'inline-block' : 'none'; imgPhText.style.display = on ? 'none' : 'block'; imagePlaceholder.style.display = 'flex'; if (on) pdfContainer.style.display = 'none'; } function showTxtSpinner(on) { txtSpinner.style.display = on ? 'inline-block' : 'none'; txtPhText.style.display = on ? 'none' : 'block'; textPlaceholder.style.display = 'flex'; } function showImgPlaceholder(html) { imgPhText.innerHTML = html; imagePlaceholder.style.display = 'flex'; pdfContainer.style.display = 'none'; } function showTxtPlaceholder(html) { txtPhText.innerHTML = html; textPlaceholder.style.display = 'flex'; } function setStatus(msg, warn) { statusMsg.textContent = msg; statusMsg.style.color = warn ? '#e07070' : 'var(--text-dim)'; } // ───────────────────────────────────────────────────────────────── // PHASE 3 — SCROLL ZOOM ON LEFT PANEL // ───────────────────────────────────────────────────────────────── const ZOOM_SCALE_MIN = 0.5; const ZOOM_SCALE_MAX = 4.0; const ZOOM_SCALE_STEP = 0.1; // Zoom state — separate "last rendered" zoom from "desired" zoom so we can // use CSS transform for instant feedback without triggering a PDF re-render // on every wheel tick. let _lastRenderedZoom = 1.0; let _zoomSettleTimer = null; let _zoomAnchorFracX = 0.5; let _zoomAnchorFracY = 0.5; let _zoomAnchorClientX = 0; let _zoomAnchorClientY = 0; function updateZoomLabel() { const pct = Math.round(state.zoomScale * 100); zoomLabel.textContent = pct + '%'; btnZoomReset.style.opacity = state.zoomScale === 1.0 ? '0.4' : '1'; } imagePanelBody.addEventListener('wheel', e => { if (state.mode === 'none') return; if (state.ocrOverlay) return; // overlay is showing — let scroll pass through normally e.preventDefault(); const delta = e.deltaY < 0 ? ZOOM_SCALE_STEP : -ZOOM_SCALE_STEP; const newZoom = Math.round( Math.max(ZOOM_SCALE_MIN, Math.min(ZOOM_SCALE_MAX, state.zoomScale + delta)) * 10 ) / 10; if (newZoom === state.zoomScale) return; // Capture cursor anchor once at the start of each new gesture (when timer isn't running) if (_zoomSettleTimer === null) { const rect = imagePanelBody.getBoundingClientRect(); const cssW = parseFloat(pdfCanvas.style.width) || pdfCanvas.clientWidth || 700; const cssH = parseFloat(pdfCanvas.style.height) || pdfCanvas.clientHeight || 900; _zoomAnchorClientX = e.clientX - rect.left; _zoomAnchorClientY = e.clientY - rect.top; _zoomAnchorFracX = (_zoomAnchorClientX + imagePanelBody.scrollLeft) / cssW; _zoomAnchorFracY = (_zoomAnchorClientY + imagePanelBody.scrollTop) / cssH; } state.zoomScale = newZoom; updateZoomLabel(); // Instant visual feedback via CSS transform — GPU-composited, zero layout impact, // zero flicker. Scale relative to the last full render so the factor is exact. const sf = state.zoomScale / _lastRenderedZoom; const ox = _zoomAnchorFracX * 100; const oy = _zoomAnchorFracY * 100; pdfCanvas.style.transformOrigin = `${ox}% ${oy}%`; pdfCanvas.style.transform = `scale(${sf})`; overlay.style.transformOrigin = `${ox}% ${oy}%`; overlay.style.transform = `scale(${sf})`; // After the scroll gesture settles, do ONE proper HiDPI re-render clearTimeout(_zoomSettleTimer); _zoomSettleTimer = setTimeout(async () => { _zoomSettleTimer = null; // Clear the transform before re-render to avoid double-scaling pdfCanvas.style.transform = ''; pdfCanvas.style.transformOrigin = ''; overlay.style.transform = ''; overlay.style.transformOrigin = ''; state.lastRenderTime = 0; if (state.pdfDoc) { await renderCurrentPage(); } _lastRenderedZoom = state.zoomScale; // Scroll so the anchor point stays under the cursor const newCSSW = parseFloat(pdfCanvas.style.width) || pdfCanvas.clientWidth; const newCSSH = parseFloat(pdfCanvas.style.height) || pdfCanvas.clientHeight; imagePanelBody.scrollLeft = _zoomAnchorFracX * newCSSW - _zoomAnchorClientX; imagePanelBody.scrollTop = _zoomAnchorFracY * newCSSH - _zoomAnchorClientY; }, 150); }, { passive: false }); btnZoomReset.addEventListener('click', async () => { if (state.zoomScale === 1.0) return; // Cancel any pending settle timer clearTimeout(_zoomSettleTimer); _zoomSettleTimer = null; // Clear any in-progress CSS transform pdfCanvas.style.transform = ''; pdfCanvas.style.transformOrigin = ''; overlay.style.transform = ''; overlay.style.transformOrigin = ''; state.zoomScale = 1.0; updateZoomLabel(); state.lastRenderTime = 0; await renderCurrentPage(); _lastRenderedZoom = 1.0; imagePanelBody.scrollLeft = 0; imagePanelBody.scrollTop = 0; }); // ───────────────────────────────────────────────────────────────── // ROLE SWITCHER — PASS 1 / PASS 2 // ───────────────────────────────────────────────────────────────── const roleSelect = document.getElementById('role-select'); const pass2Banner = document.getElementById('pass2-banner'); const pass2BannerText = document.getElementById('pass2-banner-text'); let currentRole = 'pass1'; let pass2Flags = {}; const PASS2_BTN_LABELS = { done: '✓ Pass 2 approved', problem: '⚠ Needs work' }; const PASS1_BTN_LABELS = { done: '✓ Done', problem: '⚠ Problem' }; function getVerifiedPages() { return Object.keys(state.flags) .filter(k => state.flags[k] === 'done') .map(Number) .sort((a, b) => a - b); } function updatePass2NavButtons() { const verified = getVerifiedPages(); // If Pass 1 queue has no done pages yet, fall back to sequential navigation if (!verified.length) { btnPrev.disabled = !state.pdfDoc || state.currentPage <= 1; btnNext.disabled = !state.pdfDoc || state.currentPage >= state.totalPages; return; } if (!state.pdfDoc) { btnPrev.disabled = true; btnNext.disabled = true; return; } btnPrev.disabled = !verified.some(p => p < state.currentPage); btnNext.disabled = !verified.some(p => p > state.currentPage); } function updatePass2Banner() { const count = getVerifiedPages().length; pass2BannerText.textContent = count === 0 ? 'Pass 2 mode · No pages in queue' : 'Pass 2 mode · ' + count + ' page' + (count === 1 ? '' : 's') + ' ready for review'; updatePass2NavButtons(); } function applyRole(role) { currentRole = role; localStorage.setItem('p44-role', role); roleSelect.value = role; if (role === 'pass2') { pass2Banner.style.display = 'flex'; btnOcrToggle.style.display = 'inline-block'; btnFlagDone.textContent = PASS2_BTN_LABELS.done; btnFlagProblem.textContent = PASS2_BTN_LABELS.problem; updatePass2Banner(); } else { pass2Banner.style.display = 'none'; btnOcrToggle.style.display = 'none'; state.ocrOverlay = false; btnOcrToggle.classList.remove('active'); hideOCROverlay(); btnFlagDone.textContent = PASS1_BTN_LABELS.done; btnFlagProblem.textContent = PASS1_BTN_LABELS.problem; if (state.pdfDoc) { btnPrev.disabled = state.currentPage <= 1; btnNext.disabled = state.currentPage >= state.totalPages; } } updateFlagButtons(state.currentPage); } function savePass2Flags() { try { localStorage.setItem(LS_PASS2_FLAGS_KEY, JSON.stringify(pass2Flags)); } catch (_) {} fetch(SERVER_PASS2_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(pass2Flags), }).catch(() => {}); } function loadPass2FlagsOnStartup() { fetch('/' + SERVER_PASS2_FLAGS_FILE) .then(r => r.ok ? r.json() : Promise.reject()) .then(data => { pass2Flags = data; }) .catch(() => { try { const stored = localStorage.getItem(LS_PASS2_FLAGS_KEY); if (stored) pass2Flags = JSON.parse(stored); } catch (_) {} }); } function resetPass2() { if (!confirm( 'Reset Pass 2?\n\n' + 'This will clear:\n' + ' • All Pass 2 approved / needs-work / rejected flags\n' + ' • All saved text corrections\n\n' + 'Pass 1 done flags are NOT affected.\n\n' + 'This cannot be undone.' )) return; // Wipe Pass 2 flags pass2Flags = {}; try { localStorage.removeItem(LS_PASS2_FLAGS_KEY); } catch (_) {} // Wipe corrections (these are shared with Pass 1 editor but the contamination // source was Pass 2 auto-edit; clearing gives a clean slate) state.corrections = {}; try { localStorage.removeItem(LS_CORRECTIONS_KEY); } catch (_) {} try { fetch(SERVER_CORRECTIONS_END, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); } catch (_) {} // Refresh UI updateFlagButtons(state.currentPage); updateCorrectionIndicator(state.currentPage); updatePass2Banner(); if (state.pdfDoc) showOCRPage(state.currentPage); setStatus('Pass 2 queue reset — all Pass 2 flags and corrections cleared.'); } document.getElementById('btn-pass2-reset').addEventListener('click', resetPass2); // ───────────────────────────────────────────────────────────────── // PASS 2 — CHANGE SUMMARY PANEL (Step 9) // ───────────────────────────────────────────────────────────────── // Walk both word arrays simultaneously and produce a list of changes. // Simple sequential diff — fast, good enough for plain-text corrections. function buildChangeSummary(originalText, pass1Text) { const orig = (originalText || '').trim().split(/\s+/).filter(Boolean); const edit = (pass1Text || '').trim().split(/\s+/).filter(Boolean); if (orig.join(' ') === edit.join(' ')) return []; const changes = []; let i = 0, j = 0; while (i < orig.length || j < edit.length) { if (changes.length >= 50) { changes.push({ type: 'truncated' }); break; } if (i >= orig.length) { changes.push({ type: 'added', word: edit[j++] }); } else if (j >= edit.length) { changes.push({ type: 'deleted', word: orig[i++] }); } else if (orig[i] === edit[j]) { i++; j++; } else { changes.push({ type: 'changed', from: orig[i++], to: edit[j++] }); } } return changes; } function showPass2ChangeSummary(pageNum) { const panel = document.getElementById('pass2-change-summary'); if (!panel) return; const corr = state.corrections && state.corrections[pageNum]; if (!corr || !corr.pass1 || currentRole !== 'pass2') { panel.style.display = 'none'; return; } const changes = buildChangeSummary(corr.original || state.ocrPages[pageNum] || '', corr.pass1); if (!changes.length) { panel.style.display = 'none'; return; } const truncated = changes.find(c => c.type === 'truncated'); const realChanges = changes.filter(c => c.type !== 'truncated'); const count = truncated ? '50+' : realChanges.length; const listHtml = realChanges.map(c => { if (c.type === 'changed') return `~ "${escH(c.from)}" → "${escH(c.to)}"`; if (c.type === 'added') return `+ "${escH(c.word)}"`; if (c.type === 'deleted') return `− "${escH(c.word)}"`; return ''; }).join('
'); panel.innerHTML = '
' + count + ' change' + (count === 1 ? '' : 's') + ' from Pass 1
' + '
' + listHtml + (truncated ? '
…and more' : '') + '
' + '
' + '' + '' + '
'; panel.style.display = 'block'; const pass1Text = corr.pass1; document.getElementById('btn-cs-revert-pass1').addEventListener('click', () => { if (!confirm('Revert to Pass 1 text?\n\nYour Pass 2 changes on this page will be lost.')) return; textEditor.value = pass1Text; setStatus('Reverted to Pass 1 text — navigate away or click Edit to save.'); }); document.getElementById('btn-cs-revert-original').addEventListener('click', () => { const typed = prompt( 'This will discard ALL corrections on this page — both Pass 1 and Pass 2.\n\nType REVERT to confirm:' ); if (typed !== 'REVERT') { setStatus('Revert cancelled.'); return; } textEditor.value = state.ocrPages[pageNum] || ''; delete state.corrections[pageNum]; postCorrection(pageNum, null); updateCorrectionIndicator(pageNum); panel.style.display = 'none'; setStatus('Reverted to original OCR — all corrections on this page cleared.'); }); } roleSelect.addEventListener('change', () => applyRole(roleSelect.value)); // ───────────────────────────────────────────────────────────────── // PHASE 4 — FLAG / ANNOTATION SYSTEM // ───────────────────────────────────────────────────────────────── function updateFlagButtons(pageNum) { const flagSrc = currentRole === 'pass2' ? pass2Flags : state.flags; const flag = flagSrc[pageNum] || null; btnFlagDone.classList.toggle('active', flag === 'done'); btnFlagProblem.classList.toggle('active', flag === 'problem'); // Done pages are read-only in Pass 1 — disable flag buttons const isDone = currentRole === 'pass1' && flag === 'done'; btnFlagDone.disabled = isDone; btnFlagProblem.disabled = isDone; } function updateFlagsSummary() { let done = 0, problem = 0; Object.values(state.flags).forEach(f => { if (f === 'done') done++; else if (f === 'problem') problem++; }); flagsSummary.textContent = done || problem ? '✓ ' + done + (problem ? ' ⚠ ' + problem : '') : ''; if (currentRole === 'pass2') updatePass2Banner(); } async function saveFlags() { try { const res = await fetch(SERVER_FLAGS_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(state.flags), }); if (res.ok) return; } catch (_) {} // Server doesn't support it (or offline) — fall back to localStorage try { localStorage.setItem(LS_FLAGS_KEY, JSON.stringify(state.flags)); } catch (_) {} } function setFlag(type) { const p = state.currentPage; if (currentRole === 'pass2') { if (type === null) { delete pass2Flags[p]; } else { pass2Flags[p] = type; } updateFlagButtons(p); savePass2Flags(); return; } // Pass 1 — Done is one-way: once set it cannot be cleared by the volunteer if (state.flags[p] === 'done') return; // Problem is toggleable — clicking it again removes it if (type === 'problem' && state.flags[p] === 'problem') { delete state.flags[p]; } else if (type === null) { delete state.flags[p]; } else { state.flags[p] = type; } updateFlagButtons(p); updateFlagsSummary(); saveFlags(); // Re-render page immediately so done-lock (readOnly) takes effect in Pass 1 if (type === 'done' && currentRole === 'pass1') showOCRPage(state.currentPage); } btnFlagDone.addEventListener('click', () => setFlag('done')); btnFlagProblem.addEventListener('click', () => setFlag('problem')); async function loadFlagsOnStartup() { try { const res = await fetch('/' + SERVER_FLAGS_FILE); if (res.ok) { state.flags = await res.json(); updateFlagsSummary(); return; } } catch (_) {} // Fallback: localStorage try { const stored = localStorage.getItem(LS_FLAGS_KEY); if (stored) { state.flags = JSON.parse(stored); updateFlagsSummary(); } } catch (_) {} } function clearPass1Queue() { const doneCount = Object.values(state.flags).filter(f => f === 'done').length; if (doneCount === 0) { setStatus('Pass 1 queue is already empty — no done pages to clear.'); return; } if (!confirm( 'Clear Pass 1 queue?\n\n' + 'This will remove all ' + doneCount + ' done flag(s) from Pass 1.\n' + 'The Pass 2 review queue will become empty.\n\n' + 'Problem flags are NOT affected.\n' + 'This cannot be undone.' )) return; // Remove all done flags; keep problem flags intact Object.keys(state.flags).forEach(k => { if (state.flags[k] === 'done') delete state.flags[k]; }); updateFlagButtons(state.currentPage); updateFlagsSummary(); // also calls updatePass2Banner if in pass2 saveFlags(); setStatus('Pass 1 queue cleared — ' + doneCount + ' done flag(s) removed. Pass 2 queue is now empty.'); } document.getElementById('btn-clear-p1-queue').addEventListener('click', clearPass1Queue); function clearAllLocalData() { if (!confirm( 'Clear ALL saved data?\n\n' + 'This will wipe:\n' + ' • All Pass 1 and Pass 2 flags\n' + ' • All text corrections\n' + ' • Role setting\n' + ' • Diff legend preference\n\n' + 'This cannot be undone.' )) return; // Clear localStorage localStorage.removeItem(LS_CORRECTIONS_KEY); localStorage.removeItem(LS_FLAGS_KEY); localStorage.removeItem(LS_PASS2_FLAGS_KEY); localStorage.removeItem('p44-role'); // Reset in-memory state state.corrections = {}; state.flags = {}; pass2Flags = {}; // Reset role to Pass 1 applyRole('pass1'); // Clear server-side files (best-effort) fetch(SERVER_CLEAR_ENDPOINT, { method: 'POST' }).catch(() => {}); // Refresh UI updateFlagButtons(state.currentPage); updateFlagsSummary(); updateCorrectionIndicator(state.currentPage); if (state.pdfDoc) showOCRPage(state.currentPage); setStatus('All saved data cleared — fresh start.'); } document.getElementById('btn-clear-all').addEventListener('click', clearAllLocalData); // ───────────────────────────────────────────────────────────────── // LINE NUMBERS — update and scroll-sync with textarea // ───────────────────────────────────────────────────────────────── // Returns a newline-joined string of line number labels that accounts for // soft-wrapped visual rows in the textarea. Wrapped continuation rows are // represented by empty strings so the gutter stays in sync with scroll. function buildWrappedLineNumbers(text, textarea) { const lines = text.split('\n'); if (!lines.length) return ''; // Lazily create a hidden canvas for text measurement (reused across calls). if (!buildWrappedLineNumbers._canvas) { buildWrappedLineNumbers._canvas = document.createElement('canvas'); } const ctx = buildWrappedLineNumbers._canvas.getContext('2d'); const style = window.getComputedStyle(textarea); // Compose the CSS font shorthand that canvas expects ctx.font = style.fontSize + ' ' + style.fontFamily; // Width available for text inside the textarea (exclude horizontal padding). const paddingL = parseFloat(style.paddingLeft) || 0; const paddingR = parseFloat(style.paddingRight) || 0; const availWidth = Math.max(50, textarea.clientWidth - paddingL - paddingR); const result = []; lines.forEach((line, i) => { result.push(String(i + 1)); if (line.length > 0) { // Estimate the number of extra visual rows this line wraps onto. // We measure total line width and floor-divide by the available width. // This is an approximation — word-wrap boundaries differ slightly — // but it keeps the gutter aligned for typical OCR paragraph lengths. const lineWidth = ctx.measureText(line).width; const extraRows = Math.max(0, Math.floor(lineWidth / availWidth)); for (let r = 0; r < extraRows; r++) result.push(''); } }); return result.join('\n'); } function updateLineNumbers() { lineNumbers.textContent = buildWrappedLineNumbers(textEditor.value, textEditor); syncLineNumbers(); } function syncLineNumbers() { lineNumbers.scrollTop = textEditor.scrollTop; } textEditor.addEventListener('input', updateLineNumbers); textEditor.addEventListener('scroll', syncLineNumbers); // ───────────────────────────────────────────────────────────────── // PHASE 5 — INLINE TEXT EDITING // ───────────────────────────────────────────────────────────────── function updateCorrectionIndicator(pageNum) { const corr = state.corrections && state.corrections[pageNum]; const hasEdit = corr && (corr.pass1 || corr.pass2); ocrSyncLabel.style.color = hasEdit ? 'var(--gold)' : 'var(--gold-dim)'; } async function postCorrection(pageNum, corrObj) { const body = JSON.stringify({ page: pageNum, correction: corrObj }); try { const res = await fetch(SERVER_CORRECTIONS_END, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, }); if (res.ok) return; } catch (_) {} try { const stored = JSON.parse(localStorage.getItem(LS_CORRECTIONS_KEY) || '{}'); if (corrObj) { stored[pageNum] = corrObj; } else { delete stored[pageNum]; } localStorage.setItem(LS_CORRECTIONS_KEY, JSON.stringify(stored)); } catch (_) {} } async function loadCorrectionsOnStartup() { let loaded = null; try { const res = await fetch('/' + SERVER_CORRECTIONS_FILE); if (res.ok) loaded = await res.json(); } catch (_) {} if (!loaded) { try { const stored = localStorage.getItem(LS_CORRECTIONS_KEY); if (stored) loaded = JSON.parse(stored); } catch (_) {} } if (loaded) { // Migrate old flat-string corrections to new object format Object.keys(loaded).forEach(k => { if (typeof loaded[k] === 'string') { if (loaded[k].trim()) { loaded[k] = { pass1: loaded[k].trim() }; } else { delete loaded[k]; } } else if (!loaded[k] || (!loaded[k].pass1 && !loaded[k].pass2)) { delete loaded[k]; } }); state.corrections = loaded; try { localStorage.setItem(LS_CORRECTIONS_KEY, JSON.stringify(state.corrections)); } catch (_) {} } } // ───────────────────────────────────────────────────────────────── // PASS 2 REVERT BAR // ───────────────────────────────────────────────────────────────── function showPass2RevertBar(pageNum) { const bar = document.getElementById('pass2-revert-bar'); if (currentRole !== 'pass2') { bar.style.display = 'none'; return; } const corr = state.corrections && state.corrections[pageNum]; if (!corr || !corr.pass1) { bar.style.display = 'none'; return; } const originalText = corr.original || state.ocrPages[pageNum] || ''; const pass1Text = corr.pass1; bar.innerHTML = 'Pass 1 corrections on this page' + '' + ''; bar.style.display = 'flex'; document.getElementById('btn-revert-pass1').addEventListener('click', () => { if (!confirm('Revert to Pass 1 text?\n\nYour Pass 2 changes on this page will be lost.')) return; textEditor.value = pass1Text; setStatus('Reverted to Pass 1 text — navigate away or click Edit to save.'); }); document.getElementById('btn-revert-original').addEventListener('click', () => { const typed = prompt( 'This will discard ALL corrections on this page — both Pass 1 and Pass 2.\n\n' + 'Type REVERT to confirm:' ); if (typed !== 'REVERT') { setStatus('Revert cancelled.'); return; } textEditor.value = originalText; delete state.corrections[pageNum]; postCorrection(pageNum, null); updateCorrectionIndicator(pageNum); bar.style.display = 'none'; setStatus('Reverted to original OCR — all corrections on this page cleared.'); }); } function saveCurrentPage() { const pageNum = parseInt(textEditor.dataset.page, 10); if (!pageNum) return; if (!state.ocrPages[pageNum]) return; // do not save pages outside loaded MD range const editedText = textEditor.value.trim(); const passKey = currentRole === 'pass2' ? 'pass2' : 'pass1'; const originalText = (state.corrections[pageNum] && state.corrections[pageNum].original) || (state.ocrPages[pageNum] || '').trim(); if (!state.corrections[pageNum]) state.corrections[pageNum] = {}; state.corrections[pageNum].original = state.corrections[pageNum].original || originalText; if (editedText && editedText !== originalText) { state.corrections[pageNum][passKey] = editedText; } else { delete state.corrections[pageNum][passKey]; } if (!state.corrections[pageNum].pass1 && !state.corrections[pageNum].pass2) { delete state.corrections[pageNum]; } postCorrection(pageNum, state.corrections[pageNum] || null); updateCorrectionIndicator(pageNum); } // ───────────────────────────────────────────────────────────────── // PHASE 6 — KEYBOARD NAVIGATION // ───────────────────────────────────────────────────────────────── document.addEventListener('keydown', e => { // Skip shortcuts when typing in the page-number input or the editor textarea const tag = document.activeElement && document.activeElement.tagName; if (tag === 'INPUT' && document.activeElement.id === 'page-input') return; if (tag === 'TEXTAREA') return; switch (e.key) { case 'ArrowRight': case 'l': e.preventDefault(); goToPage(state.currentPage + 1); break; case 'ArrowLeft': case 'j': e.preventDefault(); goToPage(state.currentPage - 1); break; case 'v': setFlag('done'); break; case 'p': setFlag('problem'); break; } }); // ───────────────────────────────────────────────────────────────── // HOOK INTO showOCRPage TO APPLY FLAGS/CORRECTIONS/EDIT STATE // ───────────────────────────────────────────────────────────────── // Override goToPage to save edits before navigating function goToPage(n) { saveCurrentPage(); if (!state.pdfDoc) return; n = Math.max(1, Math.min(state.totalPages, n)); if (state.ocrOverlay) showOCROverlay(n); if (n === state.currentPage && !state.rendering) return; if (state.rendering) { _pendingPage = n; return; } state.currentPage = n; renderCurrentPage(); } // ───────────────────────────────────────────────────────────────── // STARTUP — load persistent data // ───────────────────────────────────────────────────────────────── loadPass2FlagsOnStartup(); loadFlagsOnStartup(); loadCorrectionsOnStartup(); // Restore saved role after all DOM refs are ready (function() { const saved = localStorage.getItem('p44-role') || 'pass1'; applyRole(saved); })();