1262 lines
51 KiB
JavaScript
1262 lines
51 KiB
JavaScript
'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,'&').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 `<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);
|
||
})();
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|