feat(propaganda): add PDF text search with highlighting, match navigation, Ctrl+F shortcut
This commit is contained in:
parent
bb2438a997
commit
b0a6362e68
2 changed files with 485 additions and 2 deletions
|
|
@ -622,6 +622,154 @@
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Page Container (canvas + text layer) ───────────── */
|
||||||
|
.prop-page-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-page-container canvas {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Text Layer (transparent overlay for selection/search) */
|
||||||
|
.prop-text-layer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0.25;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-text-layer > span {
|
||||||
|
position: absolute;
|
||||||
|
color: transparent;
|
||||||
|
white-space: pre;
|
||||||
|
pointer-events: all;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-text-layer > span::selection {
|
||||||
|
background: rgba(0, 204, 51, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Search Bar ─────────────────────────────────────── */
|
||||||
|
.prop-search-bar {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(17, 17, 17, 0.98);
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
transition: max-height 0.3s ease, border-color 0.3s ease, padding 0.3s ease;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-bar.active {
|
||||||
|
max-height: 60px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
background: rgba(5, 5, 5, 0.9);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-input:focus {
|
||||||
|
border-color: var(--status-green);
|
||||||
|
box-shadow: 0 0 6px rgba(0, 204, 51, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-btn {
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
background: rgba(30, 30, 30, 0.8);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-btn:hover {
|
||||||
|
border-color: var(--status-green);
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 204, 51, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-btn.active {
|
||||||
|
border-color: var(--status-green);
|
||||||
|
color: var(--status-green);
|
||||||
|
background: rgba(0, 204, 51, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-close {
|
||||||
|
color: var(--mil-red);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-close:hover {
|
||||||
|
border-color: var(--mil-red);
|
||||||
|
background: rgba(139, 0, 0, 0.15);
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-info {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
min-width: 70px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-info.no-match {
|
||||||
|
color: var(--mil-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Search Highlights ──────────────────────────────── */
|
||||||
|
.prop-search-hl {
|
||||||
|
background: rgba(0, 204, 51, 0.3) !important;
|
||||||
|
color: transparent !important;
|
||||||
|
border-radius: 1px;
|
||||||
|
box-shadow: 0 0 4px rgba(0, 204, 51, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-search-hl-active {
|
||||||
|
background: rgba(255, 170, 0, 0.5) !important;
|
||||||
|
box-shadow: 0 0 8px rgba(255, 170, 0, 0.6), 0 0 2px rgba(255, 170, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ─── Classification Banner ──────────────────────────── */
|
/* ─── Classification Banner ──────────────────────────── */
|
||||||
.prop-classification {
|
.prop-classification {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -717,6 +865,14 @@
|
||||||
.prop-breadcrumb {
|
.prop-breadcrumb {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
.prop-search-input {
|
||||||
|
min-width: 80px;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
.prop-search-info {
|
||||||
|
min-width: 50px;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1400px) {
|
@media (min-width: 1400px) {
|
||||||
|
|
|
||||||
331
js/propaganda.js
331
js/propaganda.js
|
|
@ -327,9 +327,20 @@
|
||||||
<button class="prop-viewer-btn" id="pdfZoomOut" title="Zoom out">−</button>
|
<button class="prop-viewer-btn" id="pdfZoomOut" title="Zoom out">−</button>
|
||||||
<button class="prop-viewer-btn" id="pdfZoomIn" title="Zoom in">+</button>
|
<button class="prop-viewer-btn" id="pdfZoomIn" title="Zoom in">+</button>
|
||||||
<button class="prop-viewer-btn" id="pdfFit" title="Fit width">FIT</button>
|
<button class="prop-viewer-btn" id="pdfFit" title="Fit width">FIT</button>
|
||||||
|
<button class="prop-viewer-btn" id="pdfSearchToggle" title="Search (Ctrl+F)">🔍 FIND</button>
|
||||||
<a class="prop-viewer-download" href="${pdfUrl}" target="_blank" download>⬇ DOWNLOAD</a>
|
<a class="prop-viewer-download" href="${pdfUrl}" target="_blank" download>⬇ DOWNLOAD</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="prop-search-bar" id="pdfSearchBar">
|
||||||
|
<div class="prop-search-inner">
|
||||||
|
<input type="text" class="prop-search-input" id="pdfSearchInput" placeholder="SEARCH DOCUMENT..." autocomplete="off" spellcheck="false">
|
||||||
|
<button class="prop-search-btn" id="pdfSearchCaseBtn" title="Case sensitive">Aa</button>
|
||||||
|
<span class="prop-search-info" id="pdfSearchInfo"></span>
|
||||||
|
<button class="prop-search-btn" id="pdfSearchPrev" title="Previous match (Shift+Enter)">▲</button>
|
||||||
|
<button class="prop-search-btn" id="pdfSearchNext" title="Next match (Enter)">▼</button>
|
||||||
|
<button class="prop-search-btn prop-search-close" id="pdfSearchClose" title="Close search (Esc)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="prop-viewer-canvas-wrap" id="pdfCanvasWrap">
|
<div class="prop-viewer-canvas-wrap" id="pdfCanvasWrap">
|
||||||
<div class="prop-viewer-loading" id="pdfLoading">DECRYPTING DOCUMENT...</div>
|
<div class="prop-viewer-loading" id="pdfLoading">DECRYPTING DOCUMENT...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -358,6 +369,13 @@
|
||||||
let pdfScale = 1.5;
|
let pdfScale = 1.5;
|
||||||
let pdfRendering = false;
|
let pdfRendering = false;
|
||||||
let pdfPending = null;
|
let pdfPending = null;
|
||||||
|
let pdfAllText = [];
|
||||||
|
let pdfSearchActive = false;
|
||||||
|
let pdfSearchQuery = '';
|
||||||
|
let pdfSearchCaseSensitive = false;
|
||||||
|
let pdfSearchMatches = [];
|
||||||
|
let pdfSearchCurrentIdx = -1;
|
||||||
|
|
||||||
|
|
||||||
async function initPdfViewer(url) {
|
async function initPdfViewer(url) {
|
||||||
const canvasWrap = document.getElementById('pdfCanvasWrap');
|
const canvasWrap = document.getElementById('pdfCanvasWrap');
|
||||||
|
|
@ -382,10 +400,14 @@
|
||||||
|
|
||||||
loadingEl.remove();
|
loadingEl.remove();
|
||||||
|
|
||||||
// Create canvas
|
// Create page container with canvas
|
||||||
|
const pageContainer = document.createElement('div');
|
||||||
|
pageContainer.id = 'pdfPageContainer';
|
||||||
|
pageContainer.className = 'prop-page-container';
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.id = 'pdfCanvas';
|
canvas.id = 'pdfCanvas';
|
||||||
canvasWrap.appendChild(canvas);
|
pageContainer.appendChild(canvas);
|
||||||
|
canvasWrap.appendChild(pageContainer);
|
||||||
|
|
||||||
updatePageInfo();
|
updatePageInfo();
|
||||||
renderPdfPage();
|
renderPdfPage();
|
||||||
|
|
@ -413,6 +435,12 @@
|
||||||
pdfScale = 1.5;
|
pdfScale = 1.5;
|
||||||
queueRender();
|
queueRender();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Extract all page text for search
|
||||||
|
extractAllText();
|
||||||
|
|
||||||
|
// Initialize search controls
|
||||||
|
initSearchControls();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('PDF load failed:', e);
|
console.error('PDF load failed:', e);
|
||||||
canvasWrap.innerHTML = `<div class="prop-viewer-error">
|
canvasWrap.innerHTML = `<div class="prop-viewer-error">
|
||||||
|
|
@ -442,7 +470,21 @@
|
||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
|
|
||||||
|
// Update page container size to match viewport
|
||||||
|
const pageContainer = document.getElementById('pdfPageContainer');
|
||||||
|
if (pageContainer) {
|
||||||
|
pageContainer.style.width = viewport.width + 'px';
|
||||||
|
pageContainer.style.height = viewport.height + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
|
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
|
||||||
|
|
||||||
|
// Render text layer for search and text selection
|
||||||
|
await renderPageTextLayer(page, viewport);
|
||||||
|
|
||||||
|
// Highlight search matches on this page
|
||||||
|
highlightCurrentPageMatches();
|
||||||
|
|
||||||
pdfRendering = false;
|
pdfRendering = false;
|
||||||
updatePageInfo();
|
updatePageInfo();
|
||||||
|
|
||||||
|
|
@ -460,6 +502,285 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Text Layer Rendering ────────────────────────────
|
||||||
|
async function renderPageTextLayer(page, viewport) {
|
||||||
|
const existing = document.getElementById('pdfTextLayer');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const pageContainer = document.getElementById('pdfPageContainer');
|
||||||
|
if (!pageContainer) return;
|
||||||
|
|
||||||
|
const textContent = await page.getTextContent();
|
||||||
|
const textLayerDiv = document.createElement('div');
|
||||||
|
textLayerDiv.id = 'pdfTextLayer';
|
||||||
|
textLayerDiv.className = 'prop-text-layer';
|
||||||
|
textLayerDiv.style.width = viewport.width + 'px';
|
||||||
|
textLayerDiv.style.height = viewport.height + 'px';
|
||||||
|
|
||||||
|
// Try pdf.js built-in renderTextLayer, fallback to manual
|
||||||
|
if (typeof pdfjsLib.renderTextLayer === 'function') {
|
||||||
|
try {
|
||||||
|
const task = pdfjsLib.renderTextLayer({
|
||||||
|
textContentSource: textContent,
|
||||||
|
container: textLayerDiv,
|
||||||
|
viewport: viewport,
|
||||||
|
});
|
||||||
|
await task.promise;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('renderTextLayer failed, using fallback:', err);
|
||||||
|
buildTextLayerFallback(textContent, viewport, textLayerDiv);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buildTextLayerFallback(textContent, viewport, textLayerDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag each span with its item index for search highlighting
|
||||||
|
let itemIdx = 0;
|
||||||
|
for (const child of textLayerDiv.childNodes) {
|
||||||
|
if (child.nodeName === 'SPAN') {
|
||||||
|
child.dataset.itemIdx = itemIdx;
|
||||||
|
itemIdx++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pageContainer.appendChild(textLayerDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTextLayerFallback(textContent, viewport, container) {
|
||||||
|
textContent.items.forEach((item, idx) => {
|
||||||
|
if (!item.str) return;
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = item.str;
|
||||||
|
const tx = item.transform;
|
||||||
|
const fontHeight = Math.sqrt(tx[2] * tx[2] + tx[3] * tx[3]);
|
||||||
|
const s = viewport.scale;
|
||||||
|
span.style.fontSize = (fontHeight * s) + 'px';
|
||||||
|
span.style.fontFamily = 'sans-serif';
|
||||||
|
span.style.left = (tx[4] * s) + 'px';
|
||||||
|
span.style.top = (viewport.height - tx[5] * s - fontHeight * s) + 'px';
|
||||||
|
span.style.position = 'absolute';
|
||||||
|
span.style.color = 'transparent';
|
||||||
|
span.style.whiteSpace = 'pre';
|
||||||
|
container.appendChild(span);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Text Extraction for Search ──────────────────────
|
||||||
|
async function extractAllText() {
|
||||||
|
pdfAllText = [];
|
||||||
|
if (!pdfDoc) return;
|
||||||
|
for (let i = 1; i <= pdfDoc.numPages; i++) {
|
||||||
|
const page = await pdfDoc.getPage(i);
|
||||||
|
const tc = await page.getTextContent();
|
||||||
|
let fullText = '';
|
||||||
|
const itemOffsets = [];
|
||||||
|
tc.items.forEach((item, idx) => {
|
||||||
|
const start = fullText.length;
|
||||||
|
fullText += item.str;
|
||||||
|
itemOffsets.push({ start: start, end: fullText.length, itemIdx: idx });
|
||||||
|
if (item.hasEOL) fullText += '\n';
|
||||||
|
});
|
||||||
|
pdfAllText.push({ pageNum: i, text: fullText, items: tc.items, itemOffsets: itemOffsets });
|
||||||
|
}
|
||||||
|
console.log('PROPAGANDA: Extracted text from ' + pdfAllText.length + ' pages');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Search Controls Init ────────────────────────────
|
||||||
|
function initSearchControls() {
|
||||||
|
const searchInput = document.getElementById('pdfSearchInput');
|
||||||
|
const searchPrev = document.getElementById('pdfSearchPrev');
|
||||||
|
const searchNext = document.getElementById('pdfSearchNext');
|
||||||
|
const searchClose = document.getElementById('pdfSearchClose');
|
||||||
|
const searchToggle = document.getElementById('pdfSearchToggle');
|
||||||
|
const searchCaseBtn = document.getElementById('pdfSearchCaseBtn');
|
||||||
|
if (!searchInput) return;
|
||||||
|
|
||||||
|
searchToggle.addEventListener('click', () => toggleSearchBar());
|
||||||
|
searchClose.addEventListener('click', () => closeSearchBar());
|
||||||
|
|
||||||
|
let searchTimeout = null;
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
pdfSearchQuery = searchInput.value;
|
||||||
|
performSearch();
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
searchInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) prevMatch(); else nextMatch();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeSearchBar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
searchPrev.addEventListener('click', () => prevMatch());
|
||||||
|
searchNext.addEventListener('click', () => nextMatch());
|
||||||
|
|
||||||
|
searchCaseBtn.addEventListener('click', () => {
|
||||||
|
pdfSearchCaseSensitive = !pdfSearchCaseSensitive;
|
||||||
|
searchCaseBtn.classList.toggle('active', pdfSearchCaseSensitive);
|
||||||
|
performSearch();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ctrl+F shortcut — only when PDF viewer is present
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||||
|
if (document.getElementById('pdfViewerContainer')) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSearchBar(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSearchBar(forceOpen) {
|
||||||
|
const searchBar = document.getElementById('pdfSearchBar');
|
||||||
|
const searchInput = document.getElementById('pdfSearchInput');
|
||||||
|
if (!searchBar) return;
|
||||||
|
if (forceOpen || !pdfSearchActive) {
|
||||||
|
searchBar.classList.add('active');
|
||||||
|
pdfSearchActive = true;
|
||||||
|
setTimeout(() => searchInput && searchInput.focus(), 50);
|
||||||
|
} else {
|
||||||
|
closeSearchBar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearchBar() {
|
||||||
|
const searchBar = document.getElementById('pdfSearchBar');
|
||||||
|
if (!searchBar) return;
|
||||||
|
searchBar.classList.remove('active');
|
||||||
|
pdfSearchActive = false;
|
||||||
|
pdfSearchQuery = '';
|
||||||
|
pdfSearchMatches = [];
|
||||||
|
pdfSearchCurrentIdx = -1;
|
||||||
|
const searchInput = document.getElementById('pdfSearchInput');
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
updateSearchInfo();
|
||||||
|
clearHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
function performSearch() {
|
||||||
|
pdfSearchMatches = [];
|
||||||
|
pdfSearchCurrentIdx = -1;
|
||||||
|
const query = pdfSearchQuery.trim();
|
||||||
|
if (!query || query.length === 0) {
|
||||||
|
updateSearchInfo();
|
||||||
|
clearHighlights();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cs = pdfSearchCaseSensitive;
|
||||||
|
const q = cs ? query : query.toLowerCase();
|
||||||
|
pdfAllText.forEach(pageData => {
|
||||||
|
const text = cs ? pageData.text : pageData.text.toLowerCase();
|
||||||
|
let pos = 0;
|
||||||
|
while ((pos = text.indexOf(q, pos)) !== -1) {
|
||||||
|
const matchStart = pos;
|
||||||
|
const matchEnd = pos + q.length;
|
||||||
|
const affectedItems = pageData.itemOffsets.filter(
|
||||||
|
io => io.end > matchStart && io.start < matchEnd
|
||||||
|
);
|
||||||
|
pdfSearchMatches.push({
|
||||||
|
page: pageData.pageNum,
|
||||||
|
charStart: matchStart,
|
||||||
|
charEnd: matchEnd,
|
||||||
|
itemIndices: affectedItems.map(io => io.itemIdx)
|
||||||
|
});
|
||||||
|
pos += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pdfSearchCurrentIdx = pdfSearchMatches.length > 0 ? 0 : -1;
|
||||||
|
updateSearchInfo();
|
||||||
|
if (pdfSearchMatches.length > 0) {
|
||||||
|
goToMatch(0);
|
||||||
|
} else {
|
||||||
|
clearHighlights();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToMatch(idx) {
|
||||||
|
if (idx < 0 || idx >= pdfSearchMatches.length) return;
|
||||||
|
pdfSearchCurrentIdx = idx;
|
||||||
|
const match = pdfSearchMatches[idx];
|
||||||
|
if (match.page !== pdfPage) {
|
||||||
|
pdfPage = match.page;
|
||||||
|
queueRender();
|
||||||
|
} else {
|
||||||
|
highlightCurrentPageMatches();
|
||||||
|
}
|
||||||
|
updateSearchInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMatch() {
|
||||||
|
if (pdfSearchMatches.length === 0) return;
|
||||||
|
goToMatch((pdfSearchCurrentIdx + 1) % pdfSearchMatches.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevMatch() {
|
||||||
|
if (pdfSearchMatches.length === 0) return;
|
||||||
|
goToMatch((pdfSearchCurrentIdx - 1 + pdfSearchMatches.length) % pdfSearchMatches.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightCurrentPageMatches() {
|
||||||
|
clearHighlights();
|
||||||
|
const textLayer = document.getElementById('pdfTextLayer');
|
||||||
|
if (!textLayer) return;
|
||||||
|
const pageMatches = pdfSearchMatches.filter(m => m.page === pdfPage);
|
||||||
|
if (pageMatches.length === 0) return;
|
||||||
|
|
||||||
|
pageMatches.forEach(match => {
|
||||||
|
const globalIdx = pdfSearchMatches.indexOf(match);
|
||||||
|
const isActive = globalIdx === pdfSearchCurrentIdx;
|
||||||
|
match.itemIndices.forEach(itemIdx => {
|
||||||
|
const span = textLayer.querySelector('span[data-item-idx="' + itemIdx + '"]');
|
||||||
|
if (span) {
|
||||||
|
span.classList.add('prop-search-hl');
|
||||||
|
if (isActive) {
|
||||||
|
span.classList.add('prop-search-hl-active');
|
||||||
|
const canvasWrap = document.getElementById('pdfCanvasWrap');
|
||||||
|
if (canvasWrap) {
|
||||||
|
const sr = span.getBoundingClientRect();
|
||||||
|
const wr = canvasWrap.getBoundingClientRect();
|
||||||
|
const offset = sr.top - wr.top - wr.height / 3;
|
||||||
|
canvasWrap.scrollBy({ top: offset, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHighlights() {
|
||||||
|
const tl = document.getElementById('pdfTextLayer');
|
||||||
|
if (!tl) return;
|
||||||
|
tl.querySelectorAll('.prop-search-hl').forEach(el => {
|
||||||
|
el.classList.remove('prop-search-hl', 'prop-search-hl-active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSearchInfo() {
|
||||||
|
const info = document.getElementById('pdfSearchInfo');
|
||||||
|
if (!info) return;
|
||||||
|
if (pdfSearchMatches.length === 0) {
|
||||||
|
if (pdfSearchQuery && pdfSearchQuery.trim().length > 0) {
|
||||||
|
info.textContent = 'NO MATCHES';
|
||||||
|
info.className = 'prop-search-info no-match';
|
||||||
|
} else {
|
||||||
|
info.textContent = '';
|
||||||
|
info.className = 'prop-search-info';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info.textContent = (pdfSearchCurrentIdx + 1) + ' OF ' + pdfSearchMatches.length;
|
||||||
|
info.className = 'prop-search-info';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ─── Not Found / Empty ────────────────────────────────
|
// ─── Not Found / Empty ────────────────────────────────
|
||||||
function renderNotFound(msg) {
|
function renderNotFound(msg) {
|
||||||
ROOT.innerHTML = `<div class="prop-empty">
|
ROOT.innerHTML = `<div class="prop-empty">
|
||||||
|
|
@ -489,6 +810,12 @@
|
||||||
pdfPage = 1;
|
pdfPage = 1;
|
||||||
pdfRendering = false;
|
pdfRendering = false;
|
||||||
pdfPending = null;
|
pdfPending = null;
|
||||||
|
pdfAllText = [];
|
||||||
|
pdfSearchActive = false;
|
||||||
|
pdfSearchQuery = '';
|
||||||
|
pdfSearchMatches = [];
|
||||||
|
pdfSearchCurrentIdx = -1;
|
||||||
|
|
||||||
|
|
||||||
const hash = (location.hash || '').replace(/^#\/?/, '');
|
const hash = (location.hash || '').replace(/^#\/?/, '');
|
||||||
const parts = hash.split('/').filter(Boolean);
|
const parts = hash.split('/').filter(Boolean);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue