diora-web/static/js/app/62-reader.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

752 lines
30 KiB
JavaScript
Raw 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';
// ---------------------------------------------------------------------------
// Immersive reader mode — tap centre of screen to toggle bars
// ---------------------------------------------------------------------------
let _immBarsVisible = true;
function _immHandleTap(e) {
// Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover)
if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet, .tts-bar')) return;
// In marker mode, taps have a dedicated meaning (highlight/create a note) —
// don't also toggle the immersive bars underneath.
if (markerModeActive) return;
// Don't toggle bars while the user has just finished selecting text (e.g. to highlight it)
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
// If a footnote popover is open, dismiss it instead of toggling bars
if (currentFootnotePopover && !currentFootnotePopover.contains(e.target)) {
dismissFootnotePopover();
return;
}
_immBarsVisible = !_immBarsVisible;
document.body.classList.toggle('reader-immersive', !_immBarsVisible);
// Close settings panel when bars disappear
if (!_immBarsVisible && readerSettingsPanelOpen) {
const sp = document.getElementById('reader-settings-panel');
if (sp) sp.remove();
readerSettingsPanelOpen = false;
}
}
// A double click on the text (with a mouse): full screen, the bars out of the
// way; again (or Esc): back as it was. Fingers keep their double tap; beside
// the text a click turns the page (63-pages.js), so only on the text itself.
let _readerFsHidBars = false;
// What openBook hangs on the content and the page number for this book, taken off again in
// closeReader — else every book opened adds its own, and a PDF's handlers run under the next
// EPUB. (Not addEventListener's signal: the tolino's Chrome 61 ignores it.)
let _readerListeners = [];
function _readerListen(el, type, fn, opts) {
if (!el) return;
el.addEventListener(type, fn, opts);
_readerListeners.push([el, type, fn, opts]);
}
function _readerUnlistenAll() {
for (const [el, type, fn, opts] of _readerListeners) el.removeEventListener(type, fn, opts);
_readerListeners = [];
}
function _readerDblClick(e) {
if (!matchMedia('(hover: hover) and (pointer: fine)').matches) return;
if (!e.target.closest('[data-epub-src], .pdf-page-wrapper')) return;
if (e.target.closest('button, a, input, select, textarea, label, [data-footnote-ref], mark.epub-highlight')) return;
// The double click also picked a word: that's not a marking.
const sel = window.getSelection();
if (sel) sel.removeAllRanges();
if (typeof dismissHighlightPopover === 'function') dismissHighlightPopover();
if (document.fullscreenElement) {
if (document.exitFullscreen) document.exitFullscreen().catch(() => {});
return;
}
const el = document.documentElement;
if (!el.requestFullscreen) return;
el.requestFullscreen().then(() => {
if (_immBarsVisible) {
_immBarsVisible = false;
document.body.classList.add('reader-immersive');
_readerFsHidBars = true;
}
}).catch(() => {});
}
document.addEventListener('fullscreenchange', () => {
if (document.fullscreenElement || !_readerFsHidBars) return;
_readerFsHidBars = false;
const overlay = $('reader-overlay');
if (overlay && overlay.style.display !== 'none' && !_immBarsVisible) {
_immBarsVisible = true;
document.body.classList.remove('reader-immersive');
}
});
document.addEventListener('DOMContentLoaded', () => {
const row = $('reader-body-row');
if (row) row.addEventListener('dblclick', _readerDblClick);
});
function enterReaderImmersiveMode() {
_immBarsVisible = true;
document.body.classList.remove('reader-immersive');
document.addEventListener('click', _immHandleTap);
}
function exitReaderImmersiveMode() {
_immBarsVisible = true;
document.body.classList.remove('reader-immersive');
document.removeEventListener('click', _immHandleTap);
}
// Serializes openBook: it empties #reader-content, then appends the chapters
// across several awaits. A second call in between (a second tap while a slow
// e-reader is still decrypting) appended the whole book a second time — and
// progress saved from that doubled DOM had block indices beyond the real book,
// which then out-ranked every real position on the server.
let _openBookInFlight = null;
// The opening under way: {cancelled, ctrl} — "Abbrechen", closing the reader or opening another
// book end it (docs: a big scanned PDF used to take minutes and could not be left).
let _openToken = null;
function cancelBookOpening() {
const t = _openToken;
if (!t || t.cancelled) return false;
t.cancelled = true;
try { t.ctrl.abort(); } catch (e) {}
_pdfRenderGen++;
if (typeof _pdfStopLazy === 'function') _pdfStopLazy();
if (typeof _pdfLoadingTask !== 'undefined' && _pdfLoadingTask) { try { _pdfLoadingTask.destroy(); } catch (e) {} }
document.querySelectorAll('.pdf-loading-overlay').forEach(el => el.remove());
const overlay = $('reader-overlay');
if (overlay) overlay.style.display = 'none';
const contentEl = $('reader-content');
if (contentEl) contentEl.innerHTML = '';
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
currentPdfDoc = null;
currentPdfBuffer = null;
_readerUnlistenAll();
_readPos = null;
return true;
}
window.cancelBookOpening = cancelBookOpening;
async function openBook(bookId) {
if (_openBookInFlight) {
if (_openBookInFlight.bookId === bookId) return _openBookInFlight.promise;
cancelBookOpening(); // another book: the one still loading is left
await _openBookInFlight.promise.catch(() => {});
}
const promise = _openBookOnce(bookId);
_openBookInFlight = {bookId, promise};
try {
return await promise;
} finally {
if (_openBookInFlight && _openBookInFlight.promise === promise) _openBookInFlight = null;
}
}
async function _openBookOnce(bookId) {
// Nothing is saved for this book until its place is restored.
_readPos = {bookId, ready: false, key: null, readAt: 0, moved: false, hold: false};
const overlay = $('reader-overlay');
const contentEl = $('reader-content');
const titleEl = $('reader-title');
if (!overlay || !contentEl) return;
titleEl.textContent = 'Lade …';
contentEl.innerHTML = '';
overlay.style.display = '';
const tok = {cancelled: false, ctrl: new AbortController()};
_openToken = tok;
// A moment later (not for a book that opens at once): the spinner with "Abbrechen".
const loadingEl = document.createElement('div');
loadingEl.className = 'pdf-loading-overlay';
loadingEl.innerHTML = '<div class="reader-opening"><span class="pdf-loading-spinner"></span>' +
'<button type="button" class="btn btn-sm reader-opening-cancel">Abbrechen</button></div>';
loadingEl.querySelector('button').addEventListener('click', () => cancelBookOpening());
const showLoading = setTimeout(() => { if (!tok.cancelled) overlay.appendChild(loadingEl); }, 400);
const left = () => tok.cancelled;
try {
loadReaderSettings(bookId);
_charIndex = null;
const key = await getOrCreateEncKey();
let data_ct, data_iv;
const cached = await _getCachedBook(bookId);
if (left()) return;
if (cached) {
({data_ct, data_iv} = cached);
} else {
let res;
try {
res = await fetch(`/books/${bookId}/data/`, {signal: tok.ctrl.signal});
} catch {
if (left()) return;
// offline and book not cached yet
overlay.style.display = 'none';
const toast = document.createElement('div');
toast.className = 'reader-toast';
toast.textContent = 'Buch nicht offline verfügbar — einmal online öffnen zum Cachen.';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3500);
return;
}
({data_ct, data_iv} = await res.json());
if (left()) return;
_setCachedBook(bookId, data_ct, data_iv); // fire-and-forget
}
const plain = await decryptBytes(key, data_iv, data_ct);
if (left()) return;
// Revoke any previous image blob URLs
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
currentImageMap = {};
const cachedMeta = bookMetaCache[bookId] || {};
let title = cachedMeta.title || '';
let author = cachedMeta.author || '';
let toc = [];
let numPages = 0;
const isPdfBook = cachedMeta.type === 'pdf';
if (isPdfBook) {
// A document still open (another book) goes; renderPdf loads this one.
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
currentPdfDoc = null;
const result = await renderPdf(plain, contentEl);
if (left() || !result) return;
title = result.title || title;
author = result.author || author;
toc = result.toc;
numPages = result.numPages;
// pdf.js holds the document: the decrypted bytes needn't stay as well.
currentPdfBuffer = null;
} else {
currentPdfBuffer = null;
// Prepared before (same book file, same preparation code): only the images to unpack.
const prepared = await _getParsedBook(bookId, data_iv, key);
const result = prepared ? await _restoreParsedBook(prepared, plain) : await parseEpub(plain);
if (left()) return;
if (!prepared) {
// Kept for the next open once the book is on screen (encrypting takes a moment).
setTimeout(() => _setParsedBook(bookId, data_iv, key, result), 3000);
}
title = result.title || title;
author = result.author || author;
toc = result.toc;
currentImageMap = result.imageMap;
// Inject chapters progressively so the browser stays responsive on large books:
// a batch per ~50 ms of work (a fixed count waited a frame every 8 chapters).
const tmp = document.createElement('div');
for (let i = 0; i < result.chapters.length;) {
const frag = document.createDocumentFragment();
const start = performance.now();
do {
tmp.innerHTML = result.chapters[i++];
while (tmp.firstChild) frag.appendChild(tmp.firstChild);
} while (i < result.chapters.length && performance.now() - start < 50);
contentEl.appendChild(frag);
if (i < result.chapters.length) await new Promise(r => setTimeout(r, 0));
if (left()) return;
}
// On screen: no more need to offer "Abbrechen".
clearTimeout(showLoading);
loadingEl.remove();
// Used to decide whole-percent vs one-decimal display further down —
// see _formatBookPercent().
_currentBookCharCount = contentEl.textContent.length;
}
clearTimeout(showLoading);
loadingEl.remove();
if (_openToken === tok) _openToken = null;
currentBookToc = toc;
titleEl.textContent = title + (author ? ` — ${author}` : '');
currentBookId = bookId;
// Remember the book's real (PDF) or estimated (EPUB) page count in its encrypted meta so
// the book list can show it; rewritten only when it changed.
const bookPages = isPdfBook ? numPages : Math.max(1, Math.round(_currentBookCharCount / CHARS_PER_BOOK_PAGE));
if (bookPages > 0 && cachedMeta.pages !== bookPages) {
_updateBookMeta(bookId, {pages: bookPages}).catch(() => {});
}
// Load bookmarks and highlights
await Promise.all([
loadBookmarks(bookId),
loadHighlights(bookId),
]);
// Apply reader settings (theme, font size, etc.)
applyReaderSettings(isPdfBook);
// If this book already has notes/highlights, surface them right away
// instead of making the reader hide them behind a manual toggle.
// Not on phones: there the margin covers the text; it stays one tap away (✎).
if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen && !_isMobileReader()) {
toggleAnnotationsMargin();
}
// Touch: swipe (paginated) + pinch-to-zoom
_readerListen(contentEl, 'touchstart', _pdfTouchStart, {passive: true});
_readerListen(contentEl, 'touchmove', _pdfTouchMove, {passive: false});
_readerListen(contentEl, 'touchend', _pdfTouchEnd, {passive: true});
_readerListen(contentEl, 'touchcancel', _pdfTouchEnd, {passive: true});
// Set up progress input
const progressInput = $('reader-progress-input');
const progressSuffix = $('reader-progress-suffix');
const isPdf = isPdfBook;
if (progressInput) {
progressInput.style.display = '';
if (isPdf) {
progressInput.value = 1;
if (progressSuffix) progressSuffix.textContent = `/ ${numPages}`;
} else {
progressInput.value = _formatBookPercent(0);
if (progressSuffix) progressSuffix.textContent = '%';
}
_readerListen(progressInput, 'change', function () {
_suppressScrollJumpDetect();
if (isPdf) {
const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1));
this.value = page;
pdfGoToPage(page);
} else {
const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value)));
this.value = _formatBookPercent(pct / 100);
restoreFromAnchor(contentEl, _anchorForFraction(contentEl, pct / 100));
}
});
_readerListen(progressInput, 'click', function () { this.select(); });
}
// Restore scroll position — must happen BEFORE auto-save timer is started
_suppressScrollJumpDetect();
let openedProgress = null;
try {
let fraction = 0, anchor = '';
let bookData = null;
try {
const progressRes = await fetch('/books/', {cache: 'no-store'});
_noteServerNowHeader(progressRes);
const allBooks = await progressRes.json();
bookData = allBooks.find(b => b.id === bookId) || null;
} catch {
// offline — use cached meta
const cached = await _loadCachedBookMeta();
bookData = cached.find(b => b.id === bookId) || null;
}
// The newer of the server's place and an own one it doesn't have yet.
if (bookData) bookData = _withPendingProgress(bookData);
fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
anchor = bookData ? (bookData.position_anchor || '') : '';
openedProgress = bookData;
_currentPositionAnchor = anchor;
if (isPdf && readerSettings.pdfPaginated) {
if (fraction > 0 && pdfTotalPages > 1)
pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1);
enterPdfPaginatedMode();
} else if (isPdf) {
// PDF scroll mode
await new Promise(r => requestAnimationFrame(r));
if (fraction > 0)
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
_pdfSetCurrentPage(_pdfTopPage(contentEl));
} else {
// EPUB — wait for images so scrollHeight is final
const imgs = Array.from(contentEl.querySelectorAll('img'));
if (imgs.length) {
await Promise.all(imgs.map(img =>
img.complete ? Promise.resolve()
: new Promise(r => { img.onload = r; img.onerror = r; })
));
}
await new Promise(r => requestAnimationFrame(r));
if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) {
restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction));
}
}
} catch (e) {}
// From here on, what the reader does is reading. The place shown has the
// time it was read at (elsewhere, or here earlier): nothing new to send yet.
if (_readPos && _readPos.bookId === bookId && currentBookId === bookId) {
const shownAnchor = isPdf ? '' : getPositionAnchor(contentEl);
const readAtServer = openedProgress && openedProgress.read_at != null ? openedProgress.read_at : null;
_readPos.key = _positionKey(shownAnchor, openedProgress ? (openedProgress.scroll_fraction || 0) : 0);
_readPos.readAt = readAtServer == null ? 0 : readAtServer - _serverOffset;
_readPos.ready = true;
_offerFurthestPosition(openedProgress);
}
// Prime the "settled" baseline to wherever we just restored to, rather
// than waiting to lazily pick up whatever scroll event happens to fire
// first — that could otherwise be a later, unrelated layout correction.
_scrollRestTop = contentEl.scrollTop;
// PDF scrolling: the page shown at the top is the current one (header, bookmarks, the
// app's footer). EPUB: shown from the position anchor, see the anchor tracking below.
if (isPdf) {
let pageRaf = 0;
_readerListen(contentEl, 'scroll', () => {
if (pageRaf || readerSettings.pdfPaginated) return;
pageRaf = requestAnimationFrame(() => {
pageRaf = 0;
if (currentPdfDoc) _pdfSetCurrentPage(_pdfTopPage(contentEl));
});
}, {passive: true});
}
// Auto-save progress every 10s and on scroll (debounced 2s)
// Started AFTER restore so an early visibilitychange can't overwrite with position 0
readerScrollSaveTimer = setInterval(saveReaderProgress, 10000);
let _scrollDebounce = null;
_readerListen(contentEl, 'scroll', () => {
clearTimeout(_scrollDebounce);
_scrollDebounce = setTimeout(saveReaderProgress, 2000);
}, {passive: true});
// Keep _currentPositionAnchor fresh independent of the slower save debounce
// above: the resize observer below (or an immersive-bars toggle right after
// scrolling) would otherwise restore to a stale, pre-scroll anchor.
if (!isPdf) {
let _anchorTrackDebounce = null;
_readerListen(contentEl, 'scroll', () => {
clearTimeout(_anchorTrackDebounce);
_anchorTrackDebounce = setTimeout(() => {
_currentPositionAnchor = getPositionAnchor(contentEl);
const f = _fractionFromAnchor(contentEl, _currentPositionAnchor);
_noteReadingPosition(_currentPositionAnchor, f === null ? 0 : f);
if (progressInput && f !== null && document.activeElement !== progressInput) {
progressInput.value = _formatBookPercent(f);
}
}, 150);
}, {passive: true});
}
// Restore anchor on viewport resize (e.g. screen rotation, font zoom)
if (!isPdf) {
_resizeObserver = new ResizeObserver(() => {
if (_currentPositionAnchor) {
_suppressScrollJumpDetect();
requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor));
}
_repositionMarginMarkers();
});
_resizeObserver.observe(contentEl);
}
// Offer to jump back after a sudden, large scroll (accidental fling,
// fat-fingered 'g'/'G' keybind, etc.) — see _checkScrollJump above.
_scrollRestTop = null;
let _scrollJumpRaf = null;
_readerListen(contentEl, 'scroll', () => {
if (_scrollJumpRaf) return;
_scrollJumpRaf = requestAnimationFrame(() => {
_scrollJumpRaf = null;
_checkScrollJump(contentEl);
_repositionMarginMarkers();
});
}, {passive: true});
enterReaderImmersiveMode();
// An issue of a magazine: its articles (54-issues.js).
if (typeof _issueReaderOpened === 'function') _issueReaderOpened(bookId, isPdf);
} catch (e) {
if (left()) return; // cancelled: the half-loaded document may complain, that's all
console.error('openBook failed:', e);
overlay.style.display = 'none';
markBookBroken(bookId);
} finally {
clearTimeout(showLoading);
loadingEl.remove();
if (_openToken === tok) _openToken = null;
}
}
async function exportEncKey() {
const statusEl = $('book-key-status');
try {
const key = await getOrCreateEncKey();
const raw = await crypto.subtle.exportKey('raw', key);
const b64 = bytesToBase64(raw);
await navigator.clipboard.writeText(b64);
if (statusEl) statusEl.textContent = '✓ Schlüssel in die Zwischenablage kopiert';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000);
} catch (e) {
if (statusEl) statusEl.textContent = 'Export fehlgeschlagen: ' + e.message;
}
}
function showImportKey() {
const body = $('sidebar-body');
openSidebar('Schlüssel importieren', `
<p class="muted">Füge den Schlüssel ein, den du im anderen Browser exportiert hast:</p>
<textarea id="import-key-input" class="search-input" rows="3" style="width:100%;resize:none;font-family:monospace;font-size:0.75rem;"></textarea>
<button class="btn" style="margin-top:8px;" data-import-key-apply>Übernehmen</button>
<p class="muted" style="margin-top:8px;">Das ersetzt den Schlüssel in diesem Browser. Hier hochgeladene Bücher sind erst wieder lesbar, wenn du den Schlüssel zurück abgleichst.</p>
`);
body.addEventListener('click', async function _importClick(e) {
if (!e.target.closest('[data-import-key-apply]')) return;
body.removeEventListener('click', _importClick);
const b64 = (body.querySelector('#import-key-input')?.value || '').trim();
const statusEl = $('book-key-status');
try {
const raw = base64ToBytes(b64);
const importedKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
const re_exported = await crypto.subtle.exportKey('raw', importedKey);
localStorage.setItem(`diora_enc_key_${window.USER_ID}`, bytesToBase64(re_exported));
closeSidebar();
if (statusEl) statusEl.textContent = '✓ Schlüssel importiert – Bücher werden neu geladen …';
await loadBookList();
if (statusEl) setTimeout(() => { statusEl.textContent = ''; }, 3000);
} catch (e) {
if ($('book-key-status')) $('book-key-status').textContent = 'Import fehlgeschlagen: ungültiger Schlüssel';
}
});
}
async function deleteBook(bookId) {
if (!await customConfirm('Dieses Buch löschen? Das lässt sich nicht rückgängig machen.', { danger: true })) return;
try {
const res = await fetch(`/books/${bookId}/delete/`, {
method: 'POST',
headers: {'X-CSRFToken': getCsrfToken()},
});
const data = await res.json();
if (data.ok) loadBookList();
} catch (e) {}
}
// Opened somewhere before the furthest place reached: offer it.
function _offerFurthestPosition(p) {
if (!p) return;
const furthest = p.furthest_fraction || 0;
if (furthest - (p.scroll_fraction || 0) < 0.02 || furthest >= 0.999) return;
const pos = _readPos;
_readerToast(`Weiteste Stelle: ${_formatBookPercent(furthest)} %`, 'Dorthin', () => {
if (_readPos !== pos) return;
_noteReaderInteraction();
_jumpToPosition(p.furthest_anchor || '', furthest);
}, 8000);
}
async function saveReaderProgress(force = false) {
if (!currentBookId) return;
const contentEl = $('reader-content');
if (!contentEl) return;
let fraction;
if (readerSettings.pdfPaginated && currentPdfDoc && pdfTotalPages > 1) {
fraction = (pdfCurrentPage - 1) / (pdfTotalPages - 1);
} else {
fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
}
// Chapter wrappers carry unique ids; seeing one twice means the book was
// rendered twice. Positions from that DOM are wrong — don't store them.
if (!currentPdfDoc && contentEl.querySelectorAll('#epub-chapter-0').length > 1) {
console.warn('Book rendered twice, progress not saved');
return;
}
// Not restored yet (or another book): what's shown isn't a place read.
const pos = _readPos;
if (!pos || !pos.ready || pos.bookId !== currentBookId) return;
// A jump that's only a look: the place before it stays.
if (pos.hold && !force) return;
let anchor = '';
if (!currentPdfDoc) {
anchor = getPositionAnchor(contentEl);
_currentPositionAnchor = anchor;
// EPUB: share of the text, the same on every device and layout.
const textFraction = _fractionFromAnchor(contentEl, anchor);
if (textFraction !== null) fraction = textFraction;
}
fraction = Math.min(1.0, Math.max(0.0, fraction));
_noteReadingPosition(anchor, fraction);
if (force) {
pos.readAt = Date.now();
pos.moved = true;
}
// Nothing read here since opening: the server has this place already.
if (!pos.moved) return;
const entry = {book: currentBookId, fraction, anchor, read_at: pos.readAt, device: _progressDevice(), ...(force && {force: true})};
const sig = `${entry.book}|${anchor}|${fraction.toFixed(5)}|${entry.read_at}`;
_lastProgressBeacon = entry;
if (!force && sig === pos.sentSig) return; // unchanged since the last save
pos.sentSig = sig;
_progressOutboxPut(entry);
_updateCachedBookProgress(currentBookId, fraction, anchor, force, pos.readAt);
const sent = await _sendProgress(entry);
if (!sent) pos.sentSig = null; // try again with the next save
if (sent && force) {
const toast = document.createElement('div');
toast.className = 'reader-toast';
toast.textContent = 'Position gesetzt';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 1800);
}
}
function closeReader() {
// Still opening: just leave it (nothing to save yet).
if (cancelBookOpening()) return;
if (typeof _issueReaderClosed === 'function') _issueReaderClosed();
// An article (65-article.js): where it was read to, before the reader empties.
if (typeof _articleReaderClosed === 'function') _articleReaderClosed();
exitReaderImmersiveMode();
stopReadAloud();
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
saveReaderProgress();
_readPos = null; // nothing more is saved for this book
if (bookmarksDirty) saveBookmarks();
if (highlightsDirty) saveHighlights();
const overlay = $('reader-overlay');
if (overlay) overlay.style.display = 'none';
if (readerScrollSaveTimer) {
clearInterval(readerScrollSaveTimer);
readerScrollSaveTimer = null;
}
if (_resizeObserver) {
_resizeObserver.disconnect();
_resizeObserver = null;
}
_currentPositionAnchor = '';
// Close the margin panel
readerAnnotationsMarginOpen = false;
const marginEl = $('reader-margin');
if (marginEl) marginEl.classList.remove('open');
_centerReaderColumn();
const marginMarkersEl = $('reader-margin-markers');
if (marginMarkersEl) marginMarkersEl.innerHTML = '';
// Disarm marker mode
markerModeActive = false;
document.querySelectorAll('.marker-mode-btn').forEach(b => b.classList.remove('active'));
dismissNoteBottomSheet();
// Clear search before wiping content
clearReaderSearch();
// Close settings panel if open
readerSettingsPanelOpen = false;
const sp = document.getElementById('reader-settings-panel');
if (sp) sp.remove();
// Reset progress input
const progressInput = $('reader-progress-input');
if (progressInput) { progressInput.style.display = 'none'; progressInput.value = 0; }
const progressSuffix = $('reader-progress-suffix');
if (progressSuffix) progressSuffix.textContent = '';
// Remove touch handlers and free image blob URLs
const contentEl = $('reader-content');
_readerUnlistenAll();
if (contentEl) {
contentEl.removeEventListener('click', _pdfPaginatedClick);
contentEl.removeEventListener('wheel', _pdfPaginatedWheel);
contentEl.classList.remove('pinch-active', 'pdf-paginated');
contentEl.style.overflow = '';
contentEl.innerHTML = '';
}
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
currentImageMap = {};
// Reset all state
currentBookId = null;
currentBookToc = [];
_currentBookCharCount = 0;
if (typeof _pdfStopLazy === 'function') _pdfStopLazy();
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
currentPdfDoc = null;
if (typeof _pdfForgetDoc === 'function') _pdfForgetDoc();
currentPdfBuffer = null;
currentBookmarks = [];
bookmarksDirty = false;
currentHighlights = [];
highlightsDirty = false;
_lastProgressBeacon = null;
_lastBookmarkBeacon = null;
_lastHighlightBeacon = null;
dismissHighlightPopover();
pdfCurrentPage = 1;
pdfTotalPages = 0;
_pdfPageTextBoxCache = {};
// Remove PDF invert class
if (overlay) overlay.classList.remove('pdf-inverted');
}
function openTocSidebar() {
if (!currentBookToc.length) {
openSidebar('Inhaltsverzeichnis', '<p class="muted">Dieses Buch hat kein Inhaltsverzeichnis.</p>');
return;
}
let html = '<ul class="toc-list">';
for (const entry of currentBookToc) {
const indent = entry.depth * 14;
// Use data-toc-href — onclick would be stripped by sanitizeSidebarHtml
html += `<li style="padding-left:${indent}px">
<button class="btn-link toc-entry" data-toc-href="${escapeHtml(entry.href)}">${escapeHtml(entry.label)}</button>
</li>`;
}
html += '</ul>';
openSidebar('Inhaltsverzeichnis', html);
// Attach delegated listener after sidebar body is populated
const body = $('sidebar-body');
body.addEventListener('click', function _tocClick(e) {
const btn = e.target.closest('.toc-entry');
if (btn) {
body.removeEventListener('click', _tocClick);
jumpToTocEntry(btn.getAttribute('data-toc-href') || '');
}
});
}
function jumpToTocEntry(href) {
closeSidebar();
_suppressScrollJumpDetect();
setTimeout(() => {
const contentEl = $('reader-content');
if (!contentEl) return;
// PDF page jump (scrolling or page by page)
if (href.startsWith('#pdf-page-')) {
pdfGoToPage(parseInt(href.slice('#pdf-page-'.length), 10) || 1);
return;
}
const hashIdx = href.indexOf('#');
const fragment = hashIdx >= 0 ? href.slice(hashIdx + 1) : '';
const filePath = hashIdx >= 0 ? href.slice(0, hashIdx) : href;
let target = null;
if (fragment) {
target = contentEl.querySelector(`#${CSS.escape(fragment)}`);
}
if (!target && filePath) {
target = Array.from(contentEl.querySelectorAll('[data-epub-src]'))
.find(el => el.getAttribute('data-epub-src') === filePath) || null;
}
if (target) {
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
}
}, 50);
}