@@ -195,6 +195,7 @@ html, body {
pointer-events : none ;
}
/* noinspection CssUnusedSymbol */
. word-box {
position : absolute ;
border : 1 px solid transparent ;
@@ -203,7 +204,9 @@ html, body {
pointer-events : all ;
transition : background 0.07 s , border-color 0.07 s ;
}
/* 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 : 2 px 0 ; }
# text-output em { color : var ( - - text - dim ) ; }
/* word spans */
# text-output . word-span {
display : inline ;
border-radius : 2 px ;
padding : 0 1 px ;
cursor : pointer ;
transition : background 0.07 s ;
}
# 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.04 em ; white-space : nowrap ; }
/* ── Scrollbars ───────────────────────────────────────────────── */
:: -webkit-scrollbar { width : 7 px ; height : 7 px ; }
:: -webkit-scrollbar-track { background : var ( - - bg ) ; }
:: -webkit-scrollbar-thumb { background : var ( - - surface2 ) ; border-radius : 4 px ; }
:: -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 : 280 px ; height : 280 px ;
background : var ( - - surface ) ;
border : 2 px solid var ( - - gold ) ;
border-radius : 4 px ;
box-shadow : 0 4 px 16 px rgba ( 0 , 0 , 0 , 0.6 ) ;
z-index : 100 ;
overflow : hidden ;
display : none ;
}
# zoom-close {
position : absolute ;
top : 4 px ; right : 4 px ;
color : var ( - - gold ) ;
font-size : 12 px ;
cursor : pointer ;
z-index : 101 ;
line-height : 1 ;
background : var ( - - surface2 ) ;
border : 1 px solid var ( - - gold - dim ) ;
border-radius : 2 px ;
padding : 2 px 6 px ;
}
# zoom-close : hover { color : var ( - - gold ) ; border-color : var ( - - gold ) ; }
# zoom-canvas { display : block ; width : 100 % ; height : 100 % ; }
/* ── Flag buttons ─────────────────────────────────────────────── */
. flag-btn { font-size : 9 px ; padding : 2 px 7 px ; }
. flag-btn . active { border-color : var ( - - gold ) ; color : var ( - - gold ) ; }
# correction-dot {
display : none ;
width : 6 px ; height : 6 px ;
border-radius : 50 % ;
background : var ( - - gold ) ;
flex-shrink : 0 ;
}
/* ── Footer extras ────────────────────────────────────────────── */
# flags-summary { color : var ( - - gold - dim ) ; letter-spacing : 0.04 em ; white-space : nowrap ; font-size : 10 px ; }
# kbd-hint { color : var ( - - text - dim ) ; font-size : 9 px ; white-space : nowrap ; opacity : 0.7 ; }
/* ── Edit mode text panel ─────────────────────────────────────── */
# text-output [ contenteditable = "true" ] {
outline : 1 px 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 v/r/e flag 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 box es
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 imag es
// 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 {
render PlainT extWithLinks ( rawText ) ;
// Plain-t ext / 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 , '&' ) . 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 ( ) ;
< / script >
< / body >
< / html >