';
}
}
function renderBookList(books) {
const listEl = $('book-list');
if (!listEl) return;
let html = '';
for (const b of books) {
const pct = Math.round((b.scroll_fraction || 0) * 100);
html += `
`;
}
listEl.innerHTML = html;
}
function bookFileSelected(input) {
const file = input.files[0];
if (!file) return;
uploadEbook(file);
input.value = '';
}
function initBookDropZone() {
// Prevent Firefox from opening dragged files when dropped outside the zone
document.addEventListener('dragover', e => e.preventDefault());
document.addEventListener('drop', e => {
const zone = $('book-drop-zone');
if (!zone || !zone.contains(e.target)) e.preventDefault();
});
const zone = $('book-drop-zone');
if (!zone) return;
zone.addEventListener('dragover', e => {
e.preventDefault();
zone.classList.add('drag-over');
});
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) uploadEbook(file);
});
}
async function deriveAndStoreKey() {
const pwInput = document.getElementById('enc-key-password');
const statusEl = $('enc-key-status');
const pw = pwInput ? pwInput.value : '';
if (!pw) { if (statusEl) statusEl.textContent = 'Please enter your password.'; return; }
if (statusEl) statusEl.textContent = 'Deriving key…';
try {
const enc = new TextEncoder();
const username = document.querySelector('meta[name="username"]')?.content || '';
const mat = await crypto.subtle.importKey('raw', enc.encode(pw), 'PBKDF2', false, ['deriveKey']);
const key = await crypto.subtle.deriveKey(
{name: 'PBKDF2', salt: enc.encode('diora:' + username), iterations: 200000, hash: 'SHA-256'},
mat, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']
);
const raw = await crypto.subtle.exportKey('raw', key);
const storageKey = `diora_enc_key_${window.USER_ID || 0}`;
localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw)));
_encKey = null; // reset cached key
if (statusEl) statusEl.textContent = '✓ Unlocked';
const prompt = $('enc-key-prompt');
const uploadArea = $('book-upload-area');
if (prompt) prompt.style.display = 'none';
if (uploadArea) uploadArea.style.display = '';
loadBookList();
} catch (err) {
if (statusEl) statusEl.textContent = 'Error: ' + err.message;
}
}
async function uploadEbook(file) {
const statusEl = $('book-upload-status');
const isPdf = /\.pdf$/i.test(file.name);
const isEpub = /\.epub$/i.test(file.name);
if (!isPdf && !isEpub) {
if (statusEl) statusEl.textContent = 'Only .epub and .pdf files are supported.';
return;
}
if (file.size > 10 * 1024 * 1024) {
if (statusEl) statusEl.textContent = 'File too large (max 10 MB).';
return;
}
if (statusEl) statusEl.textContent = 'Encrypting…';
try {
const buf = await file.arrayBuffer();
let title = file.name.replace(/\.(epub|pdf)$/i, '');
let author = '';
const type = isPdf ? 'pdf' : 'epub';
if (isPdf) {
try {
const pdfDoc = await pdfjsLib.getDocument({data: new Uint8Array(buf.slice(0))}).promise;
const meta = await pdfDoc.getMetadata();
title = meta.info?.Title?.trim() || title;
author = meta.info?.Author?.trim() || '';
} catch (e) { /* use filename as title */ }
} else {
try {
const zip = await JSZip.loadAsync(buf.slice(0));
const containerXml = await zip.file('META-INF/container.xml').async('text');
const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml');
const opfPath = containerDoc.querySelector('rootfile')?.getAttribute('full-path');
if (opfPath) {
const opfText = await zip.file(opfPath).async('text');
const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml');
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
}
} catch (e) { /* use filename as title */ }
}
const key = await getOrCreateEncKey();
const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename: file.name, type}));
const [metaEnc, dataEnc] = await Promise.all([
encryptBytes(key, metaJson),
encryptBytes(key, buf),
]);
if (statusEl) statusEl.textContent = 'Uploading…';
const res = await fetch('/books/upload/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({
meta_ct: metaEnc.ciphertext,
meta_iv: metaEnc.iv,
data_ct: dataEnc.ciphertext,
data_iv: dataEnc.iv,
}),
});
const data = await res.json();
if (data.ok) {
if (statusEl) statusEl.textContent = `✓ "${title}" uploaded`;
loadBookList();
} else {
if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'upload failed');
}
} catch (e) {
if (statusEl) statusEl.textContent = 'Upload failed: ' + e.message;
}
}
async function _parsePdfOutline(pdf, items, depth) {
depth = depth || 0;
const result = [];
for (const item of items) {
let href = '';
if (item.dest) {
try {
const dest = typeof item.dest === 'string' ? await pdf.getDestination(item.dest) : item.dest;
if (dest) {
const pageIndex = await pdf.getPageIndex(dest[0]);
href = `#pdf-page-${pageIndex + 1}`;
}
} catch (e) {}
}
result.push({label: item.title || '(untitled)', href, depth});
if (item.items && item.items.length) {
result.push(...await _parsePdfOutline(pdf, item.items, depth + 1));
}
}
return result;
}
async function renderPdf(arrayBuffer, contentEl, scaleOverride) {
const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer)}).promise;
currentPdfDoc = pdf;
let pdfTitle = '', pdfAuthor = '';
try {
const meta = await pdf.getMetadata();
pdfTitle = meta.info?.Title?.trim() || '';
pdfAuthor = meta.info?.Author?.trim() || '';
} catch (e) {}
let toc = [];
try {
const outline = await pdf.getOutline();
if (outline && outline.length) toc = await _parsePdfOutline(pdf, outline);
} catch (e) {}
contentEl.innerHTML = '';
const containerWidth = contentEl.clientWidth - 32;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const naturalVp = page.getViewport({scale: 1});
const scale = scaleOverride != null ? scaleOverride
: Math.max(0.5, (containerWidth / naturalVp.width) * (readerSettings.pdfZoom / 100));
const viewport = page.getViewport({scale});
const wrapper = document.createElement('div');
wrapper.className = 'pdf-page-wrapper';
wrapper.id = `pdf-page-${pageNum}`;
// Inner container gives canvas + text layer a shared position:relative origin,
// independent of the outer flex wrapper's centering.
const inner = document.createElement('div');
inner.className = 'pdf-page-inner';
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page';
canvas.width = viewport.width;
canvas.height = viewport.height;
inner.appendChild(canvas);
wrapper.appendChild(inner);
contentEl.appendChild(wrapper);
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
// Text layer disabled — re-enable once overlay rendering is resolved
}
pdfTotalPages = pdf.numPages;
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
}
async function openBook(bookId) {
const overlay = $('reader-overlay');
const contentEl = $('reader-content');
const titleEl = $('reader-title');
if (!overlay || !contentEl) return;
titleEl.textContent = 'Loading…';
contentEl.innerHTML = '';
overlay.style.display = '';
try {
loadReaderSettings();
const key = await getOrCreateEncKey();
const res = await fetch(`/books/${bookId}/data/`);
const {data_ct, data_iv} = await res.json();
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 result = await renderPdf(plain, contentEl);
title = result.title || title;
author = result.author || author;
toc = result.toc;
numPages = result.numPages;
currentPdfBuffer = plain;
} else {
currentPdfBuffer = null;
const result = await parseEpub(plain);
title = result.title || title;
author = result.author || author;
toc = result.toc;
currentImageMap = result.imageMap;
contentEl.innerHTML = result.html;
}
currentBookToc = toc;
titleEl.textContent = title + (author ? ` — ${author}` : '');
currentBookId = bookId;
// Load bookmarks and highlights
await Promise.all([
loadBookmarks(bookId),
loadHighlights(bookId),
]);
// Apply reader settings (theme, font size, etc.)
applyReaderSettings(isPdfBook);
// Enable PDF paginated mode if configured (auto on mobile)
if (isPdfBook && readerSettings.pdfPaginated) {
enterPdfPaginatedMode();
}
// Wire highlight selection listener for EPUB
if (!isPdfBook) {
contentEl.addEventListener('mouseup', handleReaderSelection);
}
// Swipe for PDF paginated
contentEl.addEventListener('touchstart', e => { _touchStartX = e.touches[0].clientX; }, {passive: true});
contentEl.addEventListener('touchend', e => {
if (!readerSettings.pdfPaginated) return;
const delta = e.changedTouches[0].clientX - _touchStartX;
if (delta > 50) pdfGoToPage(pdfCurrentPage - 1);
else if (delta < -50) pdfGoToPage(pdfCurrentPage + 1);
}, {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.min = 1;
progressInput.max = numPages;
progressInput.value = 1;
if (progressSuffix) progressSuffix.textContent = `/ ${numPages}`;
} else {
progressInput.min = 0;
progressInput.max = 100;
progressInput.value = 0;
if (progressSuffix) progressSuffix.textContent = '%';
}
progressInput.addEventListener('change', function () {
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, parseInt(this.value, 10) || 0));
this.value = pct;
contentEl.scrollTop = (pct / 100) * contentEl.scrollHeight;
}
});
progressInput.addEventListener('click', function () { this.select(); });
}
// Restore scroll position
try {
const progressRes = await fetch('/books/');
const allBooks = await progressRes.json();
const bookData = allBooks.find(b => b.id === bookId);
const fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
if (fraction > 0) {
if (isPdf && readerSettings.pdfPaginated && pdfTotalPages > 1) {
pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1);
} else {
// For EPUB: wait for all images to load so scrollHeight is final
if (!isPdf) {
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; })
));
}
}
// One more rAF to let the browser recalculate layout after image load
await new Promise(r => requestAnimationFrame(r));
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
}
}
} catch (e) {}
// 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;
} else {
const f = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
progressInput.value = Math.round(f * 100);
}
});
// Auto-save progress every 10s and on scroll (debounced 2s)
readerScrollSaveTimer = setInterval(saveReaderProgress, 10000);
let _scrollDebounce = null;
contentEl.addEventListener('scroll', () => {
clearTimeout(_scrollDebounce);
_scrollDebounce = setTimeout(saveReaderProgress, 2000);
}, {passive: true});
// Determine which station to play (null = use default, {url:''} = disabled)
const focusStation = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION
: (USER_FOCUS_STATION.url ? USER_FOCUS_STATION : null);
if (focusStation) {
if (isPlaying) {
// Don't interrupt — highlight button, play on click instead
const btn = $('focus-station-btn');
if (btn) {
btn.classList.add('focus-pending');
btn.title = `Click to play focus station: ${focusStation.name}`;
btn._pendingFocusStation = focusStation;
btn.onclick = function () {
playStation(focusStation.url, focusStation.name, null);
btn.classList.remove('focus-pending');
btn.title = 'Focus station';
btn._pendingFocusStation = null;
btn.onclick = openFocusStationSidebar;
};
}
} else {
playStation(focusStation.url, focusStation.name, null);
}
}
} catch (e) {
contentEl.innerHTML = `
Failed to open book: ${escapeHtml(e.message)}
`;
}
}
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 = '✓ Key copied to clipboard';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000);
} catch (e) {
if (statusEl) statusEl.textContent = 'Export failed: ' + e.message;
}
}
function showImportKey() {
const body = $('sidebar-body');
openSidebar('Import encryption key', `
Paste the key exported from your other browser:
This replaces the key in this browser. Books uploaded here won't be readable until you sync the key back.
`);
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 = '✓ Key imported — reloading books…';
await loadBookList();
if (statusEl) setTimeout(() => { statusEl.textContent = ''; }, 3000);
} catch (e) {
if ($('book-key-status')) $('book-key-status').textContent = 'Import failed: invalid key';
}
});
}
async function deleteBook(bookId) {
if (!confirm('Delete this book? This cannot be undone.')) 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) {}
}
async function saveReaderProgress() {
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);
}
fraction = Math.min(1.0, Math.max(0.0, fraction));
// Cache for sendBeacon on unload
_lastProgressBeacon = {
url: `/books/${currentBookId}/progress/`,
body: JSON.stringify({scroll_fraction: fraction}),
};
try {
await fetch(`/books/${currentBookId}/progress/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: _lastProgressBeacon.body,
});
} catch (e) {}
}
function closeReader() {
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
saveReaderProgress();
if (bookmarksDirty) saveBookmarks();
if (highlightsDirty) saveHighlights();
const overlay = $('reader-overlay');
if (overlay) overlay.style.display = 'none';
if (readerScrollSaveTimer) {
clearInterval(readerScrollSaveTimer);
readerScrollSaveTimer = null;
}
// 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 = '';
// Free image blob URLs
const contentEl = $('reader-content');
if (contentEl) contentEl.innerHTML = '';
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
currentImageMap = {};
// Reset all state
currentBookId = null;
currentBookToc = [];
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');
// Clear any pending focus station highlight
const btn = $('focus-station-btn');
if (btn && btn._pendingFocusStation) {
btn.classList.remove('focus-pending');
btn.title = 'Focus station';
btn._pendingFocusStation = null;
btn.onclick = openFocusStationSidebar;
}
}
function openTocSidebar() {
if (!currentBookToc.length) {
openSidebar('Table of Contents', '
No table of contents found in this book.
');
return;
}
let html = '
';
for (const entry of currentBookToc) {
const indent = entry.depth * 14;
// Use data-toc-href — onclick would be stripped by sanitizeSidebarHtml
html += `
`;
}
html += '
';
openSidebar('Table of Contents', 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();
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);
}
// ---------------------------------------------------------------------------
// Reader Settings
// ---------------------------------------------------------------------------
function loadReaderSettings() {
try {
const saved = JSON.parse(localStorage.getItem('diora_reader_settings') || '{}');
Object.assign(readerSettings, saved);
// Auto-paginate on mobile if not explicitly set
if (saved.pdfPaginated === undefined) {
readerSettings.pdfPaginated = window.innerWidth < 768;
}
} catch (e) {}
}
function saveReaderSettings() {
localStorage.setItem('diora_reader_settings', JSON.stringify(readerSettings));
}
function applyReaderSettings(isPdf) {
const overlay = $('reader-overlay');
const contentEl = $('reader-content');
if (!overlay || !contentEl) return;
if (!isPdf) {
contentEl.style.fontSize = readerSettings.fontSize + 'px';
contentEl.style.lineHeight = readerSettings.lineHeight;
contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch');
}
// Theme
overlay.classList.remove('reader-theme-sepia', 'reader-theme-bright');
if (readerSettings.theme === 'sepia') overlay.classList.add('reader-theme-sepia');
else if (readerSettings.theme === 'bright') overlay.classList.add('reader-theme-bright');
// PDF invert
if (isPdf && readerSettings.pdfInverted) overlay.classList.add('pdf-inverted');
else overlay.classList.remove('pdf-inverted');
}
function toggleSettingsPanel() {
const overlay = $('reader-overlay');
const contentEl = $('reader-content');
if (!overlay || !contentEl) return;
const existing = document.getElementById('reader-settings-panel');
if (existing) {
existing.remove();
readerSettingsPanelOpen = false;
return;
}
readerSettingsPanelOpen = true;
const isPdf = !!currentPdfDoc;
const panel = document.createElement('div');
panel.id = 'reader-settings-panel';
panel.className = 'reader-settings-panel';
if (!isPdf) {
panel.innerHTML = `
`;
} else {
panel.innerHTML = `
`;
}
overlay.insertBefore(panel, contentEl);
if (!isPdf) {
const fontRange = panel.querySelector('#rs-font');
const fontVal = panel.querySelector('#rs-font-val');
fontRange.addEventListener('input', () => {
readerSettings.fontSize = parseInt(fontRange.value, 10);
fontVal.textContent = readerSettings.fontSize + 'px';
applyReaderSettings(false);
saveReaderSettings();
});
const lineRange = panel.querySelector('#rs-line');
const lineVal = panel.querySelector('#rs-line-val');
lineRange.addEventListener('input', () => {
readerSettings.lineHeight = (parseInt(lineRange.value, 10) / 10).toFixed(1);
lineVal.textContent = readerSettings.lineHeight;
applyReaderSettings(false);
saveReaderSettings();
});
const widthRange = panel.querySelector('#rs-width');
const widthVal = panel.querySelector('#rs-width-val');
widthRange.addEventListener('input', () => {
readerSettings.maxWidth = parseInt(widthRange.value, 10);
widthVal.textContent = readerSettings.maxWidth + 'ch';
applyReaderSettings(false);
saveReaderSettings();
});
panel.querySelector('#rs-width-full').addEventListener('click', () => {
readerSettings.maxWidth = 999;
widthRange.value = 90;
widthVal.textContent = 'full';
applyReaderSettings(false);
saveReaderSettings();
});
panel.querySelectorAll('[data-rs-theme]').forEach(btn => {
btn.addEventListener('click', () => {
readerSettings.theme = btn.dataset.rsTheme;
panel.querySelectorAll('[data-rs-theme]').forEach(b => b.classList.toggle('active', b === btn));
applyReaderSettings(false);
saveReaderSettings();
});
});
} else {
const zoomRange = panel.querySelector('#rs-zoom');
const zoomVal = panel.querySelector('#rs-zoom-val');
zoomRange.addEventListener('change', () => {
readerSettings.pdfZoom = parseInt(zoomRange.value, 10);
zoomVal.textContent = readerSettings.pdfZoom + '%';
saveReaderSettings();
reRenderPdf();
});
panel.querySelector('#rs-invert').addEventListener('click', function () {
readerSettings.pdfInverted = !readerSettings.pdfInverted;
this.classList.toggle('active', readerSettings.pdfInverted);
applyReaderSettings(true);
saveReaderSettings();
});
panel.querySelector('#rs-paginated').addEventListener('click', function () {
readerSettings.pdfPaginated = !readerSettings.pdfPaginated;
this.classList.toggle('active', readerSettings.pdfPaginated);
saveReaderSettings();
if (readerSettings.pdfPaginated) {
enterPdfPaginatedMode();
} else {
exitPdfPaginatedMode();
}
});
}
}
async function reRenderPdf() {
if (!currentPdfBuffer) return;
const contentEl = $('reader-content');
if (!contentEl) return;
currentPdfDoc = null; // force re-parse with same buffer
await renderPdf(currentPdfBuffer, contentEl);
if (readerSettings.pdfPaginated) enterPdfPaginatedMode();
}
// ---------------------------------------------------------------------------
// PDF Paginated Mode
// ---------------------------------------------------------------------------
function enterPdfPaginatedMode() {
const contentEl = $('reader-content');
if (!contentEl) return;
contentEl.classList.add('pdf-paginated');
contentEl.style.overflow = 'hidden';
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
wrappers.forEach((w, i) => {
w.style.display = (i + 1 === pdfCurrentPage) ? '' : 'none';
});
pdfSmartZoomPage(pdfCurrentPage);
// Tap left/right to navigate
contentEl.addEventListener('click', _pdfPaginatedClick);
}
function exitPdfPaginatedMode() {
const contentEl = $('reader-content');
if (!contentEl) return;
contentEl.classList.remove('pdf-paginated');
contentEl.style.overflow = '';
contentEl.removeEventListener('click', _pdfPaginatedClick);
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
wrappers.forEach(w => {
w.style.display = '';
const canvas = w.querySelector('canvas');
if (canvas) canvas.style.transform = '';
});
}
function _pdfPaginatedClick(e) {
const w = e.currentTarget.clientWidth;
if (e.clientX < w * 0.4) pdfGoToPage(pdfCurrentPage - 1);
else if (e.clientX > w * 0.6) pdfGoToPage(pdfCurrentPage + 1);
}
function pdfGoToPage(n) {
if (!currentPdfDoc) return;
n = Math.max(1, Math.min(pdfTotalPages, n));
if (n === pdfCurrentPage) return;
const contentEl = $('reader-content');
if (!contentEl) return;
const oldWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`);
if (oldWrapper) oldWrapper.style.display = 'none';
pdfCurrentPage = n;
const newWrapper = contentEl.querySelector(`#pdf-page-${pdfCurrentPage}`);
if (newWrapper) newWrapper.style.display = '';
pdfSmartZoomPage(pdfCurrentPage);
const progressInput = $('reader-progress-input');
if (progressInput) progressInput.value = pdfCurrentPage;
}
async function pdfSmartZoomPage(pageNum) {
if (!currentPdfDoc) return;
const contentEl = $('reader-content');
if (!contentEl) return;
const wrapper = contentEl.querySelector(`#pdf-page-${pageNum}`);
if (!wrapper) return;
const canvas = wrapper.querySelector('canvas');
if (!canvas) return;
const page = await currentPdfDoc.getPage(pageNum);
const naturalVp = page.getViewport({scale: 1});
const pageW = naturalVp.width;
const pageH = naturalVp.height;
let bbox = _pdfPageTextBoxCache[pageNum];
if (!bbox) {
bbox = await _computePdfTextBox(page, pageW, pageH);
_pdfPageTextBoxCache[pageNum] = bbox;
}
const containerW = contentEl.clientWidth;
const containerH = contentEl.clientHeight;
const contentW = bbox.x2 - bbox.x1;
const contentH = bbox.y2 - bbox.y1;
const pad = 12;
const scale = Math.min(
(containerW - pad * 2) / contentW,
(containerH - pad * 2) / contentH
);
// Re-render canvas at new scale if significantly different
const currentScale = canvas.width / naturalVp.width;
if (Math.abs(scale - currentScale) / currentScale > 0.05) {
const vp = page.getViewport({scale});
canvas.width = vp.width;
canvas.height = vp.height;
await page.render({canvasContext: canvas.getContext('2d'), viewport: vp}).promise;
}
// Position canvas to center the text bounding box
const renderedScale = canvas.width / naturalVp.width;
const offsetX = -renderedScale * (bbox.x1 - pad) + (containerW - renderedScale * contentW - pad * 2) / 2;
// PDF y-axis is bottom-up; canvas is top-down
const offsetY = -renderedScale * (pageH - bbox.y2 - pad) + (containerH - renderedScale * contentH - pad * 2) / 2;
canvas.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
wrapper.style.overflow = 'hidden';
wrapper.style.width = containerW + 'px';
wrapper.style.height = containerH + 'px';
}
async function _computePdfTextBox(page, pageW, pageH) {
// Tier 1: text-based
try {
const tc = await page.getTextContent();
if (tc.items && tc.items.length) {
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
for (const item of tc.items) {
if (!item.transform) continue;
const tx = item.transform[4], ty = item.transform[5];
const iw = item.width || 0, ih = item.height || 0;
if (tx < x1) x1 = tx;
if (ty < y1) y1 = ty;
if (tx + iw > x2) x2 = tx + iw;
if (ty + ih > y2) y2 = ty + ih;
}
const area = (x2 - x1) * (y2 - y1);
if (isFinite(x1) && area > pageW * pageH * 0.25) {
return {x1, y1, x2, y2};
}
}
} catch (e) {}
// Tier 2: pixel analysis at scale 0.3
try {
const lowScale = 0.3;
const vp = page.getViewport({scale: lowScale});
const offCanvas = document.createElement('canvas');
offCanvas.width = vp.width;
offCanvas.height = vp.height;
const ctx = offCanvas.getContext('2d');
await page.render({canvasContext: ctx, viewport: vp}).promise;
const {data, width, height} = ctx.getImageData(0, 0, vp.width, vp.height);
let rMin = height, rMax = 0, cMin = width, cMax = 0;
for (let r = 0; r < height; r++) {
for (let c = 0; c < width; c++) {
const idx = (r * width + c) * 4;
if (data[idx] + data[idx+1] + data[idx+2] < 720) {
if (r < rMin) rMin = r;
if (r > rMax) rMax = r;
if (c < cMin) cMin = c;
if (c > cMax) cMax = c;
}
}
}
if (rMin < rMax && cMin < cMax) {
return {
x1: cMin / lowScale,
y1: (height - rMax) / lowScale,
x2: cMax / lowScale,
y2: (height - rMin) / lowScale,
};
}
} catch (e) {}
// Fallback: full page
return {x1: 0, y1: 0, x2: pageW, y2: pageH};
}
// ---------------------------------------------------------------------------
// Bookmarks
// ---------------------------------------------------------------------------
async function loadBookmarks(bookId) {
try {
const res = await fetch(`/books/${bookId}/bookmarks/`);
const {ct, iv} = await res.json();
if (ct) {
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, iv, ct);
currentBookmarks = JSON.parse(new TextDecoder().decode(plain));
} else {
currentBookmarks = [];
}
} catch (e) {
currentBookmarks = [];
}
}
async function saveBookmarks() {
if (!currentBookId) return;
try {
const key = await getOrCreateEncKey();
const plain = new TextEncoder().encode(JSON.stringify(currentBookmarks));
const {iv, ciphertext} = await encryptBytes(key, plain);
const body = JSON.stringify({ct: ciphertext, iv});
const url = `/books/${currentBookId}/bookmarks/`;
_lastBookmarkBeacon = {url, body};
await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body,
});
bookmarksDirty = false;
} catch (e) {}
}
function addBookmark() {
const contentEl = $('reader-content');
if (!contentEl || !currentBookId) return;
let label, anchor, scrollFraction;
if (currentPdfDoc) {
const page = pdfCurrentPage || parseInt($('reader-progress-input')?.value, 10) || 1;
label = `Page ${page}`;
anchor = `pdf-page-${page}`;
scrollFraction = (page - 1) / Math.max(1, pdfTotalPages - 1);
} else {
// Find first visible chapter div
const chapters = contentEl.querySelectorAll('[data-epub-src]');
let visibleChapter = null;
for (const ch of chapters) {
const rect = ch.getBoundingClientRect();
if (rect.bottom > 0 && rect.top < window.innerHeight) {
visibleChapter = ch;
break;
}
}
const src = visibleChapter?.getAttribute('data-epub-src') || '';
label = src.split('/').pop().replace(/\.x?html?$/i, '') || 'Bookmark';
anchor = src;
scrollFraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
}
const bm = {
id: crypto.randomUUID(),
label,
anchor,
scrollFraction,
createdAt: new Date().toISOString(),
};
currentBookmarks.unshift(bm);
bookmarksDirty = true;
saveBookmarks();
// Toast
const toast = document.createElement('div');
toast.className = 'reader-toast';
toast.textContent = `★ Bookmarked: ${label}`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2200);
}
function openBookmarksSidebar() {
if (!currentBookmarks.length) {
openSidebar('Bookmarks', '
No bookmarks yet. Press ★ while reading.
');
return;
}
let html = '
';
for (const bm of currentBookmarks) {
html += `
`;
}
html += '
';
openSidebar('Bookmarks', html);
const body = $('sidebar-body');
body.addEventListener('click', function _bmClick(e) {
const jumpBtn = e.target.closest('[data-jump-bookmark]');
const delBtn = e.target.closest('[data-delete-bookmark]');
if (jumpBtn) {
body.removeEventListener('click', _bmClick);
jumpToBookmark(jumpBtn.dataset.jumpBookmark);
}
if (delBtn) {
const id = delBtn.dataset.deleteBookmark;
currentBookmarks = currentBookmarks.filter(b => b.id !== id);
bookmarksDirty = true;
saveBookmarks();
openBookmarksSidebar(); // re-render
}
});
}
function jumpToBookmark(id) {
const bm = currentBookmarks.find(b => b.id === id);
if (!bm) return;
closeSidebar();
setTimeout(() => {
const contentEl = $('reader-content');
if (!contentEl) return;
if (bm.anchor.startsWith('pdf-page-')) {
if (readerSettings.pdfPaginated) {
pdfGoToPage(parseInt(bm.anchor.replace('pdf-page-', ''), 10) || 1);
} else {
const target = contentEl.querySelector('#' + bm.anchor);
if (target) {
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
}
}
} else {
const target = Array.from(contentEl.querySelectorAll('[data-epub-src]'))
.find(el => el.getAttribute('data-epub-src') === bm.anchor);
if (target) {
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
} else {
contentEl.scrollTop = bm.scrollFraction * (contentEl.scrollHeight - contentEl.clientHeight);
}
}
}, 50);
}
// ---------------------------------------------------------------------------
// Reader Search
// ---------------------------------------------------------------------------
let _readerSearchDebounce = null;
function toggleReaderSearch() {
const overlay = $('reader-overlay');
const contentEl = $('reader-content');
if (!overlay || !contentEl) return;
const existing = document.getElementById('reader-search-bar');
if (existing) {
existing.remove();
readerSearchOpen = false;
clearReaderSearch();
return;
}
readerSearchOpen = true;
const bar = document.createElement('div');
bar.id = 'reader-search-bar';
bar.className = 'reader-search-bar';
bar.innerHTML = `
`;
overlay.insertBefore(bar, contentEl);
const input = bar.querySelector('#reader-search-input');
input.focus();
input.addEventListener('input', () => {
clearTimeout(_readerSearchDebounce);
_readerSearchDebounce = setTimeout(() => doReaderSearch(input.value.trim()), 300);
});
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.shiftKey ? readerSearchPrev() : readerSearchNext(); }
if (e.key === 'Escape') { toggleReaderSearch(); }
});
bar.querySelector('#rs-search-prev').addEventListener('click', readerSearchPrev);
bar.querySelector('#rs-search-next').addEventListener('click', readerSearchNext);
bar.querySelector('#rs-search-clear').addEventListener('click', toggleReaderSearch);
}
async function doReaderSearch(query) {
const contentEl = $('reader-content');
if (!contentEl) return;
const countEl = document.getElementById('rs-search-count');
clearReaderSearchHighlights();
searchMatches = [];
searchMatchIndex = -1;
if (!query) { if (countEl) countEl.textContent = ''; return; }
if (!currentPdfDoc) {
// EPUB: snapshot original content
if (!searchOriginalContent) {
searchOriginalContent = contentEl.innerHTML;
} else {
contentEl.innerHTML = searchOriginalContent;
applyHighlightsToContent();
}
const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT);
const lq = query.toLowerCase();
const ranges = [];
let node;
while ((node = walker.nextNode())) {
const text = node.textContent;
const lt = text.toLowerCase();
let idx = 0;
while ((idx = lt.indexOf(lq, idx)) !== -1) {
const range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + query.length);
ranges.push(range);
idx += query.length;
}
}
// Insert marks in reverse to preserve range validity
for (let i = ranges.length - 1; i >= 0; i--) {
try {
const mark = document.createElement('mark');
mark.className = 'reader-search-match';
ranges[i].surroundContents(mark);
searchMatches.unshift(mark);
} catch (e) {}
}
} else {
// PDF: collect text layer spans
const spans = contentEl.querySelectorAll('.pdf-text-layer > span');
const lq = query.toLowerCase();
for (const span of spans) {
if (span.textContent.toLowerCase().includes(lq)) {
span.classList.add('reader-search-match');
searchMatches.push(span);
}
}
}
if (countEl) countEl.textContent = searchMatches.length ? `1 / ${searchMatches.length}` : '0';
if (searchMatches.length) {
searchMatchIndex = 0;
scrollToSearchMatch(0);
}
}
function clearReaderSearchHighlights() {
if (!currentPdfDoc) {
// EPUB: restore from snapshot
if (searchOriginalContent !== null) {
const contentEl = $('reader-content');
if (contentEl) {
contentEl.innerHTML = searchOriginalContent;
applyHighlightsToContent();
}
searchOriginalContent = null;
} else {
// Just remove marks without full restore
document.querySelectorAll('mark.reader-search-match').forEach(m => {
const parent = m.parentNode;
while (m.firstChild) parent.insertBefore(m.firstChild, m);
parent.removeChild(m);
});
}
} else {
// PDF: remove highlight class from spans
document.querySelectorAll('.reader-search-match').forEach(el => {
el.classList.remove('reader-search-match', 'active');
});
}
searchMatches = [];
searchMatchIndex = -1;
}
function clearReaderSearch() {
clearTimeout(_readerSearchDebounce);
clearReaderSearchHighlights();
readerSearchOpen = false;
const countEl = document.getElementById('rs-search-count');
if (countEl) countEl.textContent = '';
}
function scrollToSearchMatch(idx) {
if (!searchMatches.length) return;
searchMatches.forEach((m, i) => m.classList.toggle('active', i === idx));
searchMatches[idx].scrollIntoView({behavior: 'smooth', block: 'center'});
const countEl = document.getElementById('rs-search-count');
if (countEl) countEl.textContent = `${idx + 1} / ${searchMatches.length}`;
}
function readerSearchNext() {
if (!searchMatches.length) return;
searchMatchIndex = (searchMatchIndex + 1) % searchMatches.length;
scrollToSearchMatch(searchMatchIndex);
}
function readerSearchPrev() {
if (!searchMatches.length) return;
searchMatchIndex = (searchMatchIndex - 1 + searchMatches.length) % searchMatches.length;
scrollToSearchMatch(searchMatchIndex);
}
// ---------------------------------------------------------------------------
// Highlights
// ---------------------------------------------------------------------------
async function loadHighlights(bookId) {
try {
const res = await fetch(`/books/${bookId}/highlights/`);
const {ct, iv} = await res.json();
if (ct) {
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, iv, ct);
currentHighlights = JSON.parse(new TextDecoder().decode(plain));
} else {
currentHighlights = [];
}
applyHighlightsToContent();
} catch (e) {
currentHighlights = [];
}
}
async function saveHighlights() {
if (!currentBookId) return;
try {
const key = await getOrCreateEncKey();
const plain = new TextEncoder().encode(JSON.stringify(currentHighlights));
const {iv, ciphertext} = await encryptBytes(key, plain);
const body = JSON.stringify({ct: ciphertext, iv});
const url = `/books/${currentBookId}/highlights/`;
_lastHighlightBeacon = {url, body};
await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body,
});
highlightsDirty = false;
} catch (e) {}
}
let _highlightSaveDebounce = null;
function debounceSaveHighlights() {
clearTimeout(_highlightSaveDebounce);
_highlightSaveDebounce = setTimeout(saveHighlights, 2000);
}
function applyHighlightsToContent() {
const contentEl = $('reader-content');
if (!contentEl || currentPdfDoc) return;
for (const h of currentHighlights) {
try { renderHighlight(h); } catch (e) {}
}
}
function renderHighlight(h) {
const contentEl = $('reader-content');
if (!contentEl || !h.anchor) return;
const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`)
|| contentEl;
let range = null;
try {
const startNode = xpathToNode(h.anchor.startXpath, chapterEl);
const endNode = xpathToNode(h.anchor.endXpath, chapterEl);
if (startNode && endNode) {
range = document.createRange();
range.setStart(startNode, h.anchor.startOffset);
range.setEnd(endNode, h.anchor.endOffset);
}
} catch (e) {}
// Fallback: quote substring search
if (!range && h.anchor.quote) {
const walker = document.createTreeWalker(chapterEl, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const idx = node.textContent.indexOf(h.anchor.quote);
if (idx !== -1) {
range = document.createRange();
range.setStart(node, idx);
range.setEnd(node, idx + h.anchor.quote.length);
break;
}
}
}
if (!range) return;
try {
const mark = document.createElement('mark');
mark.className = 'epub-highlight';
mark.dataset.highlightId = h.id;
mark.dataset.color = h.color || 'yellow';
range.surroundContents(mark);
} catch (e) {}
}
function xpathToNode(xpath, root) {
if (!xpath) return null;
const result = document.evaluate(xpath, root, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
return result.singleNodeValue;
}
function getXPathForNode(node, root) {
const parts = [];
let current = node;
while (current && current !== root) {
const parent = current.parentNode;
if (!parent) break;
if (current.nodeType === Node.TEXT_NODE) {
const siblings = Array.from(parent.childNodes).filter(n => n.nodeType === Node.TEXT_NODE);
const idx = siblings.indexOf(current);
parts.unshift(`text()[${idx + 1}]`);
} else {
const siblings = Array.from(parent.children).filter(n => n.tagName === current.tagName);
const idx = siblings.indexOf(current);
parts.unshift(`${current.tagName.toLowerCase()}[${idx + 1}]`);
}
current = parent;
}
return parts.join('/');
}
function buildEpubAnchor(range) {
const contentEl = $('reader-content');
const chapterEl = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
? range.commonAncestorContainer.closest('[data-epub-src]')
: range.commonAncestorContainer.parentElement?.closest('[data-epub-src]');
const root = chapterEl || contentEl;
return {
type: 'epub',
chapterSrc: chapterEl?.getAttribute('data-epub-src') || '',
startXpath: getXPathForNode(range.startContainer, root),
startOffset: range.startOffset,
endXpath: getXPathForNode(range.endContainer, root),
endOffset: range.endOffset,
quote: range.toString().slice(0, 200),
};
}
function handleReaderSelection(e) {
// If clicking an existing highlight, show tooltip
const hlMark = e.target.closest('.epub-highlight');
if (hlMark) {
dismissHighlightPopover();
const id = hlMark.dataset.highlightId;
const h = currentHighlights.find(x => x.id === id);
showHighlightTooltip(hlMark, h);
return;
}
dismissHighlightPopover();
const sel = window.getSelection();
if (!sel || sel.isCollapsed || !sel.rangeCount) return;
const range = sel.getRangeAt(0);
const contentEl = $('reader-content');
if (!contentEl || !contentEl.contains(range.commonAncestorContainer)) return;
if (range.toString().trim().length === 0) return;
showHighlightPopover(range);
}
function showHighlightPopover(range) {
const rect = range.getBoundingClientRect();
const popover = document.createElement('div');
popover.id = 'highlight-popover';
popover.className = 'highlight-popover';
popover.innerHTML = `
`;
popover.style.top = (rect.top + window.scrollY - 44) + 'px';
popover.style.left = (rect.left + window.scrollX + rect.width / 2 - 70) + 'px';
document.body.appendChild(popover);
currentHighlightPopover = popover;
// Store range info before selection is cleared
const savedRange = range.cloneRange();
popover.addEventListener('click', e => {
const colorBtn = e.target.closest('.hl-color-btn');
const noteBtn = e.target.closest('.hl-note-btn');
if (colorBtn) {
createHighlight(colorBtn.dataset.hlColor, savedRange);
} else if (noteBtn) {
createHighlightWithNote(savedRange);
}
});
}
function showHighlightTooltip(markEl, h) {
const rect = markEl.getBoundingClientRect();
const popover = document.createElement('div');
popover.id = 'highlight-popover';
popover.className = 'highlight-popover';
popover.style.flexDirection = 'column';
popover.style.maxWidth = '220px';
const noteText = h?.note ? escapeHtml(h.note) : 'No note';
popover.innerHTML = `
${noteText}
`;
popover.style.top = (rect.bottom + window.scrollY + 4) + 'px';
popover.style.left = (rect.left + window.scrollX) + 'px';
document.body.appendChild(popover);
currentHighlightPopover = popover;
popover.addEventListener('click', ev => {
const editBtn = ev.target.closest('[data-hl-edit-note]');
const delBtn = ev.target.closest('[data-hl-delete]');
if (editBtn && h) {
dismissHighlightPopover();
openNoteEditor(h);
}
if (delBtn && h) {
dismissHighlightPopover();
deleteHighlight(h.id);
}
});
// Close on outside click
setTimeout(() => {
document.addEventListener('click', dismissHighlightPopover, {once: true});
}, 0);
}
function createHighlight(color, range) {
const anchor = buildEpubAnchor(range);
const h = {
id: crypto.randomUUID(),
anchor,
color,
note: '',
createdAt: new Date().toISOString(),
};
currentHighlights.push(h);
highlightsDirty = true;
window.getSelection()?.removeAllRanges();
dismissHighlightPopover();
renderHighlight(h);
debounceSaveHighlights();
}
function createHighlightWithNote(range) {
const anchor = buildEpubAnchor(range);
const h = {
id: crypto.randomUUID(),
anchor,
color: 'yellow',
note: '',
createdAt: new Date().toISOString(),
};
currentHighlights.push(h);
highlightsDirty = true;
window.getSelection()?.removeAllRanges();
dismissHighlightPopover();
renderHighlight(h);
openNoteEditor(h);
}
function openNoteEditor(h) {
openSidebar('Edit note', `
`);
const body = $('sidebar-body');
body.addEventListener('click', function _noteClick(e) {
const btn = e.target.closest('[data-save-note]');
if (!btn) return;
body.removeEventListener('click', _noteClick);
const text = (body.querySelector('#hl-note-input')?.value || '').trim();
h.note = text;
highlightsDirty = true;
debounceSaveHighlights();
closeSidebar();
});
}
function deleteHighlight(id) {
currentHighlights = currentHighlights.filter(h => h.id !== id);
highlightsDirty = true;
// Re-apply all highlights after removing the deleted one
const contentEl = $('reader-content');
if (contentEl && !currentPdfDoc) {
// Snapshot restore not available mid-session, so remove the mark manually
const mark = contentEl.querySelector(`mark[data-highlight-id="${id}"]`);
if (mark) {
const parent = mark.parentNode;
while (mark.firstChild) parent.insertBefore(mark.firstChild, mark);
parent.removeChild(mark);
}
}
debounceSaveHighlights();
}
function dismissHighlightPopover() {
if (currentHighlightPopover) {
currentHighlightPopover.remove();
currentHighlightPopover = null;
}
}
// ---------------------------------------------------------------------------
// Focus station sidebar
// ---------------------------------------------------------------------------
const FOCUS_STATION_PRESETS = [
{name: 'None (no station)', url: ''},
{name: 'SomaFM Groove Salad', url: 'https://ice5.somafm.com/groovesalad-128-aac'},
{name: 'SomaFM Deep Space One', url: 'https://ice5.somafm.com/deepspaceone-128-aac'},
{name: 'SomaFM Drone Zone', url: 'https://ice5.somafm.com/dronezone-128-aac'},
{name: 'SomaFM Space Station', url: 'https://ice5.somafm.com/spacestation-128-aac'},
{name: 'Linn Jazz', url: 'http://radio.linnrecords.com/linnjazz.pls'},
];
function openFocusStationSidebar() {
// null = never saved (default active); {url:''} = disabled; {url:'...'} = custom
const effectiveUrl = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION.url : (USER_FOCUS_STATION.url || '');
const currentName = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION.name
: (USER_FOCUS_STATION.name || 'None (no station)');
let presetsHtml = FOCUS_STATION_PRESETS.map((p, i) => {
const active = p.url === effectiveUrl ? ' class="focus-preset-active"' : '';
return ``;
}).join('');
const html = `
Station played when opening a book.
Current: ${escapeHtml(currentName)}
${presetsHtml}
`;
openSidebar('Focus Station', html);
const body = $('sidebar-body');
body.addEventListener('click', function _focusClick(e) {
const presetBtn = e.target.closest('[data-focus-preset]');
const saveBtn = e.target.closest('[data-focus-save]');
const playBtn = e.target.closest('[data-focus-play]');
if (presetBtn) {
const preset = FOCUS_STATION_PRESETS[parseInt(presetBtn.dataset.focusPreset, 10)];
if (preset) saveFocusStation(preset.url, preset.name);
body.removeEventListener('click', _focusClick);
} else if (saveBtn) {
const url = (body.querySelector('#focus-custom-url')?.value || '').trim();
const name = (body.querySelector('#focus-custom-name')?.value || '').trim();
saveFocusStation(url, name);
body.removeEventListener('click', _focusClick);
} else if (playBtn) {
const url = (body.querySelector('#focus-custom-url')?.value || '').trim();
const name = (body.querySelector('#focus-custom-name')?.value || '').trim();
if (url) playStation(url, name, null);
}
});
}
async function saveFocusStation(url, name) {
url = (url || '').trim();
name = (name || '').trim();
if (!IS_AUTHENTICATED) {
USER_FOCUS_STATION = {url, name};
closeSidebar();
return;
}
try {
const res = await fetch('/accounts/focus-station/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({url, name}),
});
const data = await res.json();
if (data.ok) {
USER_FOCUS_STATION = {url, name};
closeSidebar();
}
} catch (e) {}
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
(function init() {
// Migrate PBKDF2-derived key stored by login/register form
if (window.USER_ID) {
const pending = localStorage.getItem('diora_pending_enc_key');
if (pending) {
localStorage.setItem(`diora_enc_key_${window.USER_ID}`, pending);
localStorage.removeItem('diora_pending_enc_key');
}
}
// Populate saved stations from server-side context if available
if (typeof INITIAL_SAVED !== 'undefined' && Array.isArray(INITIAL_SAVED)) {
// The server already renders saved stations in the template; nothing extra needed.
// But if JS-rendered saved tab were needed we'd call addSavedRow here.
}
// Seed podcast feeds from server context
if (typeof INITIAL_PODCAST_FEEDS !== 'undefined' && Array.isArray(INITIAL_PODCAST_FEEDS)) {
podcastFeeds = INITIAL_PODCAST_FEEDS;
}
// Wire seek slider
const seekSlider = $('seek-slider');
if (seekSlider) {
seekSlider.addEventListener('input', function () {
if (podcastMode) audio.currentTime = parseInt(this.value, 10);
});
}
// Restore persisted volume, fall back to slider default
const volSlider = $('volume');
if (volSlider) {
const saved = localStorage.getItem('diora_volume');
const vol = saved !== null ? parseInt(saved, 10) : parseInt(volSlider.value, 10);
setVolume(vol);
}
// Load recommendations on page load
loadRecommendations();
// Initialise focus timer display
renderTimer();
// Initialise mood/genre chips
initMoodChips();
// Initialise curated station lists
initCuratedLists();
// Show curated lists again when search input is cleared
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.addEventListener('input', function () {
if (this.value === '') {
const curated = document.getElementById('curated-lists');
if (curated) curated.style.display = '';
}
});
}
// Load focus session stats
loadFocusStats();
// Apply encrypted wallpaper (if set)
applyEncryptedBackground();
// Init book drop zone
initBookDropZone();
// Restore last active tab
const savedTab = localStorage.getItem('diora_active_tab') || 'radio';
const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved';
showTab(savedTab);
showRadioTab(savedRadioTab);
})();