32 klassische Skripte in der alten Reihenfolge (01-core … 99-init), ohne Änderung am Code darin: geschnitten nur zwischen Anweisungen, je Datei 'use strict' wie vorher für die ganze Datei. Geprüft (acorn): kein Code, der beim Laden läuft, braucht einen Namen aus einer späteren Datei. Globale Funktionen und let/const bleiben global, die App-Schichten (native.js, native-eink.js, …) umhüllen sie wie bisher. Template und sw.js-SHELL listen die Dateien; diora-android build_web nimmt static/js rekursiv. Getestet: 14 Browser-Tests (e2e), Desktop-App (Buch, Lesestand, Leiste im Reader, Hell/Dunkel). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
5.6 KiB
JavaScript
170 lines
5.6 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: collect text layer spans
|
|
const spans = contentEl.querySelectorAll('.pdf-text-layer > span');
|
|
const lq = query.toLowerCase();
|
|
for (const span of spans) {
|
|
if (span.textContent.toLowerCase().includes(lq)) {
|
|
span.classList.add('reader-search-match');
|
|
searchMatches.push(span);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// PDF: remove highlight class from spans
|
|
document.querySelectorAll('.reader-search-match').forEach(el => {
|
|
el.classList.remove('reader-search-match', 'active');
|
|
});
|
|
}
|
|
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;
|
|
_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}`;
|
|
}
|
|
|
|
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);
|
|
}
|