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:
parent
46632a3c6c
commit
eefc8980cc
3 changed files with 104 additions and 19 deletions
|
|
@ -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&¤tMatchIndex<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&¤tMatchIndex<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(){
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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%}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue