Über jeder gezeichneten Seite liegt die Textebene von pdf.js (auswählen, kopieren) und eine Ebene mit den Links: Sprünge im Dokument gehen dorthin, ein Knopf führt zurück; Web-Adressen öffnen außerhalb. Die Suche geht durch den Text aller Seiten (je Dokument einmal geholt, auch für den Zuschnitt im Seitenmodus) statt nur durch die gerade gezeichneten, markiert den Treffer genau und springt in beiden Modi hin. Tippen und Wischen auf Text blättern weiter, außer nach einer Auswahl; Markierungen bleiben EPUBs vorbehalten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
290 lines
10 KiB
JavaScript
290 lines
10 KiB
JavaScript
'use strict';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reader Search
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let _readerSearchDebounce = null;
|
||
|
||
function toggleReaderSearch() {
|
||
const overlay = $('reader-overlay');
|
||
const contentEl = $('reader-content');
|
||
if (!overlay || !contentEl) return;
|
||
|
||
const existing = document.getElementById('reader-search-bar');
|
||
if (existing) {
|
||
existing.remove();
|
||
readerSearchOpen = false;
|
||
clearReaderSearch();
|
||
return;
|
||
}
|
||
|
||
readerSearchOpen = true;
|
||
const bar = document.createElement('div');
|
||
bar.id = 'reader-search-bar';
|
||
bar.className = 'reader-search-bar';
|
||
bar.innerHTML = `
|
||
<input type="text" id="reader-search-input" class="search-input" placeholder="Suchen …" style="width:160px;">
|
||
<button class="btn-icon" id="rs-search-prev" title="Vorheriger Treffer">↑</button>
|
||
<button class="btn-icon" id="rs-search-next" title="Nächster Treffer">↓</button>
|
||
<span id="rs-search-count" class="muted"></span>
|
||
<button class="btn-icon" id="rs-search-clear" title="Close">✕</button>
|
||
`;
|
||
overlay.insertBefore(bar, $('reader-body-row'));
|
||
|
||
const input = bar.querySelector('#reader-search-input');
|
||
input.focus();
|
||
input.addEventListener('input', () => {
|
||
clearTimeout(_readerSearchDebounce);
|
||
_readerSearchDebounce = setTimeout(() => doReaderSearch(input.value.trim()), 300);
|
||
});
|
||
input.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') { e.shiftKey ? readerSearchPrev() : readerSearchNext(); }
|
||
if (e.key === 'Escape') { toggleReaderSearch(); }
|
||
});
|
||
bar.querySelector('#rs-search-prev').addEventListener('click', readerSearchPrev);
|
||
bar.querySelector('#rs-search-next').addEventListener('click', readerSearchNext);
|
||
bar.querySelector('#rs-search-clear').addEventListener('click', toggleReaderSearch);
|
||
}
|
||
|
||
async function doReaderSearch(query) {
|
||
const contentEl = $('reader-content');
|
||
if (!contentEl) return;
|
||
const countEl = document.getElementById('rs-search-count');
|
||
|
||
clearReaderSearchHighlights();
|
||
searchMatches = [];
|
||
searchMatchIndex = -1;
|
||
|
||
if (!query) { if (countEl) countEl.textContent = ''; return; }
|
||
|
||
if (!currentPdfDoc) {
|
||
// EPUB: snapshot original content
|
||
if (!searchOriginalContent) {
|
||
searchOriginalContent = contentEl.innerHTML;
|
||
} else {
|
||
contentEl.innerHTML = searchOriginalContent;
|
||
applyHighlightsToContent();
|
||
}
|
||
|
||
const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT);
|
||
const lq = query.toLowerCase();
|
||
const ranges = [];
|
||
let node;
|
||
while ((node = walker.nextNode())) {
|
||
const text = node.textContent;
|
||
const lt = text.toLowerCase();
|
||
let idx = 0;
|
||
while ((idx = lt.indexOf(lq, idx)) !== -1) {
|
||
const range = document.createRange();
|
||
range.setStart(node, idx);
|
||
range.setEnd(node, idx + query.length);
|
||
ranges.push(range);
|
||
idx += query.length;
|
||
}
|
||
}
|
||
|
||
// Insert marks in reverse to preserve range validity
|
||
for (let i = ranges.length - 1; i >= 0; i--) {
|
||
try {
|
||
const mark = document.createElement('mark');
|
||
mark.className = 'reader-search-match';
|
||
ranges[i].surroundContents(mark);
|
||
searchMatches.unshift(mark);
|
||
} catch (e) {}
|
||
}
|
||
} else {
|
||
// PDF: the whole document, page by page (not only the pages drawn now).
|
||
_pdfSearchAll(query);
|
||
return;
|
||
}
|
||
|
||
if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0';
|
||
if (searchMatches.length) {
|
||
searchMatchIndex = 0;
|
||
scrollToSearchMatch(0);
|
||
}
|
||
}
|
||
|
||
function clearReaderSearchHighlights() {
|
||
if (!currentPdfDoc) {
|
||
// EPUB: restore from snapshot
|
||
if (searchOriginalContent !== null) {
|
||
const contentEl = $('reader-content');
|
||
if (contentEl) {
|
||
contentEl.innerHTML = searchOriginalContent;
|
||
applyHighlightsToContent();
|
||
}
|
||
searchOriginalContent = null;
|
||
} else {
|
||
// Just remove marks without full restore
|
||
document.querySelectorAll('mark.reader-search-match').forEach(m => {
|
||
const parent = m.parentNode;
|
||
while (m.firstChild) parent.insertBefore(m.firstChild, m);
|
||
parent.removeChild(m);
|
||
});
|
||
}
|
||
} else {
|
||
_pdfSearchStop();
|
||
}
|
||
searchMatches = [];
|
||
searchMatchIndex = -1;
|
||
}
|
||
|
||
function clearReaderSearch() {
|
||
clearTimeout(_readerSearchDebounce);
|
||
clearReaderSearchHighlights();
|
||
readerSearchOpen = false;
|
||
const countEl = document.getElementById('rs-search-count');
|
||
if (countEl) countEl.textContent = '';
|
||
}
|
||
|
||
function scrollToSearchMatch(idx) {
|
||
if (!searchMatches.length) return;
|
||
if (currentPdfDoc) { _pdfShowMatch(idx); return; }
|
||
_suppressScrollJumpDetect();
|
||
searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx));
|
||
searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'});
|
||
const countEl = document.getElementById('rs-search-count');
|
||
if (countEl) countEl.textContent = `${idx + 1} / ${searchMatches.length}`;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PDF search: through the text of every page (58-pdf.js keeps it), in order; the matches
|
||
// marked in the text layer of the pages drawn, and of those drawn later.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let _pdfSearchState = null; // {query, matches: [{page, start, end}], done}
|
||
let _pdfSearchGen = 0;
|
||
|
||
function _pdfSearchCount() {
|
||
const countEl = document.getElementById('rs-search-count');
|
||
const st = _pdfSearchState;
|
||
if (!countEl || !st) return;
|
||
const more = st.done ? '' : ' …';
|
||
countEl.textContent = st.matches.length
|
||
? `${searchMatchIndex >= 0 ? searchMatchIndex + 1 : '–'} / ${st.matches.length}${more}`
|
||
: (st.done ? '0' : '…');
|
||
}
|
||
|
||
async function _pdfSearchAll(query) {
|
||
const pdf = currentPdfDoc;
|
||
const gen = ++_pdfSearchGen;
|
||
const lq = query.toLowerCase();
|
||
const st = {query, matches: [], done: false};
|
||
_pdfSearchState = st;
|
||
searchMatches = st.matches;
|
||
searchMatchIndex = -1;
|
||
_pdfSearchCount();
|
||
// The first match from the page being read on is shown as soon as it's found.
|
||
const from = pdfCurrentPage || 1;
|
||
for (let n = 1; n <= pdf.numPages; n++) {
|
||
const tc = await _pdfPageText(pdf, n);
|
||
if (gen !== _pdfSearchGen || pdf !== currentPdfDoc) return;
|
||
const {str} = _pdfPageString(tc);
|
||
const before = st.matches.length;
|
||
let i = 0;
|
||
while (lq && (i = str.indexOf(lq, i)) !== -1) {
|
||
st.matches.push({page: n, start: i, end: i + lq.length});
|
||
i += lq.length;
|
||
}
|
||
if (st.matches.length > before) {
|
||
const e = _pdfView && _pdfView.entries[n - 1];
|
||
if (e) _pdfSearchMarkPage(e);
|
||
if (searchMatchIndex < 0 && n >= from) { searchMatchIndex = before; _pdfShowMatch(before); }
|
||
}
|
||
if (n % 20 === 0) _pdfSearchCount();
|
||
}
|
||
st.done = true;
|
||
if (searchMatchIndex < 0 && st.matches.length) { searchMatchIndex = 0; _pdfShowMatch(0); }
|
||
_pdfSearchCount();
|
||
}
|
||
|
||
/** Marks of search [st] in page [e]'s text layer (none when no search is on). */
|
||
function _pdfSearchMarkPage(e) {
|
||
if (!e.textDivs || !e.textInfo) return;
|
||
if (e.marked) {
|
||
for (const i of e.marked) if (e.textDivs[i]) e.textDivs[i].textContent = e.textInfo.items[i].str;
|
||
e.marked = null;
|
||
}
|
||
const st = _pdfSearchState;
|
||
if (!st) return;
|
||
const {starts} = e.textInfo.string;
|
||
const items = e.textInfo.items;
|
||
// Per span, the pieces of it that match (one match can run over several spans).
|
||
const pieces = new Map();
|
||
st.matches.forEach((m, idx) => {
|
||
if (m.page !== e.num) return;
|
||
for (let i = 0; i < items.length; i++) {
|
||
const s = starts[i], len = (items[i].str || '').length;
|
||
if (s >= m.end) break;
|
||
if (s + len <= m.start || !len) continue;
|
||
if (!pieces.has(i)) pieces.set(i, []);
|
||
pieces.get(i).push([Math.max(m.start, s) - s, Math.min(m.end, s + len) - s, idx]);
|
||
}
|
||
});
|
||
e.marked = [];
|
||
pieces.forEach((list, i) => {
|
||
const div = e.textDivs[i];
|
||
if (!div) return;
|
||
const str = items[i].str;
|
||
div.textContent = '';
|
||
let at = 0;
|
||
for (const [a, b, idx] of list) {
|
||
if (a > at) div.appendChild(document.createTextNode(str.slice(at, a)));
|
||
const mark = document.createElement('mark');
|
||
mark.className = 'reader-search-match' + (idx === searchMatchIndex ? ' active' : '');
|
||
mark.dataset.m = idx;
|
||
mark.textContent = str.slice(a, b);
|
||
div.appendChild(mark);
|
||
at = b;
|
||
}
|
||
if (at < str.length) div.appendChild(document.createTextNode(str.slice(at)));
|
||
e.marked.push(i);
|
||
});
|
||
}
|
||
|
||
/** Go to match [idx]: its page, and (scrolling) the match itself into view. */
|
||
async function _pdfShowMatch(idx) {
|
||
const st = _pdfSearchState;
|
||
const m = st && st.matches[idx];
|
||
if (!m) return;
|
||
const gen = _pdfSearchGen;
|
||
_pdfSearchCount();
|
||
document.querySelectorAll('.pdf-text-layer mark.reader-search-match').forEach(mk => {
|
||
mk.classList.toggle('active', +mk.dataset.m === idx);
|
||
});
|
||
const contentEl = $('reader-content');
|
||
const shown = readerSettings.pdfPaginated ? pdfCurrentPage : (contentEl ? _pdfTopPage(contentEl) : 0);
|
||
if (m.page !== shown || readerSettings.pdfPaginated) pdfGoToPage(m.page);
|
||
if (readerSettings.pdfPaginated || !contentEl) return;
|
||
// Scrolling: the page is drawn (and its text laid over it) once it's near the view.
|
||
for (let t = 0; t < 50; t++) {
|
||
if (gen !== _pdfSearchGen || searchMatchIndex !== idx) return;
|
||
const mk = contentEl.querySelector(`#pdf-page-${m.page} mark[data-m="${idx}"]`);
|
||
if (mk) {
|
||
_suppressScrollJumpDetect();
|
||
mk.scrollIntoView({block: 'center'});
|
||
return;
|
||
}
|
||
await new Promise(r => setTimeout(r, 100));
|
||
}
|
||
}
|
||
|
||
function _pdfSearchStop() {
|
||
_pdfSearchGen++;
|
||
_pdfSearchState = null;
|
||
if (_pdfView) _pdfView.entries.forEach(e => { if (e.marked) _pdfSearchMarkPage(e); });
|
||
}
|
||
|
||
function readerSearchNext() {
|
||
if (!searchMatches.length) return;
|
||
searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length;
|
||
scrollToSearchMatch(searchMatchIndex);
|
||
}
|
||
|
||
function readerSearchPrev() {
|
||
if (!searchMatches.length) return;
|
||
searchMatchIndex = (searchMatchIndex - 1 + searchMatches.length) % searchMatches.length;
|
||
scrollToSearchMatch(searchMatchIndex);
|
||
}
|