From eefc8980cc0204f05f1b71fdd2e0aec177a52def Mon Sep 17 00:00:00 2001 From: jedarden Date: Sun, 31 May 2026 23:49:27 -0400 Subject: [PATCH] feat(pdftract-3ka4f): implement per-page span search filter in inspector Added search filter UI that highlights matching spans on the current page: - HTML: added match-count span and updated placeholder text - CSS: added .search-match styling with orange outline and .active state - JS: replaced cross-page API search with per-page span filtering Features: - Case-insensitive substring search over data-text attributes - Orange outline on matching spans, double outline on current match - Match count display (e.g., "3 of 12 matches") - Enter cycles forward through matches, Shift+Enter cycles backward - Escape clears search and blur input - Slash (/) focuses search input - Auto-scrolls current match into view with smooth animation Acceptance criteria: - Typing "foo" highlights all spans containing "foo" - Match count shows "X of Y matches" - Enter/Shift+Enter cycles through matches with viewport scroll - Escape clears search - Slash focuses search input --- .../pdftract-cli/src/inspect/frontend/app.js | 113 +++++++++++++++--- .../src/inspect/frontend/index.html | 3 +- .../src/inspect/frontend/style.css | 7 +- 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/crates/pdftract-cli/src/inspect/frontend/app.js b/crates/pdftract-cli/src/inspect/frontend/app.js index 51b817f..75aaf02 100644 --- a/crates/pdftract-cli/src/inspect/frontend/app.js +++ b/crates/pdftract-cli/src/inspect/frontend/app.js @@ -13,6 +13,8 @@ let pageData=null; let isComparisonMode=false; let pageDiff=null; let scrollSync=true; +let matchedSpans=[]; +let currentMatchIndex=-1; function init(){loadLayerState();setupKeyboard();setupToggles();setupSearch();setupNav();setupComparisonMode();loadFragment()} @@ -282,7 +284,8 @@ function showComparisonMode(show){ function setupKeyboard(){ document.addEventListener('keydown',e=>{ - if(e.target.tagName==='INPUT')return; + const searchInput=document.getElementById('search-input'); + if(e.target.tagName==='INPUT'&&e.target!==searchInput)return; if(e.key==='ArrowLeft'){ e.preventDefault(); navigatePage(-1) @@ -290,35 +293,113 @@ function setupKeyboard(){ e.preventDefault(); navigatePage(1) }else if(e.key==='/'){ - e.preventDefault(); - document.getElementById('search-input').focus() + if(e.target!==searchInput){ + e.preventDefault(); + searchInput.focus() + } }else if(e.key>='1'&&e.key<='9'){ const idx=parseInt(e.key)-1; const layer=LAYERS[idx]; if(layer)toggleLayer(layer) + }else if(e.key==='Escape'&&e.target===searchInput){ + e.preventDefault(); + clearSearch() } }) } function setupSearch(){ const input=document.getElementById('search-input'); - let timeout; - input.addEventListener('input',()=>{ - clearTimeout(timeout); - timeout=setTimeout(performSearch,300) + input.addEventListener('input',performSearch); + input.addEventListener('keydown',e=>{ + if(e.key==='Enter'){ + e.preventDefault(); + if(e.shiftKey){ + cycleMatch(-1) + }else{ + cycleMatch(1) + } + } }) } -async function performSearch(){ - const query=document.getElementById('search-input').value.trim(); - if(!query)return; - const res=await fetch(`/api/search?q=${encodeURIComponent(query)}`); - if(!res.ok)return; - const matches=await res.json(); - if(matches.length>0){ - const match=matches[0]; - if(match.page_index!==currentPage)loadPage(match.page_index) +function performSearch(){ + const query=document.getElementById('search-input').value.trim().toLowerCase(); + const matchCount=document.getElementById('match-count'); + + // Clear previous matches + matchedSpans.forEach(span=>span.classList.remove('search-match','active')); + matchedSpans=[]; + currentMatchIndex=-1; + + if(!query){ + matchCount.textContent=''; + return } + + // Find all spans with matching text on current page + const wrappers=document.querySelectorAll('#page-svg svg, .svg-wrapper svg'); + wrappers.forEach(svg=>{ + svg.querySelectorAll('[data-text]').forEach(span=>{ + const text=(span.dataset.text||'').toLowerCase(); + if(text.includes(query)){ + span.classList.add('search-match'); + matchedSpans.push(span) + } + }) + }); + + // Update match count + if(matchedSpans.length>0){ + matchCount.textContent=`1 of ${matchedSpans.length} matches`; + currentMatchIndex=0; + highlightCurrentMatch() + }else{ + matchCount.textContent='No matches' + } +} + +function cycleMatch(direction){ + if(matchedSpans.length===0)return; + + // Remove active class from current match + if(currentMatchIndex>=0&¤tMatchIndex0){ + currentMatchIndex=(currentMatchIndex+1)%matchedSpans.length + }else{ + currentMatchIndex=(currentMatchIndex-1+matchedSpans.length)%matchedSpans.length + } + + highlightCurrentMatch(); + updateMatchCount() +} + +function highlightCurrentMatch(){ + if(currentMatchIndex>=0&¤tMatchIndex0){ + matchCount.textContent=`${currentMatchIndex+1} of ${matchedSpans.length} matches` + }else{ + matchCount.textContent='' + } +} + +function clearSearch(){ + const input=document.getElementById('search-input'); + input.value=''; + input.blur(); + performSearch() } function setupNav(){ diff --git a/crates/pdftract-cli/src/inspect/frontend/index.html b/crates/pdftract-cli/src/inspect/frontend/index.html index 771b5d0..6fe26a5 100644 --- a/crates/pdftract-cli/src/inspect/frontend/index.html +++ b/crates/pdftract-cli/src/inspect/frontend/index.html @@ -16,7 +16,8 @@
- + +
diff --git a/crates/pdftract-cli/src/inspect/frontend/style.css b/crates/pdftract-cli/src/inspect/frontend/style.css index 58357d8..d4ea86d 100644 --- a/crates/pdftract-cli/src/inspect/frontend/style.css +++ b/crates/pdftract-cli/src/inspect/frontend/style.css @@ -17,6 +17,7 @@ body{font-family:system-ui,-apple-system,sans-serif;font-size:14px;line-height:1 .btn:disabled{opacity:.5;cursor:not-allowed} .search-input{flex:1;max-width:300px;padding:6px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;font-family:inherit} .search-input:focus{outline:none;border-color:#0078d4;box-shadow:0 0 0 2px rgba(0,120,212,.2)} +.match-count{font-size:12px;color:#666;white-space:nowrap;padding:0 8px} .toggles{display:flex;gap:4px;flex-wrap:wrap} .layer-toggle{padding:4px 8px;background:#fff;border:1px solid #ddd;border-radius:3px;cursor:pointer;font-size:11px;font-family:inherit;white-space:nowrap} .layer-toggle:hover{background:#f0f0f0} @@ -28,12 +29,14 @@ body{font-family:system-ui,-apple-system,sans-serif;font-size:14px;line-height:1 .json-tree{flex:1;overflow:auto;padding:12px;font-size:12px;font-family:ui-monospace,monospace;white-space:pre-wrap;word-break:break-all} .loading{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:16px;color:#666} .tooltip{position:absolute;background:rgba(255,255,255,.95);border:1px solid #ccc;padding:6px 10px;font-family:ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,monospace;font-size:12px;pointer-events:none;z-index:1000;max-width:400px;white-space:pre;line-height:1.4} -.layer-spans,.layer-blocks,.layer-columns,.layer-reading-order,.layer-confidence-heatmap,.layer-ocr,.layer-mcid,.layer-anchors,.layer-diff{display:none} -html[data-layers~="spans"] .layer-spans,html[data-layers~="blocks"] .layer-blocks,html[data-layers~="columns"] .layer-columns,html[data-layers~="reading-order"] .layer-reading-order,html[data-layers~="confidence-heatmap"] .layer-confidence-heatmap,html[data-layers~="ocr"] .layer-ocr,html[data-layers~="mcid"] .layer-mcid,html[data-layers~="anchors"] .layer-anchors,html[data-layers~="diff"] .layer-diff{display:block} +.layer-spans,.layer-blocks,.layer-columns,.layer-reading-order,.layer-confidence-heatmap,.layer-ocr,.layer-ocr_regions,.layer-mcid,.layer-anchors,.layer-diff{display:none} +html[data-layers~="spans"] .layer-spans,html[data-layers~="blocks"] .layer-blocks,html[data-layers~="columns"] .layer-columns,html[data-layers~="reading-order"] .layer-reading-order,html[data-layers~="confidence-heatmap"] .layer-confidence-heatmap,html[data-layers~="ocr"] .layer-ocr,html[data-layers~="ocr_regions"] .layer-ocr_regions,html[data-layers~="mcid"] .layer-mcid,html[data-layers~="anchors"] .layer-anchors,html[data-layers~="diff"] .layer-diff{display:block} .tooltip-key{color:#8f8} .tooltip-value{color:#8cf} .tooltip-number{color:#f8c} .search-highlight{background:#ffeb3b;outline:2px solid #ff9800} +.search-match{outline:2px solid #ff9800;outline-offset:2px} +.search-match.active{outline:3px solid #ff6f00;outline-offset:3px;outline-style:double} .search-match-found{animation:highlight-pulse 1s ease-out} @keyframes highlight-pulse{0%{background:#ff9800}100%{background:#ffeb3b}} .compare-container{display:flex;gap:20px;justify-content:center;align-items:flex-start;width:100%;height:100%}