diora-web/static/js/app/62-reader.js
mrwnslz a7d4d92f9e
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 8m19s
Test / e2e (push) Successful in 1m52s
Reader: offene Notizleiste schiebt den Text nicht mehr aus der Mitte; sw v107
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>
2026-10-14 19:43:03 +02:00

642 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// ---------------------------------------------------------------------------
// Immersive reader mode — tap centre of screen to toggle bars
// ---------------------------------------------------------------------------
let _immBarsVisible = true;
function _immHandleTap(e) {
// Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover)
if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet, .tts-bar')) return;
// In marker mode, taps have a dedicated meaning (highlight/create a note) —
// don't also toggle the immersive bars underneath.
if (markerModeActive) return;
// Don't toggle bars while the user has just finished selecting text (e.g. to highlight it)
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
// If a footnote popover is open, dismiss it instead of toggling bars
if (currentFootnotePopover && !currentFootnotePopover.contains(e.target)) {
dismissFootnotePopover();
return;
}
_immBarsVisible = !_immBarsVisible;
document.body.classList.toggle('reader-immersive', !_immBarsVisible);
// Close settings panel when bars disappear
if (!_immBarsVisible && readerSettingsPanelOpen) {
const sp = document.getElementById('reader-settings-panel');
if (sp) sp.remove();
readerSettingsPanelOpen = false;
}
}
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);
}