diora-web/static/js/app/70-search.js
mrwnslz 65a01569f9
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 11m32s
Test / e2e (push) Failing after 1m30s
PDF: markierbarer Text, Suche im ganzen Dokument, anklickbare Links mit Zurück (sw CACHE v165)
Ü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>
2026-11-03 18:16:27 +01:00

290 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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);
}