Files
AI-Prototype/p44-ocr-viewer.js
2026-05-13 10:03:38 -04:00

1262 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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',
textPath: './Inputs/ocr-output/Calgary-Highlanders_War-Diary_Sep44_olmocr.md',
},
'black-watch': {
pdfPath: './Inputs/RHC-Blackwatch_War-Diary_Sep44.pdf',
textPath: './Inputs/ocr-output/RHC-Blackwatch_War-Diary_Sep44_olmocr.md',
},
'5cib': {
pdfPath: './Inputs/5CIB_War-Diary_Sep44.pdf',
textPath: './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;
const REAL_TEXT_PATH = _diary.textPath;
// ── 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 = 'flags-' + diaryId + '.json';
const SERVER_PASS2_FLAGS_FILE = 'pass2_flags-' + diaryId + '.json';
const SERVER_CORRECTIONS_FILE = '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;
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).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 + '.<br><em>Use the OCR Override picker above.</em>');
}
// 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 + '.<br><em>Use the PDF Override picker above.</em>');
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 =
'<pre style="white-space:pre-wrap;font-family:inherit;font-size:13px;' +
'line-height:1.8;color:var(--text);margin:0;">' +
escH(originalText) + '</pre>';
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 = '✓ <strong>Done</strong> — 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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 `<span class="cs-changed">~ "${escH(c.from)}" → "${escH(c.to)}"</span>`;
if (c.type === 'added') return `<span class="cs-added">+ "${escH(c.word)}"</span>`;
if (c.type === 'deleted') return `<span class="cs-deleted"> "${escH(c.word)}"</span>`;
return '';
}).join('<br>');
panel.innerHTML =
'<div class="cs-title">' + count + ' change' + (count === 1 ? '' : 's') + ' from Pass 1</div>' +
'<div class="cs-list">' + listHtml + (truncated ? '<br><em style="font-size:10px;color:var(--text-muted)">…and more</em>' : '') + '</div>' +
'<div class="cs-actions">' +
'<button class="cs-revert-btn" id="btn-cs-revert-pass1">↺ Revert to Pass 1</button>' +
'<button class="cs-revert-btn danger" id="btn-cs-revert-original">⚠ Revert to original OCR</button>' +
'</div>';
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 =
'<span class="rv-label">Pass 1 corrections on this page</span>' +
'<button class="rv-btn" id="btn-revert-pass1">↺ Revert to Pass 1</button>' +
'<button class="rv-btn danger" id="btn-revert-original">⚠ Revert to original OCR</button>';
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;
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);
})();