65-article.js (DioraArticle): Artikel öffnen im selben Reader wie Bücher – Schrift, Farben, Seiten/Doppelseite mit Fußnoten am Seitenfuß, Suche, Vorlesen, Vollbild. Lesezeichen, Markierungen und Notizspalte bleiben Büchern vorbehalten. Merken, Projekt und Original stehen in der Kopfzeile (Apps: im Lesemenü). feeds.js gibt sein eigenes Overlay auf; Fortschritt wie bisher (bis zum unteren Bildschirmrand gelesen, ab 90 % gelesen). Einstellungen der Artikel gelten für alle Artikel zusammen. 62-reader.js: Doppelklick (Maus) auf den Text schaltet Vollbild an/aus, die Leisten gehen mit. audiobooks.js: die Hörbücher sind gebaut wie die Bücher – Kopf mit Suche, Sortierung und +, Chips Angefangen/Ungehört/Gehört und Ordner, „Weiterhören“ wie „Weiterlesen“, Kacheln aus denselben Bausteinen (53-library.js gibt seine Styles auch an #audiobook-shelf), Cover quadratisch. 63-pages.js: bei nur einem Kapitel zeigt die Fußzeile nur die Seiten. sw CACHE v158 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
742 lines
29 KiB
JavaScript
742 lines
29 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;
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
|
||
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 = '';
|
||
currentPdfDoc = null;
|
||
currentPdfBuffer = null;
|
||
_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) {
|
||
currentPdfDoc = null; // reset so renderPdf creates fresh doc
|
||
const result = await renderPdf(plain, contentEl);
|
||
if (left() || !result) return;
|
||
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 (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
|
||
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();
|
||
|
||
// 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');
|
||
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;
|
||
if (typeof _pdfStopLazy === 'function') _pdfStopLazy();
|
||
if (currentPdfDoc) { try { currentPdfDoc.destroy(); } catch (e) {} }
|
||
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);
|
||
}
|