@@ -409,7 +460,8 @@ html, body {
@@ -550,6 +602,12 @@ const state = {
imageNaturalW: 1,
imageNaturalH: 1,
rendering: false,
+ ocrPages: {}, // { pageNum: rawMarkdownString } — parsed from olmOCR output
+ ocrRawFull: '', // stored for plain-text (sample) mode
+ lastRenderTime: 0, // timestamp (ms) of the last completed render — used for debounce
+ flags: {}, // { pageNum: 'verified'|'review'|'error' }
+ corrections: {}, // { pageNum: correctedText }
+ editMode: false, // is the text panel currently editable?
};
// ─────────────────────────────────────────────────────────────────
@@ -580,7 +638,27 @@ 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');
-const wordTip = document.getElementById('word-tip');
+
+// Phase 2 — sync scroll
+const btnSyncScroll = document.getElementById('btn-sync-scroll');
+const imagePanelBody = document.getElementById('image-panel-body');
+const textPanelBody = document.getElementById('text-panel-body');
+
+// Phase 3 — zoom
+const zoomLens = document.getElementById('zoom-lens');
+const zoomCanvas = document.getElementById('zoom-canvas');
+const zoomClose = document.getElementById('zoom-close');
+
+// Phase 4 — flags
+const btnFlagVerified = document.getElementById('btn-flag-verified');
+const btnFlagReview = document.getElementById('btn-flag-review');
+const btnFlagError = document.getElementById('btn-flag-error');
+const btnFlagClear = document.getElementById('btn-flag-clear');
+const flagsSummary = document.getElementById('flags-summary');
+
+// Phase 5 — edit
+const btnEdit = document.getElementById('btn-edit');
+const correctionDot = document.getElementById('correction-dot');
// ─────────────────────────────────────────────────────────────────
// TOOLBAR BUTTON EVENTS
@@ -693,8 +771,7 @@ async function loadCalgaryHighlanders() {
// ─────────────────────────────────────────────────────────────────
function loadSampleData() {
state.mode = 'image';
- state.wordData = [];
- const dataURL = buildSampleImageDataURL();
+ state.wordData = SAMPLE_JSON; // set BEFORE any rendering so buildIdQueue sees the data
const img = new Image();
img.onload = () => {
state.imageNaturalW = img.naturalWidth;
@@ -707,11 +784,22 @@ function loadSampleData() {
pdfContainer.style.display = 'inline-block';
imagePlaceholder.style.display = 'none';
pageLabel.textContent = '/ 1'; btnPrev.disabled = true; btnNext.disabled = true;
- loadWordDataJSON(SAMPLE_JSON); // triggers overlay render
+
+ // Build overlay boxes first
+ 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';
+ SAMPLE_JSON.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))
+ );
+
+ // Render OCR text AFTER word data is in place — ensures IDs get assigned to spans
+ loadOCRText(SAMPLE_TEXT, false);
+ setStatus('Sample data loaded — hover any word on either side to see bidirectional highlighting.');
};
- img.src = dataURL;
- loadOCRText(SAMPLE_TEXT, false);
- setStatus('Sample data loaded — hover words to see bidirectional link highlighting.');
+ img.src = buildSampleImageDataURL();
}
// ─────────────────────────────────────────────────────────────────
@@ -725,7 +813,8 @@ async function loadPDFBuffer(arrayBuffer) {
state.currentPage = 1;
state.wordData = []; // clear JSON sidecar — use PDF text layer
pageLabel.textContent = '/ ' + state.totalPages;
- pageInput.value = 1;
+ // 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';
@@ -738,6 +827,9 @@ async function loadPDFBuffer(arrayBuffer) {
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);
@@ -760,9 +852,26 @@ async function renderCurrentPage() {
btnPrev.disabled = state.currentPage <= 1;
btnNext.disabled = state.currentPage >= state.totalPages;
- syncOCRToPage(state.currentPage);
+ // Sync right panel — wrapped so an OCR render failure never blocks PDF
+ 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
} 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();
+ }
+ }
}
}
@@ -772,7 +881,18 @@ async function buildPDFWordOverlay(page, vp) {
overlay.style.width = vp.width + 'px';
overlay.style.height = vp.height + 'px';
- // If a JSON sidecar is loaded (image mode), use it instead
+ // JSON sidecar in PDF mode — coords are PDF points (bottom-left origin)
+ if (state.mode === 'pdf' && 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, cy, cw, ch, e.word));
+ });
+ return;
+ }
+
+ // JSON sidecar in image mode — coords are image pixels scaled to canvas
if (state.mode === 'image' && state.wordData.length) {
const sx = vp.width / state.imageNaturalW;
const sy = vp.height / state.imageNaturalH;
@@ -781,42 +901,10 @@ async function buildPDFWordOverlay(page, vp) {
makeWordBox(e.id, e.bbox.x*sx, e.bbox.y*sy, e.bbox.w*sx, e.bbox.h*sy, e.word)
);
});
- return;
}
- // PDF text layer → word boxes
- const textContent = await page.getTextContent();
- let counter = 0;
-
- textContent.items.forEach(item => {
- const str = (item.str || '').trim();
- if (!str) return;
-
- // item.transform = [a, b, c, d, tx, ty] PDF user-space coords (bottom-left origin)
- const transform = item.transform;
- const fontH = Math.abs(transform[3]); // approximate glyph height in PDF units
- const tx = transform[4], ty = transform[5];
-
- // Convert bottom-left PDF point → canvas top-left
- const [cx, cy] = vp.convertToViewportPoint(tx, ty);
- const scaledH = fontH * vp.scale * 1.15;
- const scaledW = (item.width || 0) * vp.scale;
-
- // Split multi-word runs into individual word boxes
- const words = str.split(/\s+/);
- const perCharW = scaledW / Math.max(str.replace(/\s/g,'').length, 1);
- let xCursor = cx;
-
- words.forEach(word => {
- if (!word) return;
- const wW = word.length * perCharW;
- const id = 'pf' + String(++counter).padStart(5, '0');
- overlay.appendChild(
- makeWordBox(id, xCursor, cy - scaledH, wW, scaledH, word)
- );
- xCursor += wW + perCharW; // approx space
- });
- });
+ // No JSON sidecar — overlay intentionally left blank (PDFs are scanned images
+ // with no embedded text layer; getTextContent() returns only shell labels).
}
function makeWordBox(id, x, y, w, h, title) {
@@ -828,28 +916,12 @@ function makeWordBox(id, x, y, w, h, title) {
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');
- const span = textOutput.querySelector('.word-span[data-word-id="' + id + '"]');
- if (span) { span.classList.add('highlight'); scrollIfNeeded(span, document.getElementById('text-panel-body')); }
- wordTip.textContent = title ? '▸ ' + title : '';
- });
- div.addEventListener('mouseleave', () => {
- div.classList.remove('highlight');
- const span = textOutput.querySelector('.word-span[data-word-id="' + id + '"]');
- if (span) span.classList.remove('highlight');
- wordTip.textContent = '';
- });
+ div.addEventListener('mouseenter', () => { div.classList.add('highlight'); });
+ div.addEventListener('mouseleave', () => { div.classList.remove('highlight'); });
return div;
}
-function goToPage(n) {
- if (!state.pdfDoc) return;
- n = Math.max(1, Math.min(state.totalPages, n));
- if (n === state.currentPage) return;
- state.currentPage = n;
- renderCurrentPage();
-}
+let _pendingPage = null; // holds the last page requested while a render was in progress
// ─────────────────────────────────────────────────────────────────
// OCR TEXT RENDERING
@@ -859,13 +931,92 @@ function loadOCRText(rawText, isHtmlMarkdown) {
textOutput.style.display = 'block';
if (isHtmlMarkdown) {
- textOutput.innerHTML = convertMarkdown(rawText);
- addHoverSpansToTextNodes(textOutput);
+ // 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);
+ state.ocrRawFull = '';
+ // 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 {
- renderPlainTextWithLinks(rawText);
+ // Plain-text / sample mode — render as preformatted text
+ state.ocrPages = {};
+ state.ocrRawFull = rawText;
+ textOutput.innerHTML = '
' + escH(rawText) + '
';
}
}
+// ── 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 = [line]; // keep the heading at the top
+ } else if (pageNum !== null) {
+ buf.push(line);
+ }
+ }
+ if (pageNum !== null) pages[pageNum] = buf.join('\n');
+ return pages;
+}
+
+// ── Render the OCR panel for a single page ────────────────────────────────
+function showOCRPage(pageNum) {
+ // Always make the text panel visible (placeholder hidden) so navigation
+ // reliably switches panels even when called from renderCurrentPage.
+ textPlaceholder.style.display = 'none';
+ textOutput.style.display = 'block';
+
+ if (!Object.keys(state.ocrPages).length) {
+ // OCR not loaded yet — show a polite notice and await the fetch
+ textOutput.innerHTML =
+ '
' +
+ 'OCR text not loaded yet. Use the ▶ Calgary Highlanders button or the OCR Override picker.
';
+ ocrSyncLabel.textContent = '';
+ return;
+ }
+
+ const content = state.ocrPages[pageNum];
+ if (!content) {
+ textOutput.innerHTML =
+ '
' +
+ 'No OCR text for page ' + pageNum + '.
' +
+ 'Pages with no diary entry (blank forms, maps) have no OCR content.
';
+ ocrSyncLabel.textContent = 'page ' + pageNum + ' — no data';
+ return;
+ }
+
+ textOutput.innerHTML = convertMarkdown(content);
+
+ // Apply saved correction if one exists for this page
+ if (state.corrections && state.corrections[pageNum]) {
+ textOutput.textContent = state.corrections[pageNum];
+ }
+
+ document.getElementById('text-panel-body').scrollTop = 0;
+ ocrSyncLabel.textContent = 'page ' + pageNum;
+
+ // Phase 4/5 UI updates
+ updateFlagButtons(pageNum);
+ updateCorrectionIndicator(pageNum);
+}
+
// Lightweight markdown + inline-HTML renderer (handles olmOCR .md format)
function convertMarkdown(md) {
const lines = md.split('\n');
@@ -905,94 +1056,19 @@ function inlineMd(s) {
.replace(/\*(.+?)\*/g, '
$1');
}
-// Wrap text-node words in
for hover
-function addHoverSpansToTextNodes(root) {
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
- acceptNode(node) {
- const tag = node.parentElement && node.parentElement.tagName;
- return ['TD','P','LI','STRONG','EM'].includes(tag)
- ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
- }
- });
- const nodes = [];
- let n;
- while ((n = walker.nextNode())) nodes.push(n);
-
- nodes.forEach(node => {
- const frag = document.createDocumentFragment();
- node.textContent.split(/(\s+)/).forEach(tok => {
- if (!tok) return;
- if (/^\s+$/.test(tok)) { frag.appendChild(document.createTextNode(tok)); return; }
- const span = document.createElement('span');
- span.className = 'word-span';
- span.textContent = tok;
- span.addEventListener('mouseenter', () => {
- span.style.background = 'var(--highlight)'; span.style.color = '#1a1a2e';
- wordTip.textContent = '▸ ' + tok;
- });
- span.addEventListener('mouseleave', () => {
- span.style.background = ''; span.style.color = '';
- wordTip.textContent = '';
- });
- frag.appendChild(span);
- });
- node.parentElement.replaceChild(frag, node);
- });
-}
-
-// Plain-text mode with exact word-id linking (sample data)
-function renderPlainTextWithLinks(rawText) {
- const queue = buildIdQueue(state.wordData);
- let html = '';
- rawText.split(/(\s+)/).forEach(tok => {
- if (/^\s+$/.test(tok)) { html += escH(tok); return; }
- if (!tok) return;
- const id = dequeueId(queue, normalise(tok));
- if (id) {
- html += '' + escH(tok) + '';
- } else {
- html += '' + escH(tok) + '';
- }
- });
- textOutput.innerHTML = html;
-
- textOutput.querySelectorAll('.word-span[data-word-id]').forEach(span => {
- span.addEventListener('mouseenter', () => {
- span.classList.add('highlight');
- const box = overlay.querySelector('.word-box[data-word-id="' + span.dataset.wordId + '"]');
- if (box) { box.classList.add('highlight'); scrollIfNeeded(box, document.getElementById('image-panel-body')); }
- wordTip.textContent = '▸ ' + (span.title || span.textContent);
- });
- span.addEventListener('mouseleave', () => {
- span.classList.remove('highlight');
- const box = overlay.querySelector('.word-box[data-word-id="' + span.dataset.wordId + '"]');
- if (box) box.classList.remove('highlight');
- wordTip.textContent = '';
- });
- });
-}
-
-// Scroll the OCR text panel to the matching page heading
-function syncOCRToPage(pdfPageNum) {
- if (textOutput.style.display === 'none') return;
- const headings = textOutput.querySelectorAll('h2');
- for (const h of headings) {
- const m = h.textContent.match(/Page\s+(\d+)/i);
- if (m && parseInt(m[1], 10) === pdfPageNum) {
- h.scrollIntoView({ behavior: 'smooth', block: 'start' });
- ocrSyncLabel.textContent = 'synced → page ' + pdfPageNum;
- return;
- }
- }
- ocrSyncLabel.textContent = '';
-}
-
// ─────────────────────────────────────────────────────────────────
// WORD DATA (JSON SIDECAR)
// ─────────────────────────────────────────────────────────────────
function loadWordDataJSON(data) {
state.wordData = data;
- // In image mode: re-render overlay & re-link text spans
+
+ // 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;
@@ -1008,19 +1084,7 @@ function loadWordDataJSON(data) {
// ─────────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────────
-function buildIdQueue(wordData) {
- const m = {};
- wordData.forEach(e => { const k = normalise(e.word); if (!m[k]) m[k] = []; m[k].push(e.id); });
- return m;
-}
-function dequeueId(m, k) { return (m[k] && m[k].length) ? m[k].shift() : null; }
-function normalise(s) { return s.replace(/[.,;:!?'")(—–\-]+$/g,'').toLowerCase(); }
function escH(s) { return s.replace(/&/g,'&').replace(//g,'>'); }
-function escA(s) { return s.replace(/"/g,'"'); }
-function scrollIfNeeded(el, container) {
- const er = el.getBoundingClientRect(), cr = container.getBoundingClientRect();
- if (er.top < cr.top || er.bottom > cr.bottom) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
-}
function showImgSpinner(on) {
imgSpinner.style.display = on ? 'inline-block' : 'none';
imgPhText.style.display = on ? 'none' : 'block';
@@ -1047,8 +1111,304 @@ function setStatus(msg, warn) {
statusMsg.textContent = msg;
statusMsg.style.color = warn ? '#e07070' : 'var(--text-dim)';
}
+
+// ─────────────────────────────────────────────────────────────────
+// PHASE 2 — SYNCHRONIZED SCROLLING
+// ─────────────────────────────────────────────────────────────────
+let _syncScrolling = false;
+let _syncActive = true;
+
+function onImgScroll() {
+ if (!_syncActive || _syncScrolling) return;
+ _syncScrolling = true;
+ const pct = imagePanelBody.scrollTop /
+ Math.max(1, imagePanelBody.scrollHeight - imagePanelBody.clientHeight);
+ textPanelBody.scrollTop = pct *
+ Math.max(1, textPanelBody.scrollHeight - textPanelBody.clientHeight);
+ requestAnimationFrame(() => { _syncScrolling = false; });
+}
+function onTxtScroll() {
+ if (!_syncActive || _syncScrolling) return;
+ _syncScrolling = true;
+ const pct = textPanelBody.scrollTop /
+ Math.max(1, textPanelBody.scrollHeight - textPanelBody.clientHeight);
+ imagePanelBody.scrollTop = pct *
+ Math.max(1, imagePanelBody.scrollHeight - imagePanelBody.clientHeight);
+ requestAnimationFrame(() => { _syncScrolling = false; });
+}
+
+imagePanelBody.addEventListener('scroll', onImgScroll);
+textPanelBody.addEventListener('scroll', onTxtScroll);
+
+btnSyncScroll.addEventListener('click', () => {
+ _syncActive = !_syncActive;
+ btnSyncScroll.classList.toggle('active', _syncActive);
+ setStatus('Synchronized scrolling ' + (_syncActive ? 'on' : 'off') + '.');
+});
+
+// ─────────────────────────────────────────────────────────────────
+// PHASE 3 — CLICK-TO-ZOOM ON LEFT PANEL
+// ─────────────────────────────────────────────────────────────────
+let _zoomClickX = -1, _zoomClickY = -1;
+
+function positionZoomLens() {
+ const panelRect = imagePanelBody.getBoundingClientRect();
+ zoomLens.style.top = (panelRect.top + 8) + 'px';
+ zoomLens.style.right = (window.innerWidth - panelRect.right + 8) + 'px';
+ // 'left' must be unset so 'right' takes effect
+ zoomLens.style.left = 'auto';
+}
+
+pdfCanvas.addEventListener('click', e => {
+ const rect = pdfCanvas.getBoundingClientRect();
+ const cx = (e.clientX - rect.left) * (pdfCanvas.width / rect.width);
+ const cy = (e.clientY - rect.top) * (pdfCanvas.height / rect.height);
+
+ // Second click on same spot dismisses
+ if (zoomLens.style.display !== 'none' &&
+ Math.abs(cx - _zoomClickX) < 10 && Math.abs(cy - _zoomClickY) < 10) {
+ zoomLens.style.display = 'none';
+ return;
+ }
+
+ _zoomClickX = cx; _zoomClickY = cy;
+ positionZoomLens();
+ drawZoom(cx, cy);
+ zoomLens.style.display = 'block';
+});
+
+// Keep lens anchored to panel corner if user scrolls or resizes
+imagePanelBody.addEventListener('scroll', () => {
+ if (zoomLens.style.display !== 'none') positionZoomLens();
+});
+window.addEventListener('resize', () => {
+ if (zoomLens.style.display !== 'none') positionZoomLens();
+});
+
+zoomLens.addEventListener('click', () => { zoomLens.style.display = 'none'; });
+zoomClose.addEventListener('click', e => { e.stopPropagation(); zoomLens.style.display = 'none'; });
+
+function drawZoom(cx, cy) {
+ const CROP = 200; // fixed 200×200 source-pixel region, scaled 3× into 280×280 lens
+ const lensW = 280, lensH = 280;
+ zoomCanvas.width = lensW;
+ zoomCanvas.height = lensH;
+
+ // Clamp crop region inside canvas bounds
+ const sx = Math.max(0, Math.min(cx - CROP / 2, pdfCanvas.width - CROP));
+ const sy = Math.max(0, Math.min(cy - CROP / 2, pdfCanvas.height - CROP));
+
+ zoomCanvas.getContext('2d').drawImage(pdfCanvas, sx, sy, CROP, CROP, 0, 0, lensW, lensH);
+}
+
+// ─────────────────────────────────────────────────────────────────
+// PHASE 4 — FLAG / ANNOTATION SYSTEM
+// ─────────────────────────────────────────────────────────────────
+function updateFlagButtons(pageNum) {
+ const flag = state.flags[pageNum] || null;
+ btnFlagVerified.classList.toggle('active', flag === 'verified');
+ btnFlagReview.classList.toggle('active', flag === 'review');
+ btnFlagError.classList.toggle('active', flag === 'error');
+}
+
+function updateFlagsSummary() {
+ let v = 0, r = 0, er = 0;
+ Object.values(state.flags).forEach(f => {
+ if (f === 'verified') v++;
+ else if (f === 'review') r++;
+ else if (f === 'error') er++;
+ });
+ flagsSummary.textContent = v || r || er
+ ? '✓ ' + v + ' ⚑ ' + r + ' ✗ ' + er
+ : '';
+}
+
+async function saveFlags() {
+ try {
+ const res = await fetch('/save-flags', {
+ 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('p44-flags', JSON.stringify(state.flags)); } catch (_) {}
+}
+
+function setFlag(type) {
+ const p = state.currentPage;
+ if (type === null) {
+ delete state.flags[p];
+ } else {
+ state.flags[p] = type;
+ }
+ updateFlagButtons(p);
+ updateFlagsSummary();
+ saveFlags();
+}
+
+btnFlagVerified.addEventListener('click', () => setFlag('verified'));
+btnFlagReview.addEventListener('click', () => setFlag('review'));
+btnFlagError.addEventListener('click', () => setFlag('error'));
+btnFlagClear.addEventListener('click', () => setFlag(null));
+
+async function loadFlagsOnStartup() {
+ try {
+ const res = await fetch('/flags.json');
+ if (res.ok) {
+ state.flags = await res.json();
+ updateFlagsSummary();
+ return;
+ }
+ } catch (_) {}
+ // Fallback: localStorage
+ try {
+ const stored = localStorage.getItem('p44-flags');
+ if (stored) { state.flags = JSON.parse(stored); updateFlagsSummary(); }
+ } catch (_) {}
+}
+
+// ─────────────────────────────────────────────────────────────────
+// PHASE 5 — INLINE TEXT EDITING
+// ─────────────────────────────────────────────────────────────────
+function updateCorrectionIndicator(pageNum) {
+ correctionDot.style.display = state.corrections[pageNum] ? 'inline-block' : 'none';
+}
+
+function saveEditedPage() {
+ if (!state.editMode) return;
+ const pageNum = state.currentPage;
+ // Strip HTML tags to get plain text correction
+ const plain = textOutput.innerText || textOutput.textContent;
+ if (plain.trim()) {
+ state.corrections[pageNum] = plain;
+ } else {
+ delete state.corrections[pageNum];
+ }
+ postCorrection(pageNum, plain);
+ updateCorrectionIndicator(pageNum);
+}
+
+async function postCorrection(pageNum, text) {
+ try {
+ const res = await fetch('/save-corrections', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ page: pageNum, text }),
+ });
+ if (res.ok) return;
+ } catch (_) {}
+ try {
+ const stored = JSON.parse(localStorage.getItem('p44-corrections') || '{}');
+ stored[pageNum] = text;
+ localStorage.setItem('p44-corrections', JSON.stringify(stored));
+ } catch (_) {}
+}
+
+async function loadCorrectionsOnStartup() {
+ try {
+ const res = await fetch('/corrections.json');
+ if (res.ok) { state.corrections = await res.json(); return; }
+ } catch (_) {}
+ try {
+ const stored = localStorage.getItem('p44-corrections');
+ if (stored) state.corrections = JSON.parse(stored);
+ } catch (_) {}
+}
+
+function enterEditMode() {
+ state.editMode = true;
+ textOutput.contentEditable = 'true';
+ textOutput.focus();
+ btnEdit.textContent = 'Editing';
+ btnEdit.classList.add('active');
+}
+
+function exitEditMode() {
+ if (!state.editMode) return;
+ saveEditedPage();
+ state.editMode = false;
+ textOutput.contentEditable = 'false';
+ btnEdit.textContent = 'Edit';
+ btnEdit.classList.remove('active');
+}
+
+btnEdit.addEventListener('click', () => {
+ if (state.editMode) exitEditMode();
+ else enterEditMode();
+});
+
+// ─────────────────────────────────────────────────────────────────
+// PHASE 6 — KEYBOARD NAVIGATION
+// ─────────────────────────────────────────────────────────────────
+document.addEventListener('keydown', e => {
+ // Do not intercept keys while user is typing in the text panel or page-number input
+ if (state.editMode) return;
+ const tag = document.activeElement && document.activeElement.tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement === textOutput) 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('verified'); break;
+ case 'r':
+ setFlag('review'); break;
+ case 'e':
+ setFlag('error'); break;
+ case 'Escape':
+ if (zoomLens.style.display !== 'none') { zoomLens.style.display = 'none'; }
+ else if (state.editMode) { exitEditMode(); }
+ break;
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────
+// HOOK INTO showOCRPage TO APPLY FLAGS/CORRECTIONS/EDIT STATE
+// ─────────────────────────────────────────────────────────────────
+// Override goToPage to save edits before navigating
+function goToPage(n) {
+ if (state.editMode) exitEditMode();
+ if (!state.pdfDoc) return;
+ n = Math.max(1, Math.min(state.totalPages, n));
+ if (n === state.currentPage && !state.rendering) return;
+ if (state.rendering) { _pendingPage = n; return; }
+ state.currentPage = n;
+ renderCurrentPage();
+}
+
+// ─────────────────────────────────────────────────────────────────
+// STARTUP — load persistent data
+// ─────────────────────────────────────────────────────────────────
+loadFlagsOnStartup();
+loadCorrectionsOnStartup();
+