'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 + '.
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('