From b0a6362e687561183c57d3b640ad68dac0236da1 Mon Sep 17 00:00:00 2001 From: jae Date: Wed, 15 Apr 2026 17:05:27 +0000 Subject: [PATCH] feat(propaganda): add PDF text search with highlighting, match navigation, Ctrl+F shortcut --- css/propaganda.css | 156 +++++++++++++++++++++ js/propaganda.js | 331 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 485 insertions(+), 2 deletions(-) diff --git a/css/propaganda.css b/css/propaganda.css index 9cbfd9c..c57fe3b 100644 --- a/css/propaganda.css +++ b/css/propaganda.css @@ -622,6 +622,154 @@ 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 ──────────────────────────── */ .prop-classification { text-align: center; @@ -717,6 +865,14 @@ .prop-breadcrumb { 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) { diff --git a/js/propaganda.js b/js/propaganda.js index 1eee7aa..7b312ec 100644 --- a/js/propaganda.js +++ b/js/propaganda.js @@ -327,9 +327,20 @@ + ⬇ DOWNLOAD +
DECRYPTING DOCUMENT...
@@ -358,6 +369,13 @@ let pdfScale = 1.5; let pdfRendering = false; let pdfPending = null; + let pdfAllText = []; + let pdfSearchActive = false; + let pdfSearchQuery = ''; + let pdfSearchCaseSensitive = false; + let pdfSearchMatches = []; + let pdfSearchCurrentIdx = -1; + async function initPdfViewer(url) { const canvasWrap = document.getElementById('pdfCanvasWrap'); @@ -382,10 +400,14 @@ 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'); canvas.id = 'pdfCanvas'; - canvasWrap.appendChild(canvas); + pageContainer.appendChild(canvas); + canvasWrap.appendChild(pageContainer); updatePageInfo(); renderPdfPage(); @@ -413,6 +435,12 @@ pdfScale = 1.5; queueRender(); }); + + // Extract all page text for search + extractAllText(); + + // Initialize search controls + initSearchControls(); } catch (e) { console.error('PDF load failed:', e); canvasWrap.innerHTML = `
@@ -442,7 +470,21 @@ canvas.height = viewport.height; 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; + + // Render text layer for search and text selection + await renderPageTextLayer(page, viewport); + + // Highlight search matches on this page + highlightCurrentPageMatches(); + pdfRendering = false; 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 ──────────────────────────────── function renderNotFound(msg) { ROOT.innerHTML = `
@@ -489,6 +810,12 @@ pdfPage = 1; pdfRendering = false; pdfPending = null; + pdfAllText = []; + pdfSearchActive = false; + pdfSearchQuery = ''; + pdfSearchMatches = []; + pdfSearchCurrentIdx = -1; + const hash = (location.hash || '').replace(/^#\/?/, ''); const parts = hash.split('/').filter(Boolean);