'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 = ''; 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', `

Füge den Schlüssel ein, den du im anderen Browser exportiert hast:

Das ersetzt den Schlüssel in diesem Browser. Hier hochgeladene Bücher sind erst wieder lesbar, wenn du den Schlüssel zurück abgleichst.

`); 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'); 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', '

Dieses Buch hat kein Inhaltsverzeichnis.

'); return; } let html = ''; 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); }