Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
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>
2026-10-14 18:30:43 +02:00
|
|
|
|
'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 {
|
2026-11-03 18:16:27 +01:00
|
|
|
|
// PDF: the whole document, page by page (not only the pages drawn now).
|
|
|
|
|
|
_pdfSearchAll(query);
|
|
|
|
|
|
return;
|
Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
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>
2026-10-14 18:30:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-11-03 18:16:27 +01:00
|
|
|
|
_pdfSearchStop();
|
Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
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>
2026-10-14 18:30:43 +02:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
2026-11-03 18:16:27 +01:00
|
|
|
|
if (currentPdfDoc) { _pdfShowMatch(idx); return; }
|
Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
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>
2026-10-14 18:30:43 +02:00
|
|
|
|
_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}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 18:16:27 +01:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// 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); });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
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>
2026-10-14 18:30:43 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|