Der Lesestand wird gleich beim Öffnen erfragt (parallel zur Datei), ein PDF zeichnet zuerst die Seite, an der es weitergeht, statt Seite 1; Titel, Inhaltsverzeichnis und Seitenzahlen kommen nebenher und halten das Öffnen nicht mehr auf. Eine Zeichnung, die schon läuft, wird abgewartet statt neu begonnen. „Leserfarben“ (neben Originalfarben und Invertieren): die Seite in den Farben des Lesers (Dunkel, Sepia) per SVG-Filter, Bilder darüber wie gedruckt (ihre Lage aus den Zeichenbefehlen); ein Bild, das die halbe Seite füllt, ist ein Scan und wird mit umgefärbt. pdf.js' eigene pageColors färben die Bilder mit um. Ein anderes Design ändert nur die Farben des Filters. Seitenzahlen des Buchs (PageLabels: „iv“, „A-3“) im Kopf mit der Seite der Datei daneben, eingeben lässt sich beides; Lesezeichen und der Zurück-Knopf nennen sie. Das Test-PDF hat dafür Seitenzahlen und ein farbiges Bild bekommen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
785 lines
31 KiB
JavaScript
785 lines
31 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;
|
||
|
||
// 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 /**
|
||
* Book [bookId]'s entry with its place: the server's (offline: the one kept here), or an own
|
||
* newer one the server doesn't have yet. null when there's none.
|
||
*/
|
||
async function _bookProgress(bookId) {
|
||
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
|
||
return _bookProgressCached(bookId);
|
||
}
|
||
// The newer of the server's place and an own one it doesn't have yet.
|
||
return bookData ? _withPendingProgress(bookData) : null;
|
||
}
|
||
|
||
/** The same from what's kept on this device only (no waiting for the network). */
|
||
async function _bookProgressCached(bookId) {
|
||
try {
|
||
const cached = await _loadCachedBookMeta();
|
||
const bookData = cached.find(b => b.id === bookId) || null;
|
||
return bookData ? _withPendingProgress(bookData) : null;
|
||
} catch {
|
||
return 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;
|
||
// The place this book was read at: asked for at once, alongside the book's file (a PDF
|
||
// opens at that page instead of drawing page 1 first).
|
||
const progressP = _bookProgress(bookId).catch(() => null);
|
||
|
||
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;
|
||
// The colours of the reader first: the pages may be drawn in them (Leserfarben).
|
||
applyReaderSettings(true);
|
||
// The page it opens at: the place saved, if it's known by now (else the one kept here).
|
||
const saved = await Promise.race([progressP, new Promise(r => setTimeout(() => r(undefined), 400))])
|
||
|| await _bookProgressCached(bookId);
|
||
const startFraction = saved ? (saved.scroll_fraction || 0) : 0;
|
||
const result = await renderPdf(plain, contentEl, undefined,
|
||
n => (startFraction > 0 && n > 1 ? Math.max(1, Math.min(n, Math.round(startFraction * (n - 1)) + 1)) : 1));
|
||
if (left() || !result) return;
|
||
title = result.title || title;
|
||
author = result.author || author;
|
||
toc = result.toc;
|
||
numPages = result.numPages;
|
||
// The table of contents follows when its pages are resolved.
|
||
result.tocLater.then(entries => {
|
||
if (currentBookId === bookId && currentPdfDoc && entries.length) currentBookToc = entries;
|
||
});
|
||
// 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) {
|
||
// The book's page number ("iv", "12") or the file's.
|
||
const page = Math.min(numPages, Math.max(1, _pdfPageFromInput(this.value) || pdfCurrentPage || 1));
|
||
this.value = pdfPageLabel(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 = '';
|
||
const bookData = await progressP;
|
||
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);
|
||
}
|