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
This commit is contained in:
jedarden 2026-05-31 23:49:27 -04:00
parent 46632a3c6c
commit eefc8980cc
3 changed files with 104 additions and 19 deletions

View file

@ -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&&currentMatchIndex<matchedSpans.length){
matchedSpans[currentMatchIndex].classList.remove('active')
}
// Calculate new index
if(direction>0){
currentMatchIndex=(currentMatchIndex+1)%matchedSpans.length
}else{
currentMatchIndex=(currentMatchIndex-1+matchedSpans.length)%matchedSpans.length
}
highlightCurrentMatch();
updateMatchCount()
}
function highlightCurrentMatch(){
if(currentMatchIndex>=0&&currentMatchIndex<matchedSpans.length){
const span=matchedSpans[currentMatchIndex];
span.classList.add('active');
span.scrollIntoView({behavior:'smooth',block:'center',inline:'center'})
}
}
function updateMatchCount(){
const matchCount=document.getElementById('match-count');
if(matchedSpans.length>0){
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(){

View file

@ -16,7 +16,8 @@
<div class="toolbar">
<button id="btn-prev" class="btn" aria-label="Previous page">← Prev</button>
<button id="btn-next" class="btn" aria-label="Next page">Next →</button>
<input id="search-input" type="search" placeholder="Search (press /)" aria-label="Search" class="search-input">
<input id="search-input" type="search" placeholder="Search spans... (press /)" aria-label="Search spans" class="search-input">
<span id="match-count" class="match-count"></span>
<div class="toggles">
<button class="layer-toggle" data-layer="spans" aria-label="Toggle spans layer">1 Spans</button>
<button class="layer-toggle" data-layer="blocks" aria-label="Toggle blocks layer">2 Blocks</button>

View file

@ -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%}