Die Leiste nimmt ihre Breite links weg; dieselbe Breite kommt rechts als Abstand dazu, soweit Platz ist (die Spalte wird dafür nie schmaler). Beides gleitet gleichzeitig, der Text bleibt stehen. Neu berechnet beim Öffnen und Schließen der Leiste, bei Fenstergröße und Zeilenlänge/Schrift. e2e: Test dafür (breit: Spalte bleibt, schmal: weicht aus, bleibt gleich breit, liegt nicht unter der Leiste); Service Worker in den Tests aus (sein Neuladen störte mitten im Test). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
642 lines
25 KiB
JavaScript
642 lines
25 KiB
JavaScript
'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;
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
async function openBook(bookId) {
|
||
if (_openBookInFlight) {
|
||
if (_openBookInFlight.bookId === bookId) return _openBookInFlight.promise;
|
||
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 = '';
|
||
|
||
try {
|
||
loadReaderSettings(bookId);
|
||
_charIndex = null;
|
||
const key = await getOrCreateEncKey();
|
||
let data_ct, data_iv;
|
||
const cached = await _getCachedBook(bookId);
|
||
if (cached) {
|
||
({data_ct, data_iv} = cached);
|
||
} else {
|
||
let res;
|
||
try {
|
||
res = await fetch(`/books/${bookId}/data/`);
|
||
} catch {
|
||
// 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());
|
||
_setCachedBook(bookId, data_ct, data_iv); // fire-and-forget
|
||
}
|
||
const plain = await decryptBytes(key, data_iv, data_ct);
|
||
|
||
// 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) {
|
||
currentPdfDoc = null; // reset so renderPdf creates fresh doc
|
||
const loadingEl = document.createElement('div');
|
||
loadingEl.className = 'pdf-loading-overlay';
|
||
loadingEl.innerHTML = '<span class="pdf-loading-spinner"></span>';
|
||
overlay.appendChild(loadingEl);
|
||
const result = await renderPdf(plain, contentEl);
|
||
loadingEl.remove();
|
||
title = result.title || title;
|
||
author = result.author || author;
|
||
toc = result.toc;
|
||
numPages = result.numPages;
|
||
currentPdfBuffer = plain;
|
||
} 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 (!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));
|
||
}
|
||
// Used to decide whole-percent vs one-decimal display further down —
|
||
// see _formatBookPercent().
|
||
_currentBookCharCount = contentEl.textContent.length;
|
||
}
|
||
|
||
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
|
||
contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true});
|
||
contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false});
|
||
contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true});
|
||
contentEl.addEventListener('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 = '%';
|
||
}
|
||
|
||
progressInput.addEventListener('change', function () {
|
||
_suppressScrollJumpDetect();
|
||
if (isPdf) {
|
||
const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1));
|
||
this.value = page;
|
||
const target = contentEl.querySelector(`#pdf-page-${page}`);
|
||
if (target) {
|
||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||
contentEl.scrollBy({top: top - 8, behavior: 'smooth'});
|
||
}
|
||
} else {
|
||
const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value)));
|
||
this.value = _formatBookPercent(pct / 100);
|
||
restoreFromAnchor(contentEl, _anchorForFraction(contentEl, pct / 100));
|
||
}
|
||
});
|
||
|
||
progressInput.addEventListener('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);
|
||
} 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;
|
||
|
||
// Update progress input on scroll
|
||
contentEl.addEventListener('scroll', () => {
|
||
if (!progressInput) return;
|
||
if (isPdf) {
|
||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
||
const cTop = contentEl.getBoundingClientRect().top;
|
||
let currentPage = 1;
|
||
for (const w of wrappers) {
|
||
if (w.getBoundingClientRect().bottom > cTop + 20) {
|
||
currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1;
|
||
break;
|
||
}
|
||
}
|
||
progressInput.value = currentPage;
|
||
}
|
||
// EPUB: shown from the position anchor, see the anchor tracking below.
|
||
});
|
||
|
||
// 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;
|
||
contentEl.addEventListener('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;
|
||
contentEl.addEventListener('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;
|
||
contentEl.addEventListener('scroll', () => {
|
||
if (_scrollJumpRaf) return;
|
||
_scrollJumpRaf = requestAnimationFrame(() => {
|
||
_scrollJumpRaf = null;
|
||
_checkScrollJump(contentEl);
|
||
_repositionMarginMarkers();
|
||
});
|
||
}, {passive: true});
|
||
|
||
enterReaderImmersiveMode();
|
||
|
||
} catch (e) {
|
||
console.error('openBook failed:', e);
|
||
overlay.style.display = 'none';
|
||
markBookBroken(bookId);
|
||
}
|
||
}
|
||
|
||
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() {
|
||
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');
|
||
if (contentEl) {
|
||
contentEl.removeEventListener('touchstart', _pdfTouchStart);
|
||
contentEl.removeEventListener('touchmove', _pdfTouchMove);
|
||
contentEl.removeEventListener('touchend', _pdfTouchEnd);
|
||
contentEl.removeEventListener('touchcancel', _pdfTouchEnd);
|
||
contentEl.classList.remove('pinch-active');
|
||
contentEl.innerHTML = '';
|
||
}
|
||
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
|
||
currentImageMap = {};
|
||
|
||
// Reset all state
|
||
currentBookId = null;
|
||
currentBookToc = [];
|
||
_currentBookCharCount = 0;
|
||
currentPdfDoc = null;
|
||
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
|
||
if (href.startsWith('#pdf-page-')) {
|
||
const target = contentEl.querySelector(href);
|
||
if (target) {
|
||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
|
||
}
|
||
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);
|
||
}
|