removed highlighting text. Added in magnifying words left side, sync scrolling, keyboard shortcuts, editing text etc

This commit is contained in:
nathan
2026-05-11 10:24:18 -04:00
parent ba297d6430
commit 7b059390bd
3 changed files with 753 additions and 191 deletions

View File

@@ -195,6 +195,7 @@ html, body {
pointer-events: none;
}
/* noinspection CssUnusedSymbol */
.word-box {
position: absolute;
border: 1px solid transparent;
@@ -203,7 +204,9 @@ html, body {
pointer-events: all;
transition: background 0.07s, border-color 0.07s;
}
/* noinspection CssUnusedSymbol */
.word-box:hover,
/* noinspection CssUnusedSymbol */
.word-box.highlight {
background: rgba(255, 224, 102, 0.38);
border-color: var(--highlight);
@@ -284,20 +287,6 @@ html, body {
#text-output li { margin: 2px 0; }
#text-output em { color: var(--text-dim); }
/* word spans */
#text-output .word-span {
display: inline;
border-radius: 2px;
padding: 0 1px;
cursor: pointer;
transition: background 0.07s;
}
#text-output .word-span:hover,
#text-output .word-span.highlight {
background: var(--highlight);
color: #1a1a2e;
}
/* ── Footer ───────────────────────────────────────────────────── */
#footer {
display: flex;
@@ -310,13 +299,63 @@ html, body {
color: var(--text-dim);
}
#status-msg { flex: 1; font-style: italic; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
#word-tip { color: var(--gold-dim); letter-spacing: 0.04em; white-space: nowrap; }
/* ── Scrollbars ───────────────────────────────────────────────── */
::-webkit-scrollbar { width: 7px; height: 7px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-thumb { background: var(--surface2); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--gold-dim); }
/* ── Active toggle button state (sync-scroll, edit mode) ─────── */
.tb-btn.active { border-color: var(--gold); color: var(--gold); }
/* ── Zoom lens ────────────────────────────────────────────────── */
#zoom-lens {
position: fixed;
width: 280px; height: 280px;
background: var(--surface);
border: 2px solid var(--gold);
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
z-index: 100;
overflow: hidden;
display: none;
}
#zoom-close {
position: absolute;
top: 4px; right: 4px;
color: var(--gold);
font-size: 12px;
cursor: pointer;
z-index: 101;
line-height: 1;
background: var(--surface2);
border: 1px solid var(--gold-dim);
border-radius: 2px;
padding: 2px 6px;
}
#zoom-close:hover { color: var(--gold); border-color: var(--gold); }
#zoom-canvas { display: block; width: 100%; height: 100%; }
/* ── Flag buttons ─────────────────────────────────────────────── */
.flag-btn { font-size: 9px; padding: 2px 7px; }
.flag-btn.active { border-color: var(--gold); color: var(--gold); }
#correction-dot {
display: none;
width: 6px; height: 6px;
border-radius: 50%;
background: var(--gold);
flex-shrink: 0;
}
/* ── Footer extras ────────────────────────────────────────────── */
#flags-summary { color: var(--gold-dim); letter-spacing: 0.04em; white-space: nowrap; font-size: 10px; }
#kbd-hint { color: var(--text-dim); font-size: 9px; white-space: nowrap; opacity: 0.7; }
/* ── Edit mode text panel ─────────────────────────────────────── */
#text-output[contenteditable="true"] {
outline: 1px solid var(--gold-dim);
caret-color: var(--gold);
}
</style>
</head>
<body>
@@ -346,7 +385,7 @@ html, body {
<div class="toolbar-group">
<span class="lbl">SCALE</span>
<select id="scale-select">
<select id="scale-select" aria-label="Zoom scale">
<option value="0.75">75%</option>
<option value="1.0" selected>100%</option>
<option value="1.25">125%</option>
@@ -356,6 +395,8 @@ html, body {
</div>
<div class="divider"></div>
<button id="btn-example" class="tb-btn">Load sample</button>
<div class="divider"></div>
<button id="btn-sync-scroll" class="tb-btn active">Sync Scroll</button>
</div>
<!-- ── Panels ──────────────────────────────────────────────── -->
@@ -367,12 +408,16 @@ html, body {
<span class="ph-title">Source Document — Page Scan</span>
<div class="page-nav">
<button id="btn-prev" disabled></button>
<input type="number" id="page-input" value="1" min="1" />
<input type="number" id="page-input" value="1" min="1" aria-label="Page number" />
<button id="btn-next" disabled></button>
<span id="page-label">/ —</span>
</div>
</div>
<div class="panel-body" id="image-panel-body">
<div id="zoom-lens">
<button id="zoom-close"></button>
<canvas id="zoom-canvas"></canvas>
</div>
<div class="panel-placeholder" id="image-placeholder">
<div class="spinner" id="img-spinner" style="display:none;"></div>
<div id="img-ph-text">
@@ -392,6 +437,12 @@ html, body {
<div class="panel-header">
<span class="ph-title">OCR Text Output</span>
<span id="ocr-sync-label" style="font-size:9px;color:var(--gold-dim);"></span>
<div id="correction-dot" title="This page has been edited"></div>
<button id="btn-flag-verified" class="tb-btn flag-btn" title="Mark page as verified">✓ Verified</button>
<button id="btn-flag-review" class="tb-btn flag-btn" title="Mark page for review">⚑ Review</button>
<button id="btn-flag-error" class="tb-btn flag-btn" title="Mark page as having an error">✗ Error</button>
<button id="btn-flag-clear" class="tb-btn flag-btn" title="Clear flag">Clear</button>
<button id="btn-edit" class="tb-btn" title="Toggle edit mode">Edit</button>
</div>
<div class="panel-body" id="text-panel-body">
<div class="panel-placeholder" id="text-placeholder">
@@ -409,7 +460,8 @@ html, body {
<!-- ── Footer ──────────────────────────────────────────────── -->
<div id="footer">
<span id="status-msg">Ready — run launch_viewer.py to auto-load Calgary Highlanders data.</span>
<span id="word-tip"></span>
<span id="flags-summary"></span>
<span id="kbd-hint">← → navigate &nbsp; v/r/e flag &nbsp; esc close</span>
</div>
</div>
@@ -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 = '<pre style="white-space:pre-wrap;font-family:inherit;font-size:12.5px;line-height:1.75;color:var(--text);">' + escH(rawText) + '</pre>';
}
}
// ── 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 =
'<p style="padding:24px;color:var(--text-dim);font-style:italic;text-align:center;">' +
'OCR text not loaded yet. Use the ▶ Calgary Highlanders button or the OCR Override picker.</p>';
ocrSyncLabel.textContent = '';
return;
}
const content = state.ocrPages[pageNum];
if (!content) {
textOutput.innerHTML =
'<p style="padding:24px;color:var(--text-dim);font-style:italic;text-align:center;">' +
'No OCR text for page ' + pageNum + '.<br>' +
'<small style="font-size:10px;">Pages with no diary entry (blank forms, maps) have no OCR content.</small></p>';
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, '<em>$1</em>');
}
// Wrap text-node words in <span class="word-span"> 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 += '<span class="word-span" data-word-id="' + id + '" title="' + escA(tok) + '">' + escH(tok) + '</span>';
} else {
html += '<span class="word-span">' + escH(tok) + '</span>';
}
});
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function escA(s) { return s.replace(/"/g,'&quot;'); }
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();
</script>
</body>
</html>