Files
AI-Prototype/p44-ocr-viewer.html

1415 lines
56 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>P44 OCR Viewer — Calgary Highlanders Sep 44</title>
<!-- PDF.js (CDN — needs internet access; served via launch_viewer.py) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<style>
/* ── Reset & base ─────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #0f3460;
--gold: #c8a84b;
--gold-dim: #8a6f2e;
--text: #ddd4bb;
--text-dim: #7a7260;
--highlight: #ffe066;
--border: #2e2e4e;
--toolbar-h: 52px;
--footer-h: 34px;
}
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: 'Georgia', 'Times New Roman', serif;
font-size: 14px;
overflow: hidden;
}
/* ── App shell ────────────────────────────────────────────────── */
#app {
display: grid;
grid-template-rows: var(--toolbar-h) 1fr var(--footer-h);
height: 100vh;
}
/* ── Toolbar ──────────────────────────────────────────────────── */
#toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
background: var(--surface);
border-bottom: 2px solid var(--gold-dim);
overflow: hidden;
flex-wrap: nowrap;
}
#toolbar h1 {
font-size: 13px;
font-weight: normal;
letter-spacing: 0.1em;
color: var(--gold);
white-space: nowrap;
flex-shrink: 0;
}
.divider { width: 1px; height: 26px; background: var(--border); flex-shrink: 0; margin: 0 2px; }
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.toolbar-group .lbl {
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.06em;
white-space: nowrap;
}
.file-btn {
display: inline-block;
padding: 3px 8px;
background: var(--surface2);
border: 1px solid var(--gold-dim);
border-radius: 3px;
color: var(--text);
font-size: 10px;
cursor: pointer;
letter-spacing: 0.03em;
transition: border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.file-btn:hover { border-color: var(--gold); background: #1a2a50; }
.file-btn input[type="file"] { display: none; }
.tb-btn {
padding: 3px 9px;
background: transparent;
border: 1px solid var(--gold-dim);
border-radius: 3px;
color: var(--text-dim);
font-size: 10px;
font-family: inherit;
letter-spacing: 0.05em;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
.tb-btn:hover { border-color: var(--gold); color: var(--gold); background: rgba(200,168,75,0.08); }
#btn-load-ch { border-color: var(--gold); color: var(--gold); font-weight: bold; }
#btn-load-ch:hover { background: rgba(200,168,75,0.15); }
#scale-select {
background: var(--surface2);
border: 1px solid var(--gold-dim);
border-radius: 3px;
color: var(--text);
font-size: 10px;
font-family: inherit;
padding: 2px 4px;
cursor: pointer;
}
/* ── Main panels ──────────────────────────────────────────────── */
#panels {
display: grid;
grid-template-columns: 1fr 1fr;
overflow: hidden;
}
.panel {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--border);
}
.panel:last-child { border-right: none; }
.panel-header {
padding: 5px 10px;
font-size: 9px;
letter-spacing: 0.12em;
color: var(--gold-dim);
text-transform: uppercase;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
}
.panel-header .ph-title { flex: 1; }
/* page navigation */
.page-nav { display: flex; align-items: center; gap: 4px; }
.page-nav button {
background: var(--surface2); border: 1px solid var(--gold-dim);
border-radius: 2px; color: var(--text); font-size: 10px;
padding: 1px 6px; cursor: pointer; line-height: 1.5;
}
.page-nav button:hover { border-color: var(--gold); color: var(--gold); }
.page-nav button:disabled { opacity: 0.3; cursor: default; }
.page-nav input[type="number"] {
width: 42px; text-align: center;
background: var(--surface2); border: 1px solid var(--gold-dim);
border-radius: 2px; color: var(--text); font-size: 9px; padding: 2px 3px;
}
#page-label { font-size: 9px; color: var(--text-dim); white-space: nowrap; }
.panel-body {
flex: 1;
overflow: auto;
position: relative;
}
/* ── Left panel: PDF canvas + overlay ────────────────────────── */
#pdf-container {
position: relative;
display: inline-block;
min-width: 100%;
}
#pdf-canvas {
display: block;
max-width: 100%;
height: auto;
user-select: none;
background: #e8e0cc;
}
#overlay {
position: absolute;
top: 0; left: 0;
pointer-events: none;
}
/* noinspection CssUnusedSymbol */
.word-box {
position: absolute;
border: 1px solid transparent;
border-radius: 1px;
cursor: crosshair;
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);
}
.panel-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: var(--text-dim);
font-size: 13px;
font-style: italic;
padding: 40px;
text-align: center;
}
.panel-placeholder em {
color: var(--gold-dim);
font-size: 11px;
font-style: normal;
letter-spacing: 0.04em;
}
.spinner {
width: 22px; height: 22px;
border: 2px solid var(--surface2);
border-top-color: var(--gold);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Right panel: OCR text ────────────────────────────────────── */
#text-output {
padding: 16px 20px;
font-size: 12.5px;
color: var(--text);
line-height: 1.75;
}
/* olmOCR HTML tables */
#text-output table {
width: 100%;
border-collapse: collapse;
margin: 8px 0 16px;
font-size: 12px;
}
#text-output th, #text-output td {
border: 1px solid var(--border);
padding: 5px 8px;
vertical-align: top;
text-align: left;
}
#text-output th {
background: var(--surface);
color: var(--gold-dim);
font-size: 9px;
letter-spacing: 0.07em;
text-transform: uppercase;
font-weight: normal;
}
#text-output tr:nth-child(even) td { background: rgba(22,33,62,0.4); }
#text-output h2 {
font-size: 12px;
color: var(--gold);
letter-spacing: 0.1em;
margin: 18px 0 6px;
border-bottom: 1px solid var(--border);
padding-bottom: 3px;
}
#text-output h3 { font-size: 11px; color: var(--gold-dim); margin: 10px 0 4px; }
#text-output hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
#text-output p { margin: 4px 0; }
#text-output ol, #text-output ul { padding-left: 20px; margin: 4px 0; }
#text-output li { margin: 2px 0; }
#text-output em { color: var(--text-dim); }
/* ── Footer ───────────────────────────────────────────────────── */
#footer {
display: flex;
align-items: center;
padding: 0 12px;
gap: 10px;
background: var(--surface);
border-top: 1px solid var(--border);
font-size: 10px;
color: var(--text-dim);
}
#status-msg { flex: 1; font-style: italic; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
/* ── 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>
<div id="app">
<!-- ── Toolbar ─────────────────────────────────────────────── -->
<div id="toolbar">
<h1>⚜ P44 OCR VIEWER</h1>
<div class="divider"></div>
<button id="btn-load-ch" class="tb-btn">▶ Calgary Highlanders — Sep 44</button>
<div class="divider"></div>
<div class="toolbar-group">
<span class="lbl">PDF</span>
<label class="file-btn">Override…<input type="file" id="input-pdf" accept=".pdf,application/pdf" /></label>
</div>
<div class="toolbar-group">
<span class="lbl">OCR</span>
<label class="file-btn">Override…<input type="file" id="input-text" accept=".txt,.md" /></label>
</div>
<div class="toolbar-group">
<span class="lbl">JSON</span>
<label class="file-btn">Override…<input type="file" id="input-json" accept=".json" /></label>
</div>
<div class="divider"></div>
<div class="toolbar-group">
<span class="lbl">SCALE</span>
<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>
<option value="1.5">150%</option>
<option value="2.0">200%</option>
</select>
</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 ──────────────────────────────────────────────── -->
<div id="panels">
<!-- Left: PDF / image -->
<div class="panel">
<div class="panel-header">
<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" 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">
War diary scan loads automatically.<br>
<em>Run launch_viewer.py — or click the button above</em>
</div>
</div>
<div id="pdf-container" style="display:none;">
<canvas id="pdf-canvas"></canvas>
<div id="overlay"></div>
</div>
</div>
</div>
<!-- Right: OCR text -->
<div class="panel">
<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">
<div class="spinner" id="txt-spinner" style="display:none;"></div>
<div id="txt-ph-text">
OCR transcript loads automatically.<br>
<em>Run launch_viewer.py — or click the button above</em>
</div>
</div>
<div id="text-output" style="display:none;"></div>
</div>
</div>
</div>
<!-- ── Footer ──────────────────────────────────────────────── -->
<div id="footer">
<span id="status-msg">Ready — run launch_viewer.py to auto-load Calgary Highlanders data.</span>
<span id="flags-summary"></span>
<span id="kbd-hint">← → navigate &nbsp; v/r/e flag &nbsp; esc close</span>
</div>
</div>
<script>
'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';
}
// ── Data paths (only work when served by launch_viewer.py) ───────
const REAL_PDF_PATH = './Inputs/Calgary-Highlanders_War-Diary_Sep44.pdf';
const REAL_TEXT_PATH = './Inputs/ocr-output/Calgary-Highlanders_War-Diary_Sep44_olmocr.md';
// ─────────────────────────────────────────────────────────────────
// SAMPLE DATA (canvas image + plain text + JSON sidecar)
// Used by "Load sample" button only — works without launch_viewer.py
// ─────────────────────────────────────────────────────────────────
function buildSampleImageDataURL() {
const W = 620, H = 440;
const cv = document.createElement('canvas'); cv.width = W; cv.height = H;
const ctx = cv.getContext('2d');
ctx.fillStyle = '#f5eed8'; ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = 'rgba(180,160,110,0.18)';
for (let y = 0; y < H; y += 6) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke(); }
ctx.strokeStyle = '#b8a070'; ctx.lineWidth = 3; ctx.strokeRect(14,14,W-28,H-28);
ctx.lineWidth = 1; ctx.strokeStyle = '#c8b880'; ctx.strokeRect(20,20,W-40,H-40);
ctx.fillStyle = '#2a1a05'; ctx.font = 'bold 18px Georgia,serif';
ctx.fillText('WAR DIARY — CALGARY HIGHLANDERS', 40, 58);
ctx.font = '13px Georgia,serif'; ctx.fillStyle = '#3a2a10';
ctx.fillText('September 1944 — Northwestern Europe', 40, 80);
ctx.strokeStyle = '#b8a070'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(40,92); ctx.lineTo(W-40,92); ctx.stroke();
ctx.font = '14px Georgia,serif'; ctx.fillStyle = '#1a0f00';
[
{y:118, t:'1 Sep 44 Unit moved forward to assembly area near DOUVRES.'},
{y:148, t:' Weather clear. Roads congested with transport.'},
{y:178, t:'2 Sep 44 Advance continued. Contact with enemy at MR 442891.'},
{y:208, t:' Artillery support provided by 5th Field Regt RCA.'},
{y:238, t:'3 Sep 44 Objective CANAL secured by 1400 hrs. Casualties: 3 OR.'},
{y:268, t:' Consolidation complete. Patrols sent to NORTH flank.'},
].forEach(l => ctx.fillText(l.t, 40, l.y));
ctx.save(); ctx.globalAlpha = 0.07; ctx.font = 'bold 52px Georgia,serif';
ctx.fillStyle = '#800000'; ctx.translate(W/2, H/2+30); ctx.rotate(-0.35);
ctx.fillText('CONFIDENTIAL', -120, 0); ctx.restore();
return cv.toDataURL('image/png');
}
const SAMPLE_TEXT =
`WAR DIARY — CALGARY HIGHLANDERS
September 1944 — Northwestern Europe
1 Sep 44 Unit moved forward to assembly area near DOUVRES.
Weather clear. Roads congested with transport.
2 Sep 44 Advance continued. Contact with enemy at MR 442891.
Artillery support provided by 5th Field Regt RCA.
3 Sep 44 Objective CANAL secured by 1400 hrs. Casualties: 3 OR.
Consolidation complete. Patrols sent to NORTH flank.`;
const SAMPLE_JSON = [
{"word":"WAR", "id":"w001","bbox":{"x":40, "y":100,"w":35, "h":18}},
{"word":"DIARY", "id":"w002","bbox":{"x":80, "y":100,"w":46, "h":18}},
{"word":"CALGARY", "id":"w003","bbox":{"x":140, "y":100,"w":62, "h":18}},
{"word":"HIGHLANDERS", "id":"w004","bbox":{"x":208, "y":100,"w":97, "h":18}},
{"word":"September", "id":"w005","bbox":{"x":40, "y":131,"w":73, "h":16}},
{"word":"1944", "id":"w006","bbox":{"x":118, "y":131,"w":38, "h":16}},
{"word":"Northwestern","id":"w007","bbox":{"x":164, "y":131,"w":98, "h":16}},
{"word":"Europe", "id":"w008","bbox":{"x":267, "y":131,"w":55, "h":16}},
{"word":"1", "id":"w009","bbox":{"x":40, "y":160,"w":10, "h":16}},
{"word":"Sep", "id":"w010","bbox":{"x":54, "y":160,"w":28, "h":16}},
{"word":"44", "id":"w011","bbox":{"x":86, "y":160,"w":20, "h":16}},
{"word":"Unit", "id":"w012","bbox":{"x":120, "y":160,"w":28, "h":16}},
{"word":"moved", "id":"w013","bbox":{"x":152, "y":160,"w":42, "h":16}},
{"word":"forward", "id":"w014","bbox":{"x":198, "y":160,"w":56, "h":16}},
{"word":"to", "id":"w015","bbox":{"x":258, "y":160,"w":16, "h":16}},
{"word":"assembly", "id":"w016","bbox":{"x":278, "y":160,"w":62, "h":16}},
{"word":"area", "id":"w017","bbox":{"x":344, "y":160,"w":32, "h":16}},
{"word":"near", "id":"w018","bbox":{"x":380, "y":160,"w":32, "h":16}},
{"word":"DOUVRES.", "id":"w019","bbox":{"x":416, "y":160,"w":70, "h":16}},
{"word":"Weather", "id":"w020","bbox":{"x":120, "y":188,"w":56, "h":16}},
{"word":"clear.", "id":"w021","bbox":{"x":180, "y":188,"w":40, "h":16}},
{"word":"Roads", "id":"w022","bbox":{"x":224, "y":188,"w":40, "h":16}},
{"word":"congested", "id":"w023","bbox":{"x":268, "y":188,"w":72, "h":16}},
{"word":"with", "id":"w024","bbox":{"x":344, "y":188,"w":30, "h":16}},
{"word":"transport.", "id":"w025","bbox":{"x":378, "y":188,"w":80, "h":16}},
{"word":"2", "id":"w026","bbox":{"x":40, "y":218,"w":10, "h":16}},
{"word":"Sep", "id":"w027","bbox":{"x":54, "y":218,"w":28, "h":16}},
{"word":"44", "id":"w028","bbox":{"x":86, "y":218,"w":20, "h":16}},
{"word":"Advance", "id":"w029","bbox":{"x":120, "y":218,"w":56, "h":16}},
{"word":"continued.", "id":"w030","bbox":{"x":180, "y":218,"w":72, "h":16}},
{"word":"Contact", "id":"w031","bbox":{"x":256, "y":218,"w":55, "h":16}},
{"word":"with", "id":"w032","bbox":{"x":315, "y":218,"w":30, "h":16}},
{"word":"enemy", "id":"w033","bbox":{"x":349, "y":218,"w":44, "h":16}},
{"word":"at", "id":"w034","bbox":{"x":397, "y":218,"w":16, "h":16}},
{"word":"MR", "id":"w035","bbox":{"x":417, "y":218,"w":22, "h":16}},
{"word":"442891.", "id":"w036","bbox":{"x":443, "y":218,"w":58, "h":16}},
{"word":"Artillery", "id":"w037","bbox":{"x":120, "y":248,"w":60, "h":16}},
{"word":"support", "id":"w038","bbox":{"x":184, "y":248,"w":52, "h":16}},
{"word":"provided", "id":"w039","bbox":{"x":240, "y":248,"w":60, "h":16}},
{"word":"by", "id":"w040","bbox":{"x":304, "y":248,"w":18, "h":16}},
{"word":"5th", "id":"w041","bbox":{"x":326, "y":248,"w":26, "h":16}},
{"word":"Field", "id":"w042","bbox":{"x":356, "y":248,"w":36, "h":16}},
{"word":"Regt", "id":"w043","bbox":{"x":396, "y":248,"w":32, "h":16}},
{"word":"RCA.", "id":"w044","bbox":{"x":432, "y":248,"w":34, "h":16}},
{"word":"3", "id":"w045","bbox":{"x":40, "y":278,"w":10, "h":16}},
{"word":"Sep", "id":"w046","bbox":{"x":54, "y":278,"w":28, "h":16}},
{"word":"44", "id":"w047","bbox":{"x":86, "y":278,"w":20, "h":16}},
{"word":"Objective", "id":"w048","bbox":{"x":120, "y":278,"w":70, "h":16}},
{"word":"CANAL", "id":"w049","bbox":{"x":194, "y":278,"w":46, "h":16}},
{"word":"secured", "id":"w050","bbox":{"x":244, "y":278,"w":52, "h":16}},
{"word":"by", "id":"w051","bbox":{"x":300, "y":278,"w":18, "h":16}},
{"word":"1400", "id":"w052","bbox":{"x":322, "y":278,"w":34, "h":16}},
{"word":"hrs.", "id":"w053","bbox":{"x":360, "y":278,"w":28, "h":16}},
{"word":"Casualties:", "id":"w054","bbox":{"x":392, "y":278,"w":76, "h":16}},
{"word":"3", "id":"w055","bbox":{"x":472, "y":278,"w":10, "h":16}},
{"word":"OR.", "id":"w056","bbox":{"x":486, "y":278,"w":26, "h":16}},
{"word":"Consolidation","id":"w057","bbox":{"x":120,"y":308,"w":94, "h":16}},
{"word":"complete.", "id":"w058","bbox":{"x":218, "y":308,"w":68, "h":16}},
{"word":"Patrols", "id":"w059","bbox":{"x":290, "y":308,"w":52, "h":16}},
{"word":"sent", "id":"w060","bbox":{"x":346, "y":308,"w":34, "h":16}},
{"word":"to", "id":"w061","bbox":{"x":384, "y":308,"w":16, "h":16}},
{"word":"NORTH", "id":"w062","bbox":{"x":404, "y":308,"w":46, "h":16}},
{"word":"flank.", "id":"w063","bbox":{"x":454, "y":308,"w":40, "h":16}},
];
// ─────────────────────────────────────────────────────────────────
// STATE
// ─────────────────────────────────────────────────────────────────
const state = {
mode: 'none', // 'pdf' | 'image' | 'none'
pdfDoc: null,
currentPage: 1,
totalPages: 0,
scale: 1.0,
wordData: [],
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?
};
// ─────────────────────────────────────────────────────────────────
// DOM REFS
// ─────────────────────────────────────────────────────────────────
const btnLoadCH = document.getElementById('btn-load-ch');
const btnExample = document.getElementById('btn-example');
const inputPdf = document.getElementById('input-pdf');
const inputText = document.getElementById('input-text');
const inputJson = document.getElementById('input-json');
const scaleSelect = document.getElementById('scale-select');
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 textOutput = document.getElementById('text-output');
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 — 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
// ─────────────────────────────────────────────────────────────────
btnLoadCH.addEventListener('click', loadCalgaryHighlanders);
btnExample.addEventListener('click', loadSampleData);
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);
});
scaleSelect.addEventListener('change', () => {
state.scale = parseFloat(scaleSelect.value);
if (state.mode === 'pdf' && state.pdfDoc) renderCurrentPage();
});
btnPrev.addEventListener('click', () => goToPage(state.currentPage - 1));
btnNext.addEventListener('click', () => 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();
});
// ── Auto-load when served from localhost ─────────────────────────
window.addEventListener('DOMContentLoaded', () => {
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
setTimeout(loadCalgaryHighlanders, 200);
}
});
// ─────────────────────────────────────────────────────────────────
// LOAD: CALGARY HIGHLANDERS (real data via launch_viewer.py)
// ─────────────────────────────────────────────────────────────────
async function loadCalgaryHighlanders() {
if (typeof pdfjsLib === 'undefined') {
setStatus('PDF.js not loaded — check internet, then refresh.', true);
return;
}
setStatus('Fetching Calgary Highlanders data…');
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>Run launch_viewer.py first, then refresh.</em>'
);
setStatus('Could not load OCR text — run launch_viewer.py', true);
}
// PDF
showImgSpinner(false);
if (pdfRes.status === 'fulfilled') {
await loadPDFBuffer(pdfRes.value);
const textOK = textRes.status === 'fulfilled';
setStatus(
'Calgary Highlanders Sep 44 loaded — ' +
state.totalPages + '-page PDF' +
(textOK ? ' + OCR text' : '') + '. Use ◀▶ to page through the diary.'
);
} else {
showImgPlaceholder(
'Could not load PDF: ' + pdfRes.reason + '.<br>' +
'<em>Run launch_viewer.py first, or use the PDF Override button.</em>'
);
if (textRes.status === 'fulfilled') {
setStatus('OCR text loaded. PDF unavailable — run launch_viewer.py', true);
}
}
}
// ─────────────────────────────────────────────────────────────────
// LOAD: SAMPLE DATA (works without server)
// ─────────────────────────────────────────────────────────────────
function loadSampleData() {
state.mode = 'image';
state.wordData = SAMPLE_JSON; // set BEFORE any rendering so buildIdQueue sees the data
const img = new Image();
img.onload = () => {
state.imageNaturalW = img.naturalWidth;
state.imageNaturalH = img.naturalHeight;
pdfCanvas.width = img.naturalWidth;
pdfCanvas.height = img.naturalHeight;
pdfCanvas.style.width = img.naturalWidth + 'px';
pdfCanvas.style.height = img.naturalHeight + 'px';
pdfCanvas.getContext('2d').drawImage(img, 0, 0);
pdfContainer.style.display = 'inline-block';
imagePlaceholder.style.display = 'none';
pageLabel.textContent = '/ 1'; btnPrev.disabled = true; btnNext.disabled = true;
// 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 = buildSampleImageDataURL();
}
// ─────────────────────────────────────────────────────────────────
// 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 to panel width × user scale
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.scale });
pdfCanvas.width = vp.width;
pdfCanvas.height = vp.height;
pdfCanvas.style.width = vp.width + 'px';
pdfCanvas.style.height = vp.height + 'px';
await page.render({ canvasContext: pdfCanvas.getContext('2d'), viewport: vp }).promise;
await buildPDFWordOverlay(page, vp);
pageInput.value = state.currentPage;
btnPrev.disabled = state.currentPage <= 1;
btnNext.disabled = state.currentPage >= state.totalPages;
// 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();
}
}
}
}
// Build word-box overlay from PDF.js text content
async function buildPDFWordOverlay(page, vp) {
overlay.innerHTML = '';
overlay.style.width = vp.width + 'px';
overlay.style.height = vp.height + 'px';
// 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;
state.wordData.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)
);
});
}
// 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) {
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';
textOutput.style.display = 'block';
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);
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 {
// 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');
const out = [];
let i = 0;
while (i < lines.length) {
const raw = lines[i];
const trimmed = raw.trim();
if (/^## /.test(raw)) { out.push('<h2>' + escH(raw.slice(3)) + '</h2>'); i++; continue; }
if (/^### /.test(raw)) { out.push('<h3>' + escH(raw.slice(4)) + '</h3>'); i++; continue; }
if (/^---+$/.test(trimmed)) { out.push('<hr>'); i++; continue; }
// Inline HTML block — pass through as-is until a non-HTML line or page heading
if (/^</.test(trimmed)) {
let block = '';
while (i < lines.length) {
const l = lines[i];
if (/^## /.test(l) || /^---+$/.test(l.trim())) break;
block += l + '\n';
i++;
}
out.push(block);
continue;
}
if (trimmed === '') { out.push('<p>&nbsp;</p>'); i++; continue; }
out.push('<p>' + inlineMd(raw) + '</p>');
i++;
}
return out.join('\n');
}
function inlineMd(s) {
return escH(s)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>');
}
// ─────────────────────────────────────────────────────────────────
// WORD DATA (JSON SIDECAR)
// ─────────────────────────────────────────────────────────────────
function loadWordDataJSON(data) {
state.wordData = data;
// PDF mode: re-render so buildPDFWordOverlay picks up the new boxes
if (state.mode === 'pdf' && state.pdfDoc) {
renderCurrentPage();
return;
}
// Image mode: re-render overlay & re-link text spans (unchanged behaviour)
if (state.mode === 'image' && pdfCanvas.width) {
const sx = pdfCanvas.width / state.imageNaturalW;
const sy = pdfCanvas.height / state.imageNaturalH;
overlay.innerHTML = '';
overlay.style.width = pdfCanvas.width + 'px';
overlay.style.height = pdfCanvas.height + 'px';
data.forEach(e => overlay.appendChild(
makeWordBox(e.id, e.bbox.x*sx, e.bbox.y*sy, e.bbox.w*sx, e.bbox.h*sy, e.word)
));
}
}
// ─────────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────────
function escH(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function showImgSpinner(on) {
imgSpinner.style.display = on ? 'inline-block' : 'none';
imgPhText.style.display = on ? 'none' : 'block';
imagePlaceholder.style.display = 'flex';
if (on) pdfContainer.style.display = 'none';
}
function showTxtSpinner(on) {
txtSpinner.style.display = on ? 'inline-block' : 'none';
txtPhText.style.display = on ? 'none' : 'block';
textPlaceholder.style.display = 'flex';
if (on) textOutput.style.display = 'none';
}
function showImgPlaceholder(html) {
imgPhText.innerHTML = html;
imagePlaceholder.style.display = 'flex';
pdfContainer.style.display = 'none';
}
function showTxtPlaceholder(html) {
txtPhText.innerHTML = html;
textPlaceholder.style.display = 'flex';
textOutput.style.display = 'none';
}
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>