diora-web/static/js/app.js
mrwnslz 53ca3b0002
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 8m18s
Leseposition: Binärsuche über die Blöcke statt alle vom Buchanfang an messen
getPositionAnchor maß jeden Block vom Buchanfang bis zur Leseposition und suchte
die Blöcke bei jedem Aufruf neu – im E-Reader-Modus bei jedem Umblättern (die
Prozentanzeige), beim Speichern des Fortschritts sowieso. In Das Kapital ab
Kapitel 15: 0,8 s bei 6-fach gedrosselter CPU; auf dem tolino filtert compat61
das :has() dazu in JavaScript.
- Blockliste samt Kapitel je Block einmal je geöffnetem Buch (verworfen bei
  jeder Änderung am Inhalt, MutationObserver)
- Binärsuche nur über die angezeigten Kapitel; gleiche Regel wie vorher
  (letzter Block mit Höhe, der an/über der Oberkante beginnt, bei gleicher Höhe
  der erste; sonst der erste darunter)
- Geprüft an 480 Stellen (Kapital, Hyperpolitik; E-Reader und Scrollen): gleiche
  Anker wie vorher; 53/281 ms → 4 ms; Umblättern 21–48 ms statt bis 0,9 s
- sw v98

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:01:40 +02:00

6460 lines
246 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.

/**
* diora — radio player
* Handles playback, SSE metadata, search, station management, and affiliate links.
*/
'use strict';
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
// Hardcoded for now -- only creamfresh radio gets the vote buttons, since
// only its backend (the DJ) actually does anything with them.
const CREAMFRESH_RADIO_URL = 'https://diora.creamfresh.xyz/radio/creamfresh-stream/';
let currentStation = null; // { url, name, id } | null
let currentTrack = '';
let sseSource = null;
let isPlaying = false;
let currentPlayId = null;
const audio = new Audio();
// Reconnect audio when the output device changes (e.g. Bluetooth, USB DAC)
if (navigator.mediaDevices) {
let _deviceChangeDebounce = null;
navigator.mediaDevices.addEventListener('devicechange', () => {
clearTimeout(_deviceChangeDebounce);
_deviceChangeDebounce = setTimeout(() => {
if (!isPlaying || audio.src === '') return;
const savedTime = audio.currentTime;
const savedSrc = audio.src;
audio.src = savedSrc;
audio.load();
if (savedTime > 0) {
audio.addEventListener('loadedmetadata', function onMeta() {
audio.currentTime = savedTime;
audio.removeEventListener('loadedmetadata', onMeta);
});
}
audio.play().catch(() => {});
}, 500);
});
}
// ---------------------------------------------------------------------------
// DOM helpers
// ---------------------------------------------------------------------------
function $(id) { return document.getElementById(id); }
function getCsrfToken() {
const cookie = document.cookie.split('; ').find(r => r.startsWith('csrftoken='));
return cookie ? cookie.split('=')[1] : '';
}
function formatDateTime(iso) {
if (!iso) return '';
const d = new Date(iso);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} `
+ `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ---------------------------------------------------------------------------
// Modal dialogs — styled replacements for native alert()/confirm()/prompt(),
// which look jarring against the rest of the custom dark UI and can't be
// styled or (in confirm/prompt's case) awaited without blocking the thread.
// All three return a Promise; callers must be async and `await` them.
// ---------------------------------------------------------------------------
function _showModal({ message, showInput = false, inputValue = '', showCancel = false, okLabel = 'OK', danger = false }) {
return new Promise((resolve) => {
const overlay = $('modal-overlay');
const dialog = $('modal-dialog');
const msgEl = $('modal-message');
const inputEl = $('modal-input');
const okBtn = $('modal-ok-btn');
const cancelBtn = $('modal-cancel-btn');
if (!overlay || !dialog) { resolve(null); return; }
msgEl.textContent = message;
inputEl.style.display = showInput ? '' : 'none';
inputEl.value = inputValue;
cancelBtn.style.display = showCancel ? '' : 'none';
okBtn.textContent = okLabel;
okBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary';
overlay.style.display = '';
dialog.style.display = '';
(showInput ? inputEl : okBtn).focus();
if (showInput) inputEl.select();
function cleanup(result) {
overlay.style.display = 'none';
dialog.style.display = 'none';
overlay.removeEventListener('click', onCancel);
okBtn.removeEventListener('click', onOk);
cancelBtn.removeEventListener('click', onCancel);
document.removeEventListener('keydown', onKeydown);
resolve(result);
}
function onOk() { cleanup(showInput ? inputEl.value : true); }
function onCancel() { cleanup(showInput ? null : false); }
function onKeydown(e) {
if (e.key === 'Escape') onCancel();
if (e.key === 'Enter' && showInput) onOk();
}
okBtn.addEventListener('click', onOk);
cancelBtn.addEventListener('click', onCancel);
overlay.addEventListener('click', onCancel);
document.addEventListener('keydown', onKeydown);
});
}
function customAlert(message) {
return _showModal({ message });
}
function customConfirm(message, { danger = false } = {}) {
return _showModal({ message, showCancel: true, danger });
}
// The server answers with short English error codes (an API contract, tests and
// other clients rely on them); what the page shows of them is German.
const _GERMAN_ERRORS = {
'no stations found in file': 'In der Datei wurden keine Sender gefunden.',
'file must be .m3u or .m3u8': 'Die Datei muss eine .m3u- oder .m3u8-Datei sein.',
'no file uploaded': 'Keine Datei hochgeladen.',
'only jpeg, png, or webp allowed': 'Nur JPEG, PNG oder WebP erlaubt.',
'file too large (max 50 MB)': 'Datei zu groß (höchstens 50 MB).',
'file too large (max 5 MB)': 'Datei zu groß (höchstens 5 MB).',
'authentication required': 'Bitte anmelden.',
'not found': 'Nicht gefunden.',
'invalid JSON': 'Ungültige Anfrage.',
'upstream unreachable': 'Der Server dahinter ist nicht erreichbar.',
};
function _germanError(code) {
return _GERMAN_ERRORS[code] || code;
}
function customPrompt(message, defaultValue = '') {
return _showModal({ message, showInput: true, inputValue: defaultValue, showCancel: true });
}
// ---------------------------------------------------------------------------
// Play / Stop
// ---------------------------------------------------------------------------
function playStation(url, name, stationId) {
stopPlayback(false);
if (window.__dioraAmbientStop) window.__dioraAmbientStop();
// A secure page can't load plain-http streams (mixed content). Native shells
// that play the stream themselves set window.DIORA_PLAYS_HTTP_STREAMS.
if (location.protocol === 'https:' && url.startsWith('http://') && !window.DIORA_PLAYS_HTTP_STREAMS) {
window.open(url, '_blank');
return;
}
currentStation = { url, name, id: stationId || null };
isPlaying = true;
audio.src = url;
const volSlider = document.getElementById('volume');
if (volSlider) audio.volume = volSlider.value / 255;
audio.play().catch(() => {
// Browser may block autoplay; the user needs to interact first
console.warn('Audio play blocked by browser policy.');
});
$('now-playing-station').textContent = name;
$('now-playing-track').textContent = '';
$('play-stop-btn').style.display = '';
$('play-stop-btn').textContent = '⏹ Stopp';
$('play-stop-btn').classList.add('playing');
$('save-station-btn').style.display = '';
const isCreamfresh = url === CREAMFRESH_RADIO_URL;
$('creamfresh-vote-up-btn').style.display = isCreamfresh ? '' : 'none';
$('creamfresh-vote-down-btn').style.display = isCreamfresh ? '' : 'none';
$('creamfresh-vote-up-btn').classList.remove('active');
$('creamfresh-vote-down-btn').classList.remove('active');
startMetadataSSE(url);
startPlaySession(name, url);
maybeShowDonationHint(url, name);
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({title: name, artist: 'Radio'});
navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; });
navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; });
navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true));
try { navigator.mediaSession.setActionHandler('seekbackward', null); } catch (_) {}
try { navigator.mediaSession.setActionHandler('seekforward', null); } catch (_) {}
try { navigator.mediaSession.setActionHandler('nexttrack', null); } catch (_) {}
try { navigator.mediaSession.setActionHandler('previoustrack',null); } catch (_) {}
navigator.mediaSession.playbackState = 'playing';
}
}
function stopPlayback(clearStation = true) {
audio.pause();
audio.src = '';
isPlaying = false;
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none';
if (sseSource) {
sseSource.close();
sseSource = null;
}
$('play-stop-btn').textContent = '▶ Abspielen';
$('play-stop-btn').classList.remove('playing');
$('save-station-btn').style.display = 'none';
$('creamfresh-vote-up-btn').style.display = 'none';
$('creamfresh-vote-down-btn').style.display = 'none';
$('affiliate-section').style.display = 'none';
stopPlaySession();
const stationEl = $('now-playing-station');
const trackEl = $('now-playing-track');
if (clearStation) {
currentStation = null;
currentTrack = '';
stationEl.textContent = '— kein Sender —';
trackEl.textContent = '';
$('play-stop-btn').style.display = 'none';
}
}
function togglePlayStop() {
if (isPlaying) {
stopPlayback(true);
} else if (currentStation) {
playStation(currentStation.url, currentStation.name, currentStation.id);
}
}
// ---------------------------------------------------------------------------
// Play session tracking
// ---------------------------------------------------------------------------
async function startPlaySession(stationName, stationUrl) {
try {
const res = await fetch('/radio/play/start/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({station_name: stationName, station_url: stationUrl})
});
if (res.ok) {
const data = await res.json();
currentPlayId = data.play_id;
}
} catch (e) {}
}
async function stopPlaySession() {
// Take the id before awaiting: playStation() calls this without waiting and
// starts the next session right away, whose id must not be cleared here.
const playId = currentPlayId;
if (!playId) return;
currentPlayId = null;
try {
await fetch('/radio/play/stop/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({play_id: playId})
});
} catch (e) {}
}
window.addEventListener('beforeunload', () => {
if (currentPlayId) {
navigator.sendBeacon('/radio/play/stop/', JSON.stringify({play_id: currentPlayId}));
}
// Flush cached encrypted payloads for reader data (encryption is async so we
// use pre-computed blobs stored by the debounced savers)
if (_lastProgressBeacon) navigator.sendBeacon(_lastProgressBeacon.url, _lastProgressBeacon.body);
if (_lastBookmarkBeacon) navigator.sendBeacon(_lastBookmarkBeacon.url, _lastBookmarkBeacon.body);
if (_lastHighlightBeacon) navigator.sendBeacon(_lastHighlightBeacon.url, _lastHighlightBeacon.body);
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && currentBookId) {
if (bookmarksDirty) saveBookmarks();
if (highlightsDirty) saveHighlights();
saveReaderProgress();
}
});
// Cached beacon payloads — updated after each successful encrypt in save functions
let _lastBookmarkBeacon = null;
let _lastHighlightBeacon = null;
let _lastProgressBeacon = null;
// ---------------------------------------------------------------------------
// Recommendations
// ---------------------------------------------------------------------------
async function loadRecommendations() {
const container = document.getElementById('recommendations');
if (!container) return;
try {
const res = await fetch('/radio/recommendations/');
const data = await res.json();
if (!data.recommendations.length) {
container.innerHTML = '<p class="muted">Hör mehr Sender, dann gibt es hier Empfehlungen.</p>';
return;
}
const label = data.context;
let html = `<p class="recommendations-context">Was du ${label} oft hörst:</p><ul class="recommendations-list">`;
for (const r of data.recommendations) {
html += `<li>
<button class="btn btn-sm" onclick="playStation('${escapeAttr(r.station_url)}', '${escapeAttr(r.station_name)}', ${r.saved_id || 'null'})">
&#9654; ${escapeHtml(r.station_name)}
</button>
<span class="muted">${r.play_count}&times;</span>
</li>`;
}
html += '</ul>';
container.innerHTML = html;
} catch (e) {}
}
function escapeAttr(s) {
return String(s).replace(/'/g, "\\'").replace(/"/g, '&quot;');
}
// ---------------------------------------------------------------------------
// Volume
// ---------------------------------------------------------------------------
function setVolume(val) {
val = Math.max(0, Math.min(255, Math.round(val)));
audio.volume = val / 255;
localStorage.setItem('diora_volume', val);
const slider = $('volume');
const numInput = $('volume-num');
if (slider) slider.value = val;
if (numInput) numInput.value = val;
_ambientVolume(val);
}
const volSliderEl = document.getElementById('volume');
if (volSliderEl) {
['input', 'change', 'mousemove', 'touchmove'].forEach(evt =>
volSliderEl.addEventListener(evt, function () { setVolume(this.value); })
);
}
const volNumEl = document.getElementById('volume-num');
if (volNumEl) {
volNumEl.addEventListener('change', function () { setVolume(this.value); });
volNumEl.addEventListener('input', function () { setVolume(this.value); });
volNumEl.addEventListener('click', function () { this.select(); });
}
const volSliderEl2 = document.getElementById('volume');
const volWheelTarget = volSliderEl2 || volNumEl;
if (volWheelTarget) {
volWheelTarget.addEventListener('wheel', function (e) {
e.preventDefault();
const current = parseInt(document.getElementById('volume').value, 10);
setVolume(current + (e.deltaY < 0 ? 4 : -4));
}, { passive: false });
}
// ---------------------------------------------------------------------------
// SSE metadata
// ---------------------------------------------------------------------------
function startMetadataSSE(streamUrl) {
if (sseSource) { sseSource.close(); sseSource = null; }
const endpoint = '/radio/sse/?url=' + encodeURIComponent(streamUrl);
sseSource = new EventSource(endpoint);
sseSource.onmessage = function (e) {
let data;
try { data = JSON.parse(e.data); } catch (_) { return; }
if (data.error) {
console.warn('SSE stream ended:', data.error);
return;
}
if (data.track && data.track !== currentTrack) {
currentTrack = data.track;
updateNowPlayingUI(data.track);
recordTrack(currentStation ? currentStation.name : '', data.track);
fetchAffiliateLinks(data.track);
}
};
sseSource.onerror = function () {
// Connection dropped; the browser will attempt to reconnect automatically
console.warn('SSE connection error, browser will retry.');
};
}
function updateNowPlayingUI(track) {
$('now-playing-track').textContent = track;
}
// ---------------------------------------------------------------------------
// Record track
// ---------------------------------------------------------------------------
async function recordTrack(stationName, track) {
try {
const res = await fetch('/radio/record/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({ station_name: stationName, track, scrobble: true }),
});
if (res.ok) {
addHistoryRow(stationName, track);
}
} catch (err) {
console.error('recordTrack error:', err);
}
}
function addHistoryRow(stationName, track) {
const tbody = $('history-tbody');
if (!tbody) return;
// Remove the "no history" placeholder row if present
const emptyRow = $('history-empty-row');
if (emptyRow) emptyRow.remove();
const tr = document.createElement('tr');
const now = new Date().toISOString();
tr.innerHTML = `
<td class="history-time">${escapeHtml(formatDateTime(now))}</td>
<td>${escapeHtml(stationName)}</td>
<td>${escapeHtml(track)}</td>
<td></td>
<td><button class="btn-delete-history" onclick="deleteHistoryEntry(null, this)" title="Entfernen">✕</button></td>
`;
tbody.insertBefore(tr, tbody.firstChild);
}
async function deleteHistoryEntry(id, btn) {
const tr = btn.closest('tr');
if (!id) { tr.remove(); return; }
try {
const res = await fetch(`/radio/history/${id}/delete/`, {
method: 'POST',
headers: {'X-CSRFToken': getCsrfToken()},
});
if (res.ok) tr.remove();
} catch (e) {}
}
// ---------------------------------------------------------------------------
// Affiliate links
// ---------------------------------------------------------------------------
async function fetchAffiliateLinks(track) {
const section = $('affiliate-section');
if (section && section.dataset.disabled) return;
try {
const res = await fetch('/radio/affiliate/?track=' + encodeURIComponent(track));
if (!res.ok) return;
const data = await res.json();
const itunes = data.itunes_data || {};
$('affiliate-track-name').textContent = itunes.name || track;
$('affiliate-artist-name').textContent = itunes.artist || '';
$('affiliate-album-name').textContent = itunes.album || '';
const artEl = $('affiliate-artwork');
if (itunes.artwork) {
artEl.src = itunes.artwork;
artEl.style.display = '';
} else {
artEl.style.display = 'none';
}
const amzLink = $('affiliate-amazon-link');
if (data.amazon_url) {
amzLink.href = data.amazon_url;
amzLink.style.display = '';
} else {
amzLink.style.display = 'none';
}
section.style.display = 'flex';
} catch (err) {
console.error('fetchAffiliateLinks error:', err);
section.style.display = 'none';
}
}
// ---------------------------------------------------------------------------
// Search (radio-browser.info)
// ---------------------------------------------------------------------------
async function doSearch() {
const query = $('search-input').value.trim();
if (!query) return;
const statusEl = $('search-status');
const tableEl = $('search-results-table');
const tbody = $('search-results-body');
tableEl.style.display = 'none';
tbody.innerHTML = '';
// A stream address instead of a name: offer to play or save it directly.
if (/^https?:\/\/\S+$/i.test(query)) {
showStreamAddressResult(query);
return;
}
statusEl.textContent = 'Suche …';
try {
const url = `https://de1.api.radio-browser.info/json/stations/search?name=${encodeURIComponent(query)}&limit=50&hidebroken=true&order=clickcount&reverse=true`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const stations = await res.json();
if (!stations.length) {
statusEl.textContent = 'Keine Sender gefunden.';
return;
}
statusEl.textContent = stations.length === 1 ? '1 Treffer' : `${stations.length} Treffer`;
tableEl.style.display = '';
const curated = document.getElementById('curated-lists');
if (curated) curated.style.display = 'none';
stations.forEach(st => {
const tr = document.createElement('tr');
const safeName = escapeHtml(st.name || '');
const safeUrl = escapeHtml(st.url_resolved || st.url || '');
const safeBr = escapeHtml(st.bitrate ? st.bitrate + ' kbps' : '');
const safeCC = escapeHtml(st.countrycode || st.country || '');
const safeTags = escapeHtml((st.tags || '').split(',').slice(0, 3).join(', '));
tr.innerHTML = `
<td title="${safeName}">${safeName}</td>
<td>${safeBr}</td>
<td>${safeCC}</td>
<td title="${escapeHtml(st.tags || '')}">${safeTags}</td>
<td>
<button class="btn btn-sm btn-play"
onclick='searchPlay(${JSON.stringify(safeUrl)}, ${JSON.stringify(safeName)}, ${JSON.stringify({
name: st.name,
url: st.url_resolved || st.url,
bitrate: st.bitrate ? String(st.bitrate) : '',
country: st.country || '',
tags: st.tags || '',
favicon_url: st.favicon || '',
})})'>
&#9654; Abspielen
</button>
</td>
`;
tbody.appendChild(tr);
});
} catch (err) {
statusEl.textContent = 'Suche fehlgeschlagen: ' + err.message;
}
}
// A name to start from for a pasted address: "klassik.mp3" on radio.example.org
// -> "Klassik (radio.example.org)". The query string is left out on purpose —
// it often carries an access token, which shouldn't end up on screen.
function _streamAddressName(url) {
try {
const u = new URL(url);
const file = decodeURIComponent(u.pathname.split('/').filter(Boolean).pop() || '');
const base = file.replace(/\.(mp3|aac|aacp|ogg|opus|m3u8?|pls|flac)$/i, '').replace(/[-_]+/g, ' ').trim();
const nice = base ? base.charAt(0).toUpperCase() + base.slice(1) : '';
return nice ? `${nice} (${u.host})` : u.host;
} catch (e) {
return url;
}
}
function showStreamAddressResult(url) {
const statusEl = $('search-status');
const tableEl = $('search-results-table');
const tbody = $('search-results-body');
const name = _streamAddressName(url);
const shown = url.split('?')[0];
const data = {name, url, bitrate: '', country: '', tags: '', favicon_url: ''};
statusEl.textContent = 'Stream-Adresse';
const curated = document.getElementById('curated-lists');
if (curated) curated.style.display = 'none';
// Same five columns as a radio-browser result: name, bitrate, country, tags, actions.
const tr = document.createElement('tr');
tr.className = 'stream-address-row';
for (let i = 0; i < 4; i++) tr.appendChild(document.createElement('td'));
tr.children[0].textContent = name;
tr.children[0].title = shown;
tr.children[3].textContent = shown;
tr.children[3].title = shown;
const actions = document.createElement('td');
const play = document.createElement('button');
play.className = 'btn btn-sm btn-play';
play.innerHTML = '&#9654; Abspielen';
play.addEventListener('click', () => searchPlay(url, name, data));
const save = document.createElement('button');
save.className = 'btn btn-sm';
save.innerHTML = '&#9733; Speichern';
save.style.marginLeft = '4px';
save.addEventListener('click', async e => {
e.stopPropagation();
const chosen = await customPrompt('Name für diesen Sender', name);
if (chosen == null) return;
const finalName = chosen.trim() || name;
await saveStation({...data, name: finalName});
statusEl.textContent = `Gespeichert als „${finalName}“.`;
});
actions.append(play, save);
tr.appendChild(actions);
tbody.appendChild(tr);
tableEl.style.display = '';
}
function searchPlay(url, name, stationData) {
// Store station data on the window so saveCurrentStation() can use it
window._pendingStationData = stationData;
playStation(url, name, null);
}
// ---------------------------------------------------------------------------
// Save current station
// ---------------------------------------------------------------------------
async function saveCurrentStation() {
if (!currentStation) return;
// Use the rich data from search results if available, otherwise minimal data
const data = window._pendingStationData || {
name: currentStation.name,
url: currentStation.url,
bitrate: '',
country: '',
tags: '',
favicon_url: '',
};
await saveStation(data);
}
// ---------------------------------------------------------------------------
// creamfresh radio: DJ feedback (hardcoded to this one station, see
// CREAMFRESH_RADIO_URL above)
// ---------------------------------------------------------------------------
async function creamfreshVote(direction) {
const upBtn = $('creamfresh-vote-up-btn');
const downBtn = $('creamfresh-vote-down-btn');
upBtn.disabled = true;
downBtn.disabled = true;
try {
const res = await fetch('/radio/creamfresh-feedback/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({ vote: direction }),
});
if (!res.ok) throw new Error(`feedback returned ${res.status}`);
upBtn.classList.toggle('active', direction === 'up');
downBtn.classList.toggle('active', direction === 'down');
} catch (err) {
console.warn('creamfresh vote failed', err);
} finally {
upBtn.disabled = false;
downBtn.disabled = false;
}
}
async function saveStation(station) {
try {
const res = await fetch('/radio/save/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify(station),
});
if (res.status === 401) {
await customAlert('Bitte anmelden, um Sender zu speichern.');
return;
}
const data = await res.json();
if (data.ok) {
if (data.created) {
addSavedRow({ id: data.id, ...station, is_favorite: false });
}
}
} catch (err) {
console.error('saveStation error:', err);
}
}
function addSavedRow(station) {
const tbody = $('saved-tbody');
if (!tbody) return;
const emptyRow = $('saved-empty-row');
if (emptyRow) emptyRow.remove();
// Check for duplicate
if (document.getElementById(`saved-row-${station.id}`)) return;
const tr = document.createElement('tr');
tr.id = `saved-row-${station.id}`;
tr.dataset.id = station.id;
tr.dataset.url = station.url;
tr.dataset.name = station.name;
const safeName = escapeHtml(station.name || '');
const safeBr = escapeHtml(station.bitrate || '');
const safeCC = escapeHtml(station.country || '');
const safeUrl = escapeHtml(station.url || '');
tr.innerHTML = `
<td>
<button class="btn-icon fav-btn${station.is_favorite ? ' active' : ''}"
onclick="toggleFav(${station.id}, this)"
title="Favorit an/aus">&#9733;</button>
</td>
<td class="station-name-cell">${safeName}</td>
<td>${safeBr}</td>
<td>${safeCC}</td>
<td class="notes-cell" onclick="editNotes(${station.id}, this.textContent.trim())" title="" style="cursor:pointer; color:#666; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;"></td>
<td>
<button class="btn btn-sm"
onclick="playStation('${safeUrl}', '${safeName}', ${station.id})">
&#9654; Abspielen
</button>
</td>
<td>
<button class="btn btn-sm btn-danger"
onclick="removeStation(${station.id})">
Entfernen
</button>
</td>
`;
tbody.appendChild(tr);
}
// ---------------------------------------------------------------------------
// Remove station
// ---------------------------------------------------------------------------
// The only toggleFav: a second definition further down (expecting the button
// as an argument) used to shadow this one, so the server-rendered rows — which
// pass no button — toggled the favourite on the server without the star ever
// changing, and a second click silently took it back.
async function toggleFav(pk, btnEl) {
try {
const res = await fetch(`/radio/favorite/${pk}/`, {
method: 'POST',
headers: { 'X-CSRFToken': getCsrfToken() },
});
if (!res.ok) return;
const data = await res.json();
// Flip the star button state
const btn = btnEl || document.querySelector(`#saved-row-${pk} .fav-btn`);
if (btn) btn.classList.toggle('active', !!data.is_favorite);
// Re-sort rows in the DOM: favorites first, then alphabetically
const tbody = $('saved-tbody');
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
rows.sort((a, b) => {
const aFav = a.querySelector('.fav-btn')?.classList.contains('active') ? 0 : 1;
const bFav = b.querySelector('.fav-btn')?.classList.contains('active') ? 0 : 1;
if (aFav !== bFav) return aFav - bFav;
return a.dataset.name.localeCompare(b.dataset.name);
});
rows.forEach(row => tbody.appendChild(row));
} catch (err) {
console.error('toggleFav error', err);
}
}
async function removeStation(pk) {
try {
const res = await fetch(`/radio/remove/${pk}/`, {
method: 'POST',
headers: { 'X-CSRFToken': getCsrfToken() },
});
if (res.ok) {
const row = $(`saved-row-${pk}`);
if (row) row.remove();
const tbody = $('saved-tbody');
if (tbody && tbody.querySelectorAll('tr').length === 0) {
const tr = document.createElement('tr');
tr.id = 'saved-empty-row';
tr.innerHTML = '<td colspan="7" class="empty-msg">Noch keine gespeicherten Sender.</td>';
tbody.appendChild(tr);
}
}
} catch (err) {
console.error('removeStation error:', err);
}
}
// ---------------------------------------------------------------------------
// Focus Timer
// ---------------------------------------------------------------------------
const TIMER_WORK = 25 * 60;
const TIMER_BREAK = 5 * 60;
let timerSeconds = TIMER_WORK;
let timerRunning = false;
let timerIsBreak = false;
let timerInterval = null;
function timerTick() {
timerSeconds--;
renderTimer();
if (timerSeconds <= 0) {
clearInterval(timerInterval);
timerInterval = null;
timerRunning = false;
if (!timerIsBreak) {
// work session ended → start break
timerIsBreak = true;
recordFocusSession();
timerSeconds = TIMER_BREAK;
showTimerNotification('Pause! 5 Minuten.');
// auto-pause playback during break
if (audio.src && !audio.paused) audio.pause();
} else {
// break ended → reset to work
timerIsBreak = false;
timerSeconds = TIMER_WORK;
showTimerNotification('Pause vorbei. Weiter geht’s.');
}
renderTimer();
}
}
function toggleTimer() {
if (timerRunning) {
clearInterval(timerInterval);
timerInterval = null;
timerRunning = false;
} else {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
timerRunning = true;
timerInterval = setInterval(timerTick, 1000);
}
renderTimer();
}
function resetTimer() {
clearInterval(timerInterval);
timerInterval = null;
timerRunning = false;
timerIsBreak = false;
timerSeconds = TIMER_WORK;
renderTimer();
}
function renderTimer() {
const m = String(Math.floor(timerSeconds / 60)).padStart(2, '0');
const s = String(timerSeconds % 60).padStart(2, '0');
const display = $('timer-display');
const btn = $('timer-toggle-btn');
const label = $('timer-phase-label');
if (display) display.textContent = `${m}:${s}`;
if (btn) btn.textContent = timerRunning ? '⏸' : '▶';
if (label) label.textContent = timerIsBreak ? 'Pause' : 'Fokus';
// colour the display red when break
if (display) display.style.color = timerIsBreak ? '#e63946' : '';
}
function showTimerNotification(msg) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('diora', { body: msg });
}
// also flash in the timer label
const label = $('timer-phase-label');
if (label) { label.textContent = msg; setTimeout(() => renderTimer(), 3000); }
}
// ---------------------------------------------------------------------------
// Focus session recording
// ---------------------------------------------------------------------------
async function recordFocusSession() {
try {
await fetch('/radio/focus/record/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
body: JSON.stringify({
station_name: currentStation ? currentStation.name : '',
duration_minutes: 25,
}),
});
loadFocusStats();
} catch (e) {}
}
async function loadFocusStats() {
try {
const res = await fetch('/radio/focus/stats/');
const data = await res.json();
const widget = document.getElementById('focus-today-widget');
if (widget) {
if (data.today_sessions > 0) {
widget.textContent = `Heute: ${data.today_sessions} ${data.today_sessions !== 1 ? 'Sitzungen' : 'Sitzung'} · ${data.today_minutes} min`;
widget.style.display = '';
} else {
widget.style.display = 'none';
}
}
// populate focus tab
const tbody = document.getElementById('focus-tbody');
if (!tbody) return;
tbody.innerHTML = '';
if (!data.sessions.length) {
tbody.innerHTML = '<tr><td colspan="3" class="empty-msg">Noch keine Fokus-Sitzungen. Starte den Timer!</td></tr>';
return;
}
data.sessions.forEach(s => {
const tr = document.createElement('tr');
const dt = new Date(s.completed_at).toLocaleString([], {dateStyle: 'short', timeStyle: 'short'});
tr.innerHTML = `<td>${dt}</td><td>${escapeHtml(s.station_name || '—')}</td><td>${s.duration_minutes} min</td>`;
tbody.appendChild(tr);
});
} catch (e) {}
}
// ---------------------------------------------------------------------------
// Do Not Disturb / focus mode
// ---------------------------------------------------------------------------
let dndActive = false;
function toggleDNDLight() {
document.body.classList.toggle('dnd-dark');
const btn = $('dnd-light-btn');
if (btn) btn.style.opacity = document.body.classList.contains('dnd-dark') ? '0.4' : '1';
}
function toggleDND() {
dndActive = !dndActive;
if (!dndActive) document.body.classList.remove('dnd-dark');
document.body.classList.toggle('dnd-mode', dndActive);
const btn = $('dnd-btn');
if (btn) btn.classList.toggle('active', dndActive);
if (dndActive) {
const el = document.documentElement;
if (el.requestFullscreen) el.requestFullscreen();
else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
} else {
if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen();
else if (document.webkitFullscreenElement && document.webkitExitFullscreen) document.webkitExitFullscreen();
}
}
// Exit DND on Escape (browser also exits fullscreen on Escape, so sync state)
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement && dndActive) {
dndActive = false;
document.body.classList.remove('dnd-mode');
const btn = $('dnd-btn');
if (btn) btn.classList.remove('active');
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && dndActive) toggleDND();
});
// ---------------------------------------------------------------------------
// Mood / genre tag filter
// ---------------------------------------------------------------------------
const MOOD_TAGS = [
{ label: 'Fokus', tag: 'ambient' },
{ label: 'Lo-fi', tag: 'lofi' },
{ label: 'Jazz', tag: 'jazz' },
{ label: 'Klassik', tag: 'classical' },
{ label: 'Ambient', tag: 'ambient' },
{ label: 'Metal', tag: 'metal' },
{ label: 'Elektronisch', tag: 'electronic' },
{ label: 'Wort', tag: 'talk' },
];
function initMoodChips() {
const container = $('mood-chips');
if (!container) return;
MOOD_TAGS.forEach(({ label, tag }) => {
const btn = document.createElement('button');
btn.className = 'mood-chip';
btn.textContent = label;
btn.onclick = () => {
const input = $('search-input');
if (input) input.value = tag;
doSearch();
};
container.appendChild(btn);
});
}
// ---------------------------------------------------------------------------
// Curated station lists
// ---------------------------------------------------------------------------
const CURATED_LISTS = [
{
id: 'focus',
label: 'Fokus',
stations: [
{ name: 'SomaFM Drone Zone', url: 'https://ice6.somafm.com/dronezone-256-mp3' },
{ name: 'SomaFM Groove Salad', url: 'https://ice5.somafm.com/groovesalad-128-aac' },
{ name: 'Nightride FM', url: 'https://stream.nightride.fm/nightride.mp3' },
{ name: 'Nightride FM Chillsynth', url: 'https://stream.nightride.fm/chillsynth.mp3' },
],
},
{
id: 'lofi',
label: 'Lo-fi / Chill',
stations: [
{ name: 'SomaFM Groove Salad Classic', url: 'https://ice6.somafm.com/gsclassic-128-mp3' },
{ name: 'SomaFM Secret Agent', url: 'https://ice4.somafm.com/secretagent-128-mp3' },
{ name: 'dublab DE', url: 'https://dublabde.out.airtime.pro/dublabde_a' },
],
},
{
id: 'dark',
label: 'Dark / Industrial',
stations: [
{ name: 'SomaFM Doomed', url: 'https://ice2.somafm.com/doomed-256-mp3' },
{ name: 'Nightride FM Darksynth', url: 'https://stream.nightride.fm/darksynth.mp3' },
{ name: 'Radio Caprice Industrial', url: 'http://79.120.39.202:9095/' },
],
},
{
id: 'classical',
label: 'Klassik',
stations: [
{ name: 'BR Klassik', url: 'https://dispatcher.rndfnk.com/br/brklassik/live/mp3/high' },
{ name: 'SWR Kultur', url: 'https://f111.rndfnk.com/ard/swr/swr2/live/mp3/256/stream.mp3?aggregator=web' },
{ name: 'Deutschlandfunk Kultur', url: 'https://st02.sslstream.dlf.de/dlf/02/high/aac/stream.aac?aggregator=web' },
],
},
];
function initCuratedLists() {
const container = document.getElementById('curated-lists');
if (!container) return;
if (INITIAL_FEATURED && INITIAL_FEATURED.length) {
const section = document.createElement('div');
section.className = 'curated-section';
section.innerHTML = `<div class="curated-label">&#9733; Empfohlen</div>`;
const ul = document.createElement('ul');
ul.className = 'curated-stations';
INITIAL_FEATURED.forEach(s => {
const li = document.createElement('li');
li.innerHTML = `<button class="btn btn-sm" onclick="playStation('${escapeAttr(s.url)}', '${escapeAttr(s.name)}', null)">▶</button>
<span class="curated-name">${escapeHtml(s.name)}</span>
${s.description ? `<span class="muted" style="font-size:0.78rem">${escapeHtml(s.description)}</span>` : ''}`;
ul.appendChild(li);
});
section.appendChild(ul);
container.appendChild(section);
}
CURATED_LISTS.forEach(list => {
const section = document.createElement('div');
section.className = 'curated-section';
section.innerHTML = `<div class="curated-label">${list.label}</div>`;
const ul = document.createElement('ul');
ul.className = 'curated-stations';
list.stations.forEach(s => {
const li = document.createElement('li');
li.innerHTML = `<button class="btn btn-sm" onclick="playStation('${escapeAttr(s.url)}', '${escapeAttr(s.name)}', null)">▶</button>
<span class="curated-name">${escapeHtml(s.name)}</span>`;
ul.appendChild(li);
});
section.appendChild(ul);
container.appendChild(section);
});
}
// ---------------------------------------------------------------------------
// Donation hint
// ---------------------------------------------------------------------------
const DONATION_HINT_THRESHOLD = 10;
const DONATION_HINT_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
function maybeShowDonationHint(stationUrl, stationName) {
const station = INITIAL_SAVED.find(s => s.url === stationUrl);
if (!station || station.play_count < DONATION_HINT_THRESHOLD) return;
const key = `diora_donation_hint_${stationUrl}`;
const last = parseInt(localStorage.getItem(key) || '0', 10);
if (Date.now() - last < DONATION_HINT_COOLDOWN_MS) return;
// Past the threshold just means "this station is a regular" — from then on
// every single play would otherwise qualify, so only actually show the hint
// on roughly 1 in 10 of those plays instead of every time.
if (Math.random() >= 0.1) return;
const existing = document.getElementById('donation-hint');
if (existing) existing.remove();
const el = document.createElement('div');
el.id = 'donation-hint';
el.innerHTML = `
<span>Du hörst <strong>${escapeHtml(stationName)}</strong> oft – vielleicht magst du den Sender unterstützen ♥</span>
<button onclick="dismissDonationHint('${escapeAttr(stationUrl)}')" title="Ausblenden">✕</button>
`;
document.body.appendChild(el);
setTimeout(() => dismissDonationHint(stationUrl), 12000);
}
function dismissDonationHint(stationUrl) {
localStorage.setItem(`diora_donation_hint_${stationUrl}`, Date.now());
const el = document.getElementById('donation-hint');
if (el) { el.classList.add('hiding'); setTimeout(() => el.remove(), 400); }
}
// ---------------------------------------------------------------------------
// Station notes
// ---------------------------------------------------------------------------
async function editNotes(pk, current) {
const note = await customPrompt('Notiz zum Sender:', current || '');
if (note === null) return; // cancelled
fetch(`/radio/notes/${pk}/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
body: JSON.stringify({ notes: note }),
}).then(r => {
if (r.ok) {
const cell = document.querySelector(`#saved-row-${pk} .notes-cell`);
if (cell) cell.textContent = note;
}
});
}
// ---------------------------------------------------------------------------
// Tabs
// ---------------------------------------------------------------------------
const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient'];
const RADIO_SUB_TABS = ['search', 'saved', 'history'];
// ---------------------------------------------------------------------------
// URL hash routing — makes tabs deep-linkable/shareable and lets the browser
// back/forward buttons move between them. Deliberately hash-based rather
// than real paths: /podcasts/ and /books/ etc. are already taken by the
// JSON API, so this avoids touching Django's urls.py at all.
// ---------------------------------------------------------------------------
let _routingFromHash = false;
let _currentTopTab = 'radio';
function _syncHash() {
if (_routingFromHash) return;
let hash = _currentTopTab;
if (_currentTopTab === 'radio') {
hash += '/' + (localStorage.getItem('diora_active_radio_tab') || 'saved');
}
if (location.hash.slice(1) !== hash) history.pushState(null, '', '#' + hash);
}
function _routeFromHash() {
const [tab, sub] = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean);
if (!TOP_TABS.includes(tab)) return;
_routingFromHash = true;
try {
showTab(tab);
if (tab === 'radio' && RADIO_SUB_TABS.includes(sub)) showRadioTab(sub);
} finally {
_routingFromHash = false;
}
}
window.addEventListener('popstate', _routeFromHash);
window.addEventListener('hashchange', _routeFromHash);
function showOfflineBanner(offline) {
document.body.classList.toggle('app-offline', offline);
const uploadArea = $('book-upload-area');
if (uploadArea) uploadArea.style.display = offline ? 'none' : '';
}
function showTab(name) {
TOP_TABS.forEach(p => {
const panel = $(`tab-${p}`);
if (panel) panel.style.display = (p === name) ? '' : 'none';
});
document.querySelectorAll('#tabs .tab-btn').forEach((btn, i) => {
btn.classList.toggle('active', TOP_TABS[i] === name);
});
localStorage.setItem('diora_active_tab', name);
_currentTopTab = name;
_syncHash();
if (name === 'books') loadBookList();
if (name === 'ambient') _ambientLoad();
if (name === 'home' && window.__dioraHomeShow) window.__dioraHomeShow(); // static/js/home.js
if (name === 'feeds' && window.__dioraFeedsShow) window.__dioraFeedsShow(); // static/js/feeds.js
}
// ---------------------------------------------------------------------------
// Ambient: "Drift" (static/ambient/), a generative synthesizer in a frame of its
// own. It and the radio / podcast player take turns — one starting stops the
// other — by postMessage: {type: 'ambient', cmd: 'start'|'stop'} to the frame,
// {type: 'ambient', state: 'playing'|'stopped'} from it.
// ---------------------------------------------------------------------------
let _ambientPlaying = false;
let _ambientLast = false; // Ambient was started last: the player bar shows it (native-podcast.js)
function _ambientFrame() {
return $('ambient-frame');
}
function _ambientLoad() {
const f = _ambientFrame();
if (!f) return;
if (!f.getAttribute('src')) {
// Drift takes the page's volume before its first sound; the bar follows its sound world.
f.addEventListener('load', () => {
_ambientVolume();
try {
const sel = f.contentDocument.getElementById('mode');
// The sound world chosen last comes back (Drift itself starts with its default).
let last = '';
try { last = localStorage.getItem('diora_ambient_mode') || ''; } catch (e) {}
if (last && last !== sel.value && [...sel.options].some(o => o.value === last)) {
f.contentWindow.postMessage({type: 'ambient', cmd: 'mode', mode: last}, '*');
}
sel.addEventListener('change', () => {
try {
localStorage.setItem('diora_ambient_mode', sel.value);
localStorage.setItem('diora_ambient_mode_label', sel.selectedOptions[0] ? sel.selectedOptions[0].text : '');
} catch (e) {}
if (window.__dioraAmbientChanged) window.__dioraAmbientChanged();
});
} catch (e) {}
});
f.src = f.dataset.src;
}
requestAnimationFrame(_ambientSize);
}
// The frame fills the room between the tabs and the player bar at the bottom.
function _ambientSize() {
const f = _ambientFrame();
if (!f || _currentTopTab !== 'ambient') return;
const root = getComputedStyle(document.documentElement);
const bar = parseFloat(root.getPropertyValue('--nui-dock-h')) || parseFloat(root.getPropertyValue('--dpw-bar-h')) || 0;
const top = f.getBoundingClientRect().top + window.scrollY;
f.style.height = Math.max(320, Math.floor(window.innerHeight - top - bar)) + 'px';
}
window.addEventListener('resize', () => requestAnimationFrame(_ambientSize));
// The bar at the bottom grows or shrinks (the player appears, a second line …): its height
// comes as --nui-dock-h / --dpw-bar-h on <html>, set inline — measure again then.
new MutationObserver(() => requestAnimationFrame(_ambientSize))
.observe(document.documentElement, {attributes: true, attributeFilter: ['style']});
// A command for Drift ('start' | 'stop'): the app's notification, the sleep timer.
window.__dioraAmbientCmd = function (cmd, value) {
const f = _ambientFrame();
if (f && f.contentWindow && f.getAttribute('src')) f.contentWindow.postMessage({type: 'ambient', cmd, value}, '*');
};
// Start Drift from elsewhere (Home): load the frame if needed, then start it — with the
// sound world chosen last, which the frame's load handler above sets.
window.__dioraAmbientStart = function () {
const f = _ambientFrame();
if (!f) return;
const go = () => window.__dioraAmbientCmd('start');
let ready = false;
try { ready = !!f.getAttribute('src') && f.contentDocument.readyState === 'complete' && !!f.contentDocument.getElementById('mode'); } catch (e) {}
if (ready) { go(); return; }
f.addEventListener('load', () => setTimeout(go, 0), {once: true});
_ambientLoad();
};
// The radio or a podcast starts: Ambient stops, and the player bar is theirs again.
window.__dioraAmbientStop = function () {
if (_ambientPlaying) window.__dioraAmbientCmd('stop');
if (_ambientLast) {
_ambientLast = false;
if (window.__dioraAmbientChanged) window.__dioraAmbientChanged();
}
};
window.__dioraAmbientPlaying = () => _ambientPlaying;
// For the player bar: playing, started last, the sound world's name (the frame is ours).
window.__dioraAmbientInfo = function () {
const f = _ambientFrame();
let mode = '';
try {
const sel = f && f.contentDocument && f.contentDocument.getElementById('mode');
if (sel && sel.selectedOptions[0]) mode = sel.selectedOptions[0].text;
} catch (e) {}
return {playing: _ambientPlaying, last: _ambientLast, mode};
};
// The page's volume (0–255, the bar's slider) is Drift's too; its own slider hides when
// embedded. Not in the Android app: there the volume keys set it, as for everything.
function _ambientVolume(val) {
if (window.DioraNative && DioraNative.ambientState) return;
if (val == null) {
const s = $('volume');
val = s ? +s.value : +(localStorage.getItem('diora_volume') || 102);
}
window.__dioraAmbientCmd && window.__dioraAmbientCmd('volume', val / 255);
}
window.addEventListener('message', e => {
const f = _ambientFrame();
const d = e.data;
if (!f || e.source !== f.contentWindow || !d || d.type !== 'ambient' || !d.state) return;
_ambientPlaying = d.state === 'playing';
if (_ambientPlaying) _ambientLast = true;
if (window.__dioraAmbientChanged) window.__dioraAmbientChanged();
// The app keeps it going with the screen off (AmbientService); the browser's sleep timer counts it.
if (window.DioraNative && DioraNative.ambientState) DioraNative.ambientState(_ambientPlaying);
if (window.__dioraOnAmbient) window.__dioraOnAmbient(_ambientPlaying);
if (!_ambientPlaying) return;
// Ambient starts: the radio and a podcast here stop.
if (typeof isPlaying !== 'undefined' && isPlaying) stopPlayback(false);
if (window.__dioraPausePlayer) window.__dioraPausePlayer();
});
function showRadioTab(name) {
RADIO_SUB_TABS.forEach(p => {
const panel = $(`tab-${p}`);
if (panel) panel.style.display = (p === name) ? '' : 'none';
});
document.querySelectorAll('#radio-sub-tabs .tab-btn').forEach((btn, i) => {
btn.classList.toggle('active', RADIO_SUB_TABS[i] === name);
});
localStorage.setItem('diora_active_radio_tab', name);
_syncHash();
if (name === 'saved') loadRecommendations();
}
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
function openSidebar(title, htmlContent) {
$('sidebar-title').textContent = title;
$('sidebar-body').innerHTML = sanitizeSidebarHtml(htmlContent);
$('sidebar-overlay').style.display = '';
$('sidebar').classList.add('open');
}
function closeSidebar() {
$('sidebar').classList.remove('open');
$('sidebar-overlay').style.display = 'none';
}
document.addEventListener('keydown', e => {
const overlay = $('reader-overlay');
const readerOpen = overlay && overlay.style.display !== 'none';
if (e.key === 'Escape') {
if (readerOpen) {
closeReader();
} else {
closeSidebar();
}
}
if (readerOpen) {
if ((e.ctrlKey && e.key === 'f') || e.key === 'F3') {
e.preventDefault();
toggleReaderSearch();
}
if (readerSearchOpen) {
if (e.key === 'ArrowDown') { e.preventDefault(); readerSearchNext(); }
if (e.key === 'ArrowUp') { e.preventDefault(); readerSearchPrev(); }
}
if (readerSettings.pdfPaginated && currentPdfDoc) {
if (e.key === 'ArrowRight') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); }
}
// Vim-style scroll — ignore when typing in an input
const tag = document.activeElement?.tagName;
if (tag !== 'INPUT' && tag !== 'TEXTAREA') {
const contentEl = $('reader-content');
if (contentEl) {
const large = Math.round(contentEl.clientHeight * 0.85);
const continuousKeys = {'j': 1, 'k': -1};
if (continuousKeys[e.key] !== undefined && !e.repeat) {
e.preventDefault();
_readerScrollDir = continuousKeys[e.key];
if (!_readerScrollInterval) {
_readerScrollLastTs = performance.now();
_readerScrollInterval = setInterval(_readerScrollTick, READER_SCROLL_TICK_MS);
}
}
if (e.key === 'd') { e.preventDefault(); contentEl.scrollBy({top: large / 2, behavior: 'smooth'}); }
if (e.key === 'u') { e.preventDefault(); contentEl.scrollBy({top: -large / 2, behavior: 'smooth'}); }
if (e.key === 'f') { e.preventDefault(); contentEl.scrollBy({top: large, behavior: 'smooth'}); }
if (e.key === 'b') { e.preventDefault(); contentEl.scrollBy({top: -large, behavior: 'smooth'}); }
if (e.key === 'g') { e.preventDefault(); contentEl.scrollTop = 0; }
if (e.key === 'G') { e.preventDefault(); contentEl.scrollTop = contentEl.scrollHeight; }
if (currentPdfDoc && !readerSettings.pdfPaginated) {
if (e.key === 'n') { e.preventDefault(); pdfGoToPage(pdfCurrentPage + 1); }
if (e.key === 'p') { e.preventDefault(); pdfGoToPage(pdfCurrentPage - 1); }
}
}
}
}
});
document.addEventListener('keyup', e => {
if (e.key === 'j' || e.key === 'k') {
clearInterval(_readerScrollInterval);
_readerScrollInterval = null;
_readerScrollDir = 0;
}
});
function sanitizeSidebarHtml(html) {
if (!html) return '<p class="muted">Keine Shownotes vorhanden.</p>';
const div = document.createElement('div');
div.innerHTML = html;
div.querySelectorAll('script, iframe, object, embed, style').forEach(el => el.remove());
div.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
if (attr.name.startsWith('on')) el.removeAttribute(attr.name);
});
if (el.tagName === 'A') {
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener noreferrer');
}
});
return div.innerHTML;
}
// ---------------------------------------------------------------------------
// Service Worker
// ---------------------------------------------------------------------------
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
// Registered from '/sw.js' (root), not '/static/js/sw.js': a service
// worker's default scope is its own script's directory, so registering
// it from under /static/js/ would only ever let it control requests
// under that path — page navigations to '/', '/books/' etc. would never
// be intercepted, breaking offline entirely.
navigator.serviceWorker.register('/sw.js', {scope: '/'}).catch(err => {
console.warn('Service worker registration failed:', err);
});
// Drop any leftover registration from the old, wrongly-scoped '/static/js/sw.js'
// path so it doesn't keep running or fight the new root-scoped worker.
navigator.serviceWorker.getRegistrations().then(regs => {
regs.forEach(r => { if (r.scope.endsWith('/static/js/')) r.unregister(); });
});
});
// When a new SW activates it sends SW_ACTIVATED — reload to get fresh assets,
// but never in the middle of something: not while a book is open, radio or a
// podcast plays, or a text field has the focus — then as soon as that's over.
let reloadPending = false;
const busy = () => {
if (currentBookId || isPlaying) return true;
const host = window.DioraPodcastHost;
if (host && host.playerState) {
try { if (JSON.parse(host.playerState()).playing) return true; } catch (_) {}
}
const el = document.activeElement;
return !!el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
};
const reloadWhenIdle = () => {
if (busy()) setTimeout(reloadWhenIdle, 5000);
else window.location.reload();
};
navigator.serviceWorker.addEventListener('message', e => {
if (e.data?.type === 'SW_ACTIVATED' && !reloadPending) {
reloadPending = true;
reloadWhenIdle();
}
});
}
// ---------------------------------------------------------------------------
// M3U import
// ---------------------------------------------------------------------------
async function importM3U(input) {
const file = input.files[0];
if (!file) return;
const status = document.getElementById('import-status');
status.textContent = 'Importiere …';
const form = new FormData();
form.append('file', file);
form.append('csrfmiddlewaretoken', getCsrfToken());
try {
const res = await fetch('/radio/import/', { method: 'POST', body: form });
const data = await res.json();
if (data.ok) {
status.textContent = `✓ ${data.added} hinzugefügt, ${data.skipped} schon gespeichert`;
if (data.added > 0) location.reload();
} else {
status.textContent = `Fehler: ${_germanError(data.error)}`;
}
} catch (e) {
status.textContent = 'Hochladen fehlgeschlagen';
}
input.value = '';
}
// ---------------------------------------------------------------------------
// Contrast scheme
// ---------------------------------------------------------------------------
// Accent palette — ordered by preference. Algorithm picks the one with the
// highest WCAG contrast ratio against the detected background luminance.
const ACCENT_PALETTE = [
{ base: '#e63946', hover: '#ff4d58' }, // red
{ base: '#ff9500', hover: '#ffaa33' }, // orange
{ base: '#f1c40f', hover: '#f9d439' }, // yellow
{ base: '#2ecc71', hover: '#4ee88a' }, // green
{ base: '#00b4d8', hover: '#33c7e5' }, // cyan
{ base: '#4361ee', hover: '#6d84f4' }, // blue
{ base: '#c77dff', hover: '#d89fff' }, // purple
{ base: '#ff6b9d', hover: '#ff8fb5' }, // pink
{ base: '#ffffff', hover: '#cccccc' }, // white (last resort)
];
function _linearise(c) {
c /= 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function _luminance(r, g, b) {
return 0.2126 * _linearise(r) + 0.7152 * _linearise(g) + 0.0722 * _linearise(b);
}
function _contrast(l1, l2) {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function _hexRgb(hex) {
return [parseInt(hex.slice(1,3),16), parseInt(hex.slice(3,5),16), parseInt(hex.slice(5,7),16)];
}
function analyzeBackground(url) {
return new Promise(resolve => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 64; canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, 64, 64);
const data = ctx.getImageData(0, 0, 64, 64).data;
let tR = 0, tG = 0, tB = 0, tBt601 = 0;
const n = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
tR += data[i]; tG += data[i+1]; tB += data[i+2];
tBt601 += 0.299*data[i] + 0.587*data[i+1] + 0.114*data[i+2];
}
resolve({
bright: (tBt601 / n) > 127,
bgLuminance: _luminance(tR/n, tG/n, tB/n),
});
};
img.onerror = () => resolve({ bright: false, bgLuminance: 0 });
img.src = url;
});
}
function pickBestAccent(bgLuminance) {
let best = ACCENT_PALETTE[0], bestRatio = 0;
for (const entry of ACCENT_PALETTE) {
const [r, g, b] = _hexRgb(entry.base);
const ratio = _contrast(bgLuminance, _luminance(r, g, b));
if (ratio > bestRatio) { bestRatio = ratio; best = entry; }
}
return best;
}
function applyAccent(entry) {
const root = document.documentElement;
root.style.setProperty('--accent', entry.base);
root.style.setProperty('--accent-hover', entry.hover);
}
function setScheme(bright) {
document.body.classList.toggle('bright-bg', bright);
const btn = document.getElementById('contrast-toggle');
if (btn) btn.style.opacity = bright ? '1' : '0.5';
}
function toggleContrast() {
setScheme(!document.body.classList.contains('bright-bg'));
}
// ---------------------------------------------------------------------------
// E2E Encryption utilities (Web Crypto API)
// ---------------------------------------------------------------------------
let _encKey = null;
function bytesToBase64(buf) {
const bytes = new Uint8Array(buf);
let str = '';
for (const b of bytes) str += String.fromCharCode(b);
return btoa(str);
}
function base64ToBytes(b64) {
const str = atob(b64);
const buf = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i);
return buf;
}
function bytesToHex(buf) {
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
function hexToBytes(hex) {
const arr = new Uint8Array(hex.length / 2);
for (let i = 0; i < arr.length; i++) arr[i] = parseInt(hex.slice(i*2, i*2+2), 16);
return arr;
}
async function getOrCreateEncKey() {
if (_encKey) return _encKey;
const storageKey = `diora_enc_key_${window.USER_ID || 0}`;
const stored = localStorage.getItem(storageKey);
if (stored) {
try {
const raw = base64ToBytes(stored);
_encKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt', 'decrypt']);
return _encKey;
} catch (e) { /* fall through, generate new */ }
}
// No key found — generate one and store it
_encKey = await crypto.subtle.generateKey({name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
const raw = await crypto.subtle.exportKey('raw', _encKey);
localStorage.setItem(storageKey, bytesToBase64(new Uint8Array(raw)));
return _encKey;
}
async function encryptBytes(key, plainBytes) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, plainBytes);
return {iv: bytesToHex(iv), ciphertext: bytesToBase64(ct)};
}
async function decryptBytes(key, ivHex, ctB64) {
const iv = hexToBytes(ivHex);
const ct = base64ToBytes(ctB64);
return crypto.subtle.decrypt({name: 'AES-GCM', iv}, key, ct);
}
// ---------------------------------------------------------------------------
// Encrypted wallpaper
// ---------------------------------------------------------------------------
async function uploadBackground(file) {
if (!file) return;
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
await customAlert('Nur JPEG-, PNG- oder WebP-Bilder sind erlaubt.');
return;
}
if (file.size > DIORA_CONFIG.bgMaxBytes) {
await customAlert(`Das Bild darf höchstens ${DIORA_CONFIG.bgMaxBytes / 1024 / 1024} MB groß sein.`);
return;
}
const key = await getOrCreateEncKey();
const buf = await file.arrayBuffer();
const {iv, ciphertext} = await encryptBytes(key, buf);
const res = await fetch('/accounts/background/upload/', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({iv, ciphertext, mime_type: file.type, file_size: file.size}),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error || 'upload failed');
}
async function applyEncryptedBackground() {
if (typeof ENCRYPTED_BG === 'undefined' || !ENCRYPTED_BG.ciphertext) return;
try {
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, ENCRYPTED_BG.iv, ENCRYPTED_BG.ciphertext);
const blob = new Blob([plain], {type: ENCRYPTED_BG.mime || 'image/jpeg'});
const url = URL.createObjectURL(blob);
document.body.style.backgroundImage = `url('${url}')`;
document.body.style.backgroundSize = 'cover';
document.body.style.backgroundPosition = 'center';
document.body.style.backgroundAttachment = 'fixed';
// Analyze brightness for accent/scheme
analyzeBackground(url).then(({bright, bgLuminance}) => {
setScheme(bright);
applyAccent(pickBestAccent(bgLuminance));
});
} catch (e) {
console.warn('Could not decrypt background:', e);
}
}
// ---------------------------------------------------------------------------
// EPUB parser (requires JSZip)
// ---------------------------------------------------------------------------
function resolveEpubPath(base, relative) {
if (!relative) return '';
if (relative.startsWith('/')) return relative.slice(1);
const hashIdx = relative.indexOf('#');
const frag = hashIdx >= 0 ? relative.slice(hashIdx) : '';
const rel = hashIdx >= 0 ? relative.slice(0, hashIdx) : relative;
const parts = (base + rel).split('/');
const resolved = [];
for (const p of parts) {
if (p === '..') resolved.pop();
else if (p !== '.') resolved.push(p);
}
return resolved.join('/') + frag;
}
async function parseEpub(arrayBuffer) {
const zip = await JSZip.loadAsync(arrayBuffer);
// 1. Find OPF via container.xml
const containerXml = await zip.file('META-INF/container.xml').async('text');
const containerDoc = new DOMParser().parseFromString(containerXml, 'application/xml');
const rootfileEl = containerDoc.querySelector('rootfile');
if (!rootfileEl) throw new Error('Ungültige EPUB-Datei (container.xml ohne rootfile)');
const opfPath = rootfileEl.getAttribute('full-path');
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
// 2. Parse OPF
const opfText = await zip.file(opfPath).async('text');
const opfDoc = new DOMParser().parseFromString(opfText, 'application/xml');
const title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || 'Unbekannter Titel';
const author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || 'Unbekannter Autor';
// 3. Build manifest: id → {href, mediaType, properties}
const manifest = {};
opfDoc.querySelectorAll('manifest > item').forEach(item => {
manifest[item.getAttribute('id')] = {
href: opfDir + item.getAttribute('href'),
mediaType: item.getAttribute('media-type') || '',
properties: item.getAttribute('properties') || '',
};
});
// 4. Build image map: abs zip path → blob URL (parallel)
const imageMap = {};
await Promise.all(
Object.values(manifest)
.filter(({mediaType}) => mediaType.startsWith('image/'))
.map(async ({href, mediaType}) => {
try {
const buf = await zip.file(href).async('arraybuffer');
imageMap[href] = URL.createObjectURL(new Blob([buf], {type: mediaType}));
} catch (e) { /* missing asset */ }
})
);
// 5. Parse TOC
const toc = await _parseEpubToc(zip, opfDoc, manifest);
// 6. Get spine items; chapters are returned raw for progressive DOM injection
const spineItems = Array.from(opfDoc.querySelectorAll('spine > itemref'))
.map(ref => manifest[ref.getAttribute('idref')]?.href)
.filter(Boolean);
// All chapters unpacked at once: one after the other, every chapter waits for
// JSZip's own ticks (a book like Das Kapital has 148 of them).
const texts = await Promise.all(spineItems.map(href => {
const f = zip.file(href);
return f ? f.async('text').catch(() => null) : null;
}));
const chapters = [];
let slice = performance.now();
for (let i = 0; i < spineItems.length; i++) {
const href = spineItems[i];
if (texts[i] == null) continue; // missing
try {
let chapterText = texts[i];
texts[i] = null;
// Strip style/script blocks early — publisher EPUBs often embed large base64 fonts in CSS.
// This shrinks the string before regex and DOMParser work on it.
chapterText = chapterText.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
chapterText = chapterText.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
const chapterDir = href.includes('/') ? href.substring(0, href.lastIndexOf('/') + 1) : '';
const withBlobs = _injectImageBlobs(chapterText, chapterDir, imageMap);
const sanitized = sanitizeEpubHtml(withBlobs);
chapters.push(`<div id="epub-chapter-${i}" data-epub-src="${href}">${sanitized}</div>`);
// Let the page breathe (spinner, taps) every ~50 ms of work, not after a fixed count.
if (performance.now() - slice > 50) {
await new Promise(r => setTimeout(r, 0));
slice = performance.now();
}
} catch (e) { /* skip unreadable */ }
}
return {title, author, chapters, toc, imageMap};
}
async function _parseEpubToc(zip, opfDoc, manifest) {
// Try EPUB3 nav document
const navItem = Object.values(manifest).find(m => m.properties.includes('nav'));
if (navItem) {
try {
const navText = await zip.file(navItem.href).async('text');
const navDoc = new DOMParser().parseFromString(navText, 'application/xhtml+xml');
const tocNav = navDoc.querySelector('nav[epub\\:type="toc"]') || navDoc.querySelector('nav');
if (tocNav) {
const ol = tocNav.querySelector('ol');
if (ol) return _parseTocOl(ol, navItem.href, 0);
}
} catch (e) {}
}
// Fall back to EPUB2 NCX
const ncxItem = Object.values(manifest).find(m => m.mediaType === 'application/x-dtbncx+xml');
if (ncxItem) {
try {
const ncxText = await zip.file(ncxItem.href).async('text');
const ncxDoc = new DOMParser().parseFromString(ncxText, 'application/xml');
const ncxDir = ncxItem.href.includes('/') ? ncxItem.href.substring(0, ncxItem.href.lastIndexOf('/') + 1) : '';
return _parseNcxNavMap(ncxDoc.querySelector('navMap'), ncxDir, 0);
} catch (e) {}
}
return [];
}
function _parseTocOl(ol, navHref, depth) {
if (!ol || depth > 5) return [];
const navDir = navHref.includes('/') ? navHref.substring(0, navHref.lastIndexOf('/') + 1) : '';
const items = [];
for (const li of Array.from(ol.children)) {
const a = li.querySelector(':scope > a') || li.querySelector(':scope > span');
if (a) {
const rawHref = a.getAttribute('href') || '';
items.push({
label: a.textContent.trim(),
href: rawHref ? resolveEpubPath(navDir, rawHref) : '',
depth,
});
}
const childOl = li.querySelector(':scope > ol');
if (childOl) items.push(..._parseTocOl(childOl, navHref, depth + 1));
}
return items;
}
function _parseNcxNavMap(navMap, ncxDir, depth) {
if (!navMap || depth > 5) return [];
const items = [];
for (const navPoint of Array.from(navMap.children)) {
if (navPoint.tagName !== 'navPoint') continue;
const label = navPoint.querySelector('navLabel > text')?.textContent?.trim() || '';
const src = navPoint.querySelector('content')?.getAttribute('src') || '';
items.push({label, href: src ? resolveEpubPath(ncxDir, src) : '', depth});
items.push(..._parseNcxNavMap(navPoint, ncxDir, depth + 1));
}
return items;
}
function _resolveImageBlob(imageMap, absPath) {
if (imageMap[absPath]) return imageMap[absPath];
// Fallback: match by decoded filename only
const name = decodeURIComponent(absPath.split('/').pop()).toLowerCase();
for (const [k, v] of Object.entries(imageMap)) {
if (decodeURIComponent(k.split('/').pop()).toLowerCase() === name) return v;
}
return null;
}
// Replace image src/href in raw chapter HTML text with blob URLs before DOMParser sees it.
// This avoids relying on innerHTML serialisation preserving attributes we set on DOM nodes.
function _injectImageBlobs(html, chapterDir, imageMap) {
function subst(src) {
if (!src || src.startsWith('blob:') || src.startsWith('data:') || src.startsWith('http')) return src;
return _resolveImageBlob(imageMap, resolveEpubPath(chapterDir, src)) || '';
}
// <img src="...">
html = html.replace(/(<img\b[^>]*?)\bsrc="([^"]*)"/gi,
(_, pre, src) => { const b = subst(src); return b ? `${pre}src="${b}"` : pre; });
// SVG <image xlink:href="...">
html = html.replace(/(<image\b[^>]*?)\bxlink:href="([^"]*)"/gi,
(_, pre, src) => { const b = subst(src); return b ? `${pre}xlink:href="${b}"` : pre; });
// SVG <image href="...">
html = html.replace(/(<image\b[^>]*?)\bhref="([^"]*)"/gi,
(_, pre, src) => { const b = subst(src); return b ? `${pre}href="${b}"` : pre; });
return html;
}
// EPUBs mark footnote/endnote reference links very inconsistently across
// editions/tools — some use class="footnote" (same-file '#frag' href), some
// use the EPUB3 epub:type="noteref" attribute, and some (e.g. older
// Calibre/zeno.org conversions) use neither and just wrap a plain link to
// another chapter file's fragment in a <sup> — in either direction
// (<sup><a>…</a></sup> or <a><sup>…</sup></a>). Recognize all of these
// rather than only the single hardcoded class name.
function _looksLikeFootnoteLink(el, href) {
if (!href.includes('#')) return false;
const cls = (el.getAttribute('class') || '').toLowerCase();
if (/\bnote|\bfootnote|\bfn\b/.test(cls)) return true;
const epubType = (el.getAttribute('epub:type') || '').toLowerCase();
if (epubType.includes('noteref')) return true;
return !!(el.closest('sup') || el.querySelector('sup'));
}
// Chapters are XHTML, but parsed as HTML (forgiving with broken books). HTML
// knows no self-closing <a id="x"/>: the anchor stays open and swallows the
// text after it — and being a formatting element it is re-opened in every
// following paragraph (Suhrkamp/transpect books put one in each heading and
// note). Close the inline ones; block elements are left alone so the block
// indexes that highlights are anchored to stay the same.
const _SELF_CLOSING_INLINE = /<(a|span|b|i|em|strong|sup|sub|small|u|s|code|font|abbr|cite|q|mark|var)(\s(?:[^<>"']|"[^"]*"|'[^']*')*)?\/>/gi;
function sanitizeEpubHtml(html) {
html = html.replace(_SELF_CLOSING_INLINE, '<$1$2></$1>');
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll('script, iframe, object, embed, style, head, meta, link').forEach(el => el.remove());
doc.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
if (attr.name.startsWith('on') || attr.name === 'title') el.removeAttribute(attr.name);
});
const tag = el.tagName.toLowerCase();
if (tag === 'img') {
const src = el.getAttribute('src') || '';
// Remove any non-blob, non-data src that slipped through (broken relative paths)
if (src && !src.startsWith('blob:') && !src.startsWith('data:')) el.removeAttribute('src');
}
// The printed edition's page numbers ("[12]") as small marks, not glued to the text.
const etype = el.getAttribute('epub:type') || '';
if (/\bpagebreak\b/.test(etype) || el.getAttribute('role') === 'doc-pagebreak') {
el.classList.add('epub-pagenum');
}
if (el.tagName === 'A') {
const href = el.getAttribute('href') || '';
if (href.startsWith('http://') || href.startsWith('https://')) {
el.setAttribute('target', '_blank');
el.setAttribute('rel', 'noopener noreferrer');
} else if (_looksLikeFootnoteLink(el, href)) {
// The whole book is one concatenated DOM, so a fragment id resolves
// via getElementById regardless of which chapter file it originally
// pointed into — strip any "../Text/other.html" path prefix and keep
// just the id after the last '#'.
el.dataset.footnoteRef = href.slice(href.indexOf('#') + 1);
el.removeAttribute('href');
el.style.cursor = 'pointer';
} else {
el.removeAttribute('href');
el.style.cursor = 'default';
}
}
});
return doc.body ? doc.body.innerHTML : doc.documentElement.innerHTML;
}
// ---------------------------------------------------------------------------
// Books
// ---------------------------------------------------------------------------
let currentBookId = null;
let currentBookToc = [];
let currentImageMap = {};
let readerScrollSaveTimer = null;
let _resizeObserver = null;
let _currentPositionAnchor = '';
let _currentBookCharCount = 0;
// Rough characters per printed book page, used to estimate a real page count for EPUBs.
const CHARS_PER_BOOK_PAGE = 1800;
const bookMetaCache = {}; // id → {title, author, type}
// Reading-position percentages (the progress readout, annotation "% im Buch"
// coordinates): a whole percent is fine for a normal book, but on a long one
// (~600-700 physical pages and up) it barely moves during a whole scrolling
// session, which reads as broken. Past that length, show one decimal —
// formatted with the browser locale's own decimal separator.
const LONG_BOOK_CHAR_THRESHOLD = 1000000;
function _formatBookPercent(fraction) {
const pct = Math.max(0, Math.min(100, fraction * 100));
const decimals = _currentBookCharCount > LONG_BOOK_CHAR_THRESHOLD ? 1 : 0;
return pct.toLocaleString(undefined, {minimumFractionDigits: decimals, maximumFractionDigits: decimals});
}
// Accepts both '45.3' and '45,3' (locale-typed input)
function _parseLocalePercent(str) {
const v = parseFloat(String(str).trim().replace(',', '.'));
return isNaN(v) ? 0 : v;
}
// ---------------------------------------------------------------------------
// Reader "scrolled too far" safety net — offers a toast to jump back after a
// sudden, large scroll (accidental fling, fat-fingered keybind, etc.). Only
// watches organic scrolling; explicit destination jumps (TOC, bookmarks, the
// progress input, search) call _suppressScrollJumpDetect() first so they
// don't trigger it.
// ---------------------------------------------------------------------------
let _scrollRestTop = null; // scrollTop last "settled" at, before the current burst
let _scrollRestDebounce = null;
let _scrollJumpSuppressed = false; // true while an explicit/programmatic jump is in flight
let _scrollJumpToastEl = null;
// Marks the current (and any immediately-following, possibly animated) scroll
// as deliberate, so _checkScrollJump ignores it. Suppression lifts itself
// once scrolling actually settles (see _armScrollJumpSettle) — not after a
// fixed delay — so it stays active for however long a `smooth` scroll
// animation to a far-away target actually takes, and calling this again
// (e.g. from repeated resize-triggered corrections while a book settles)
// simply pushes that settle point further out instead of racing it.
function _suppressScrollJumpDetect() {
_scrollJumpSuppressed = true;
_armScrollJumpSettle();
}
function _armScrollJumpSettle() {
clearTimeout(_scrollRestDebounce);
_scrollRestDebounce = setTimeout(() => {
const contentEl = $('reader-content');
if (contentEl) _scrollRestTop = contentEl.scrollTop;
_scrollJumpSuppressed = false;
}, 500);
}
function _dismissScrollJumpToast() {
if (_scrollJumpToastEl) {
_scrollJumpToastEl.remove();
_scrollJumpToastEl = null;
}
}
function _showScrollJumpToast(backTop) {
_dismissScrollJumpToast();
const toast = document.createElement('div');
toast.className = 'reader-toast reader-toast-action';
toast.innerHTML = `<span>Weit gescrollt</span><button type="button" class="reader-toast-btn">Zurückspringen</button>`;
document.body.appendChild(toast);
_scrollJumpToastEl = toast;
toast.querySelector('.reader-toast-btn').addEventListener('click', () => {
const contentEl = $('reader-content');
if (contentEl) {
_suppressScrollJumpDetect();
contentEl.scrollTo({top: backTop, behavior: 'smooth'});
}
_dismissScrollJumpToast();
});
setTimeout(_dismissScrollJumpToast, 6000);
}
function _checkScrollJump(contentEl) {
const top = contentEl.scrollTop;
if (_scrollRestTop === null) _scrollRestTop = top; // first sample this session
if (!_scrollJumpSuppressed && !_scrollJumpToastEl) {
const screen = contentEl.clientHeight || 1;
if (Math.abs(top - _scrollRestTop) > screen * 3.5) {
_showScrollJumpToast(_scrollRestTop);
}
}
// Only adopt the new position as the "settled" baseline — and lift any
// suppression — once scrolling has paused for a bit. That's what lets a
// fast, still-in-progress fling (or a long smooth-scroll animation) be
// compared against/protected until it actually finishes, not mid-flight.
_armScrollJumpSettle();
}
const EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))';
// The open book's text blocks (EPUB_BLOCK_SELECTOR), with the chapter each one
// belongs to — found once and kept until the book's content changes (a
// highlight wrapping text can turn a leaf div into a non-leaf one). Selecting
// them is the expensive part on an e-reader: Chrome 61 has no :has(), so
// compat61.js filters every div in JavaScript.
let _blockCache = null; // {content, blocks, chapterOf: Int32Array, chapters: Element[]}
function _epubBlocks(contentEl) {
if (_blockCache && _blockCache.content === contentEl) return _blockCache;
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
const chapters = Array.from(contentEl.querySelectorAll('[data-epub-src]'));
const chapterOf = new Int32Array(blocks.length);
for (let i = 0, ch = -1; i < blocks.length; i++) {
while (ch + 1 < chapters.length && chapters[ch + 1].compareDocumentPosition(blocks[i]) & Node.DOCUMENT_POSITION_FOLLOWING) ch++;
chapterOf[i] = ch;
}
const observer = new MutationObserver(() => {
if (_blockCache && _blockCache.content === contentEl) _blockCache = null;
observer.disconnect();
});
observer.observe(contentEl, {childList: true, subtree: true});
_blockCache = {content: contentEl, blocks, chapterOf, chapters};
return _blockCache;
}
// The anchor of the reading position: "block:inner" — the last block (with a
// height) that starts at or above the top of the view, the first of several
// starting at the same height; else the first block below. Blocks are in
// document order and so top to bottom: a binary search over the chapters on
// screen (the e-reader mode shows one), not every block from the book's start.
function getPositionAnchor(contentEl) {
const {blocks, chapterOf, chapters} = _epubBlocks(contentEl);
if (!blocks.length) return '';
const containerTop = contentEl.getBoundingClientRect().top;
const containerBottom = containerTop + contentEl.clientHeight;
// Index ranges of the blocks in chapters that are laid out (all, when reading by scrolling).
const shown = new Set();
chapters.forEach((ch, i) => { if (ch.getClientRects().length) shown.add(i); });
const order = [];
for (let i = 0; i < blocks.length; i++) {
if (chapterOf[i] < 0 || shown.has(chapterOf[i])) {
let j = i;
while (j + 1 < blocks.length && chapterOf[j + 1] === chapterOf[i]) j++;
order.push([i, j]);
i = j;
}
}
const count = order.reduce((n, r) => n + r[1] - r[0] + 1, 0);
const at = k => { // k-th block on screen -> block index
for (const [a, b] of order) { if (k <= b - a) return a + k; k -= b - a + 1; }
return -1;
};
const rectOf = k => blocks[at(k)].getBoundingClientRect();
// A block without height says nothing about the position: use the next one with a height.
const measured = k => {
for (let n = k; n < count; n++) { const r = rectOf(n); if (r.height >= 1) return {k: n, top: r.top}; }
return null;
};
// Binary search for the last block with a height whose top <= containerTop.
let lo = 0, hi = count - 1, best = -1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const m = measured(mid);
if (m && m.top <= containerTop) { best = m.k; lo = m.k + 1; }
else hi = mid - 1;
}
let bestIndex;
if (best >= 0) {
// Several blocks starting at the same height (a blockquote and its first paragraph): the first.
const top = rectOf(best).top;
let k = best;
for (let n = best - 1; n >= 0; n--) {
const r = rectOf(n);
if (r.height < 1) continue;
if (r.top !== top) break;
k = n;
}
bestIndex = at(k);
} else {
const first = measured(0);
bestIndex = first && first.top <= containerBottom ? at(first.k) : 0;
}
const rect = blocks[bestIndex].getBoundingClientRect();
const innerFraction = Math.max(0, Math.min(1, (containerTop - rect.top) / (rect.height || 1)));
return `${bestIndex}:${innerFraction.toFixed(6)}`;
}
// Reading progress as a share of the book's text (characters), from a
// position anchor. Independent of layout — font size, screen width, which
// chapters are rendered (the Android app's e-reader mode lays out one chapter
// at a time) — so every client shows the same percentage for the same place.
// The pixel share (scrollTop / scrollHeight) used before differed between
// layouts and counted margins, headings and images as reading.
let _charIndex = null; // {content, starts[], lens[], total} for the open book
function _bookCharIndex(contentEl) {
if (_charIndex && _charIndex.content === contentEl) return _charIndex;
const offsets = new Map();
let pos = 0;
const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
offsets.set(node, pos);
pos += node.nodeValue.length;
}
const blocks = contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR);
const starts = new Array(blocks.length);
const lens = new Array(blocks.length);
for (let i = 0; i < blocks.length; i++) {
const first = document.createTreeWalker(blocks[i], NodeFilter.SHOW_TEXT).nextNode();
starts[i] = first ? offsets.get(first) : (i ? starts[i - 1] + lens[i - 1] : 0);
lens[i] = blocks[i].textContent.length;
}
_charIndex = {content: contentEl, starts, lens, total: pos || 1};
return _charIndex;
}
// Share of the text before the anchored place (0..1), or null.
function _fractionFromAnchor(contentEl, anchor) {
const [block, inner] = _anchorParts(anchor);
if (block < 0) return null;
const idx = _bookCharIndex(contentEl);
if (block >= idx.starts.length) return null;
return Math.max(0, Math.min(1, (idx.starts[block] + inner * idx.lens[block]) / idx.total));
}
// Anchor of the place where `fraction` of the text has been read.
function _anchorForFraction(contentEl, fraction) {
const idx = _bookCharIndex(contentEl);
if (!idx.starts.length) return '';
const target = Math.max(0, Math.min(1, fraction)) * idx.total;
let lo = 0, hi = idx.starts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (idx.starts[mid] <= target) lo = mid; else hi = mid - 1;
}
const inner = idx.lens[lo] ? Math.max(0, Math.min(1, (target - idx.starts[lo]) / idx.lens[lo])) : 0;
return `${lo}:${inner.toFixed(6)}`;
}
function restoreFromAnchor(contentEl, anchor) {
if (!anchor) return false;
const parts = anchor.split(':');
if (parts.length !== 2) return false;
const idx = parseInt(parts[0], 10);
const innerFraction = parseFloat(parts[1]);
if (isNaN(idx) || isNaN(innerFraction)) return false;
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
if (idx >= blocks.length) return false;
const el = blocks[idx];
contentEl.scrollTop = el.offsetTop + Math.round(innerFraction * el.offsetHeight);
return true;
}
// Split 'blockIndex:innerFraction' into [block, inner]; [-1, 0] if invalid/empty.
function _anchorParts(anchor) {
if (typeof anchor !== 'string') return [-1, 0];
const c = anchor.indexOf(':');
if (c < 0) return [-1, 0];
const b = parseInt(anchor.slice(0, c), 10);
const f = parseFloat(anchor.slice(c + 1));
return [Number.isFinite(b) ? b : -1, Number.isFinite(f) ? f : 0];
}
// >0 if position A is further into the book than B, <0 if behind, 0 if equal.
// Compares by anchor block index (precise); falls back to scroll_fraction only
// when an anchor is missing (e.g. PDFs), matching the server's _progress_is_further.
function _cmpProgress(anchorA, fracA, anchorB, fracB) {
const [ba, ia] = _anchorParts(anchorA);
const [bb, ib] = _anchorParts(anchorB);
if (ba >= 0 && bb >= 0) return ba !== bb ? ba - bb : ia - ib;
return (fracA || 0) - (fracB || 0);
}
// ---------------------------------------------------------------------------
// Book cache — IndexedDB, evict after 4 weeks of inactivity
// ---------------------------------------------------------------------------
const _BOOK_CACHE_STORE = 'books';
const _BOOK_META_STORE = 'book_meta';
const _BOOK_ANNOTATIONS_STORE = 'book_annotations';
const _BOOK_PARSED_STORE = 'parsed'; // prepared chapters, see _getParsedBook
const _BOOK_CACHE_EVICT_MS = 4 * 7 * 24 * 60 * 60 * 1000;
function _openBookCacheDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open('diora_book_cache', 3);
req.onupgradeneeded = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains(_BOOK_CACHE_STORE))
db.createObjectStore(_BOOK_CACHE_STORE, {keyPath: 'id'});
if (!db.objectStoreNames.contains(_BOOK_META_STORE))
db.createObjectStore(_BOOK_META_STORE, {keyPath: 'id'});
if (!db.objectStoreNames.contains(_BOOK_ANNOTATIONS_STORE))
db.createObjectStore(_BOOK_ANNOTATIONS_STORE, {keyPath: 'id'});
if (!db.objectStoreNames.contains(_BOOK_PARSED_STORE))
db.createObjectStore(_BOOK_PARSED_STORE, {keyPath: 'id'});
};
req.onsuccess = e => resolve(e.target.result);
req.onerror = () => reject();
});
}
// ---------------------------------------------------------------------------
// Prepared books: an EPUB's chapters after unpacking and sanitizing, so opening
// it again skips both (for a book like Das Kapital that's most of the time an
// e-reader needs to open it). Encrypted with the book key like everything else
// on the device. Valid for one version of the book file (its IV) and of the
// code that prepares chapters (a hash of its source: changing the sanitizer
// makes every book be prepared anew). Images stay in the book file: the
// chapters keep "diora-img:<path>" in place of the session's blob URLs.
// ---------------------------------------------------------------------------
let _parseCodeSig = null;
function _parsePreparationSig() {
if (_parseCodeSig) return _parseCodeSig;
const src = [parseEpub, sanitizeEpubHtml, _injectImageBlobs, _resolveImageBlob, _parseEpubToc]
.map(f => String(f)).join('\n');
let h = 5381;
for (let i = 0; i < src.length; i++) h = ((h << 5) + h + src.charCodeAt(i)) | 0;
return (_parseCodeSig = (h >>> 0).toString(36) + '.' + src.length);
}
async function _getParsedBook(bookId, dataIv, key) {
try {
const db = await _openBookCacheDb();
const row = await new Promise(resolve => {
const req = db.transaction(_BOOK_PARSED_STORE).objectStore(_BOOK_PARSED_STORE).get(bookId);
req.onsuccess = e => resolve(e.target.result || null);
req.onerror = () => resolve(null);
});
if (!row || row.sig !== dataIv + ':' + _parsePreparationSig()) return null;
const plain = await crypto.subtle.decrypt({name: 'AES-GCM', iv: row.iv}, key, row.ct);
return JSON.parse(new TextDecoder().decode(plain));
} catch (e) { return null; }
}
async function _setParsedBook(bookId, dataIv, key, result) {
try {
// Blob URLs live as long as this page; the stored chapters name the image instead.
const pathOf = {};
for (const [path, url] of Object.entries(result.imageMap || {})) pathOf[url] = path;
const blobRe = /blob:[^"'\s)]+/g;
const chapters = result.chapters.map(c => c.replace(blobRe, u => pathOf[u] ? 'diora-img:' + pathOf[u] : u));
const doc = {title: result.title, author: result.author, toc: result.toc, chapters,
images: Object.keys(result.imageMap || {})};
// Raw bytes, not base64: IndexedDB keeps an ArrayBuffer as it is (megabytes for a big book).
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({name: 'AES-GCM', iv}, key, new TextEncoder().encode(JSON.stringify(doc)));
const db = await _openBookCacheDb();
await new Promise(resolve => {
const tx = db.transaction(_BOOK_PARSED_STORE, 'readwrite');
const store = tx.objectStore(_BOOK_PARSED_STORE);
store.put({id: bookId, sig: dataIv + ':' + _parsePreparationSig(), iv, ct, cached_at: Date.now()});
// Prepared books not opened for a while go (they come back on the next open).
const all = store.getAll();
all.onsuccess = () => all.result.forEach(r => {
if (r.id !== bookId && Date.now() - (r.cached_at || 0) > _BOOK_CACHE_EVICT_MS) store.delete(r.id);
});
tx.oncomplete = resolve;
tx.onerror = resolve;
});
} catch (e) {}
}
/** A prepared book back to what parseEpub returns: fresh blob URLs for its images. */
async function _restoreParsedBook(doc, epubBytes) {
const imageMap = {};
if (doc.images && doc.images.length) {
const zip = await JSZip.loadAsync(epubBytes);
await Promise.all(doc.images.map(async path => {
const f = zip.file(path);
if (!f) return;
try {
const buf = await f.async('arraybuffer');
const ext = path.split('.').pop().toLowerCase();
const type = {jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
svg: 'image/svg+xml', webp: 'image/webp'}[ext] || '';
imageMap[path] = URL.createObjectURL(new Blob([buf], type ? {type} : {}));
} catch (e) {}
}));
}
const placeholder = /diora-img:([^"'\s)]+)/g;
const chapters = doc.chapters.map(c => c.replace(placeholder, (m, path) => imageMap[path] || ''));
return {title: doc.title, author: doc.author, toc: doc.toc, chapters, imageMap};
}
async function _getCachedBook(bookId) {
try {
const db = await _openBookCacheDb();
return await new Promise((resolve) => {
const req = db.transaction(_BOOK_CACHE_STORE).objectStore(_BOOK_CACHE_STORE).get(bookId);
req.onsuccess = e => resolve(e.target.result || null);
req.onerror = () => resolve(null);
});
} catch { return null; }
}
async function _setCachedBook(bookId, data_ct, data_iv) {
try {
const db = await _openBookCacheDb();
await new Promise((resolve) => {
const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite');
tx.objectStore(_BOOK_CACHE_STORE).put({id: bookId, data_ct, data_iv, cached_at: Date.now()});
tx.oncomplete = resolve;
tx.onerror = resolve;
});
// Request persistent storage so the browser doesn't evict cached books
// under storage pressure. Chrome grants this silently for installed PWAs;
// Firefox may show a permission prompt.
if (navigator.storage?.persist) navigator.storage.persist();
} catch {}
}
async function _evictCachedBook(bookId) {
try {
const db = await _openBookCacheDb();
await new Promise((resolve) => {
const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite');
tx.objectStore(_BOOK_CACHE_STORE).delete(bookId);
tx.oncomplete = resolve;
tx.onerror = resolve;
});
} catch {}
}
async function _evictBookCache(bookList) {
try {
const db = await _openBookCacheDb();
const now = Date.now();
const metaById = Object.fromEntries(bookList.map(b => [b.id, b]));
await new Promise((resolve) => {
const tx = db.transaction(_BOOK_CACHE_STORE, 'readwrite');
const store = tx.objectStore(_BOOK_CACHE_STORE);
store.openCursor().onsuccess = e => {
const cursor = e.target.result;
if (!cursor) { resolve(); return; }
const meta = metaById[cursor.value.id];
const lastRead = meta?.last_read ? new Date(meta.last_read).getTime() : 0;
if (!meta || (now - lastRead) > _BOOK_CACHE_EVICT_MS) cursor.delete();
cursor.continue();
};
tx.onerror = resolve;
});
} catch {}
}
async function _saveBookMeta(books) {
try {
const db = await _openBookCacheDb();
const ids = new Set(books.map(b => String(b.id)));
await new Promise(resolve => {
const tx = db.transaction(_BOOK_META_STORE, 'readwrite');
const store = tx.objectStore(_BOOK_META_STORE);
for (const b of books) store.put(b);
store.openCursor().onsuccess = e => {
const cursor = e.target.result;
if (!cursor) return;
if (!ids.has(String(cursor.value.id))) cursor.delete();
cursor.continue();
};
tx.oncomplete = resolve;
tx.onerror = resolve;
});
} catch {}
}
async function _loadCachedBookMeta() {
try {
const db = await _openBookCacheDb();
return await new Promise(resolve => {
const req = db.transaction(_BOOK_META_STORE).objectStore(_BOOK_META_STORE).getAll();
req.onsuccess = e => resolve(e.target.result || []);
req.onerror = () => resolve([]);
});
} catch { return []; }
}
async function _updateCachedBookProgress(bookId, fraction, anchor, force = false) {
try {
const db = await _openBookCacheDb();
await new Promise(resolve => {
const tx = db.transaction(_BOOK_META_STORE, 'readwrite');
const store = tx.objectStore(_BOOK_META_STORE);
const req = store.get(bookId);
req.onsuccess = e => {
const entry = e.target.result;
// Only advance (or on force), so a transiently wrong save can't lose progress.
if (entry && (force || _cmpProgress(anchor, fraction, entry.position_anchor, entry.scroll_fraction) >= 0)) {
entry.scroll_fraction = fraction;
entry.position_anchor = anchor;
entry.last_read = new Date().toISOString();
store.put(entry);
}
};
tx.oncomplete = resolve;
tx.onerror = resolve;
});
} catch {}
}
async function _saveAnnotationsCache(bookId, type, ct, iv) {
try {
const db = await _openBookCacheDb();
await new Promise(resolve => {
const tx = db.transaction(_BOOK_ANNOTATIONS_STORE, 'readwrite');
tx.objectStore(_BOOK_ANNOTATIONS_STORE).put({id: `${bookId}_${type}`, ct, iv});
tx.oncomplete = resolve;
tx.onerror = resolve;
});
} catch {}
}
async function _loadAnnotationsCache(bookId, type) {
try {
const db = await _openBookCacheDb();
return await new Promise(resolve => {
const req = db.transaction(_BOOK_ANNOTATIONS_STORE).objectStore(_BOOK_ANNOTATIONS_STORE).get(`${bookId}_${type}`);
req.onsuccess = e => resolve(e.target.result || null);
req.onerror = () => resolve(null);
});
} catch { return null; }
}
const _PENDING_SYNC_KEY = 'diora_pending_sync';
function _markPendingSync(bookId, type) {
try {
const pending = JSON.parse(localStorage.getItem(_PENDING_SYNC_KEY) || '[]');
const key = `${bookId}:${type}`;
if (!pending.includes(key)) pending.push(key);
localStorage.setItem(_PENDING_SYNC_KEY, JSON.stringify(pending));
} catch {}
}
async function _syncPendingAnnotations() {
try {
const pending = JSON.parse(localStorage.getItem(_PENDING_SYNC_KEY) || '[]');
if (!pending.length) return;
const remaining = [];
for (const key of pending) {
const sep = key.indexOf(':');
const bookId = key.slice(0, sep);
const type = key.slice(sep + 1);
const cached = await _loadAnnotationsCache(bookId, type);
if (!cached?.ct) continue;
const url = type === 'bookmarks'
? `/books/${bookId}/bookmarks/`
: `/books/${bookId}/highlights/`;
try {
await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({ct: cached.ct, iv: cached.iv}),
});
} catch { remaining.push(key); }
}
localStorage.setItem(_PENDING_SYNC_KEY, JSON.stringify(remaining));
} catch {}
}
// Reader settings
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
fontFamily: 'serif', noBold: false, fontWeight: null,
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false };
let readerSettingsPanelOpen = false;
let currentPdfDoc = null;
let currentPdfBuffer = null;
// Bookmarks
let currentBookmarks = [];
let bookmarksDirty = false;
// Highlights
let currentHighlights = [];
let highlightsDirty = false;
let currentHighlightPopover = null;
// Search
let searchMatches = [];
let searchMatchIndex = -1;
let searchOriginalContent = null;
let readerSearchOpen = false;
// PDF paginated
let pdfCurrentPage = 1;
let pdfTotalPages = 0;
let _pdfPageTextBoxCache = {};
let _pdfRenderGen = 0;
let _touchStartX = 0;
let _readerScrollInterval = null;
let _readerScrollDir = 0;
let _readerScrollLastTs = 0;
const READER_SCROLL_SPEED = 1200; // constant px/s, no ramp-up
const READER_SCROLL_TICK_MS = 20; // fixed cadence, independent of the
// display's refresh rate — rAF runs at
// that rate (120/144/165Hz on plenty of
// laptops), which was driving 2-3x more
// scrollBy + scroll-listener work per
// second than intended and read as lag.
function _readerScrollTick() {
const now = performance.now();
const dt = now - _readerScrollLastTs;
_readerScrollLastTs = now;
$('reader-content')?.scrollBy({top: Math.round(_readerScrollDir * READER_SCROLL_SPEED * dt / 1000)});
}
let _pinchStartDist = 0;
let _pinchStartZoom = 100;
let _isPinching = false;
if (typeof pdfjsLib !== 'undefined') {
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js';
}
async function loadBookList() {
if (!IS_AUTHENTICATED) return;
const listEl = $('book-list');
if (!listEl) return;
listEl.innerHTML = '<p class="muted">Bücher werden geladen …</p>';
try {
listEl.innerHTML = '<p class="muted">Bücherliste wird vom Server geholt …</p>';
const res = await fetch('/books/', {cache: 'no-store'});
if (!res.ok) {
listEl.innerHTML = `<p class="muted">Serverfehler ${res.status} beim Laden der Bücher.</p>`;
return;
}
const books = await res.json();
_evictBookCache(books); // fire-and-forget
if (!Array.isArray(books)) {
listEl.innerHTML = `<p class="muted">Unerwartete Antwort vom Server.</p>`;
return;
}
if (!books.length) {
listEl.innerHTML = '<p class="muted">Noch keine Bücher. Zieh oben eine .epub- oder .pdf-Datei hinein.</p>';
return;
}
listEl.innerHTML = `<p class="muted">${books.length === 1 ? '1 Buch' : books.length + ' Bücher'} auf dem Server. Entschlüssele …</p>`;
let key;
try {
key = await getOrCreateEncKey();
} catch (e) {
listEl.innerHTML = `<p class="muted">Verschlüsselung nicht verfügbar: ${e.message}. Die Seite muss über HTTPS laufen.</p>`;
return;
}
const decrypted = [];
for (const b of books) {
try {
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
const meta = JSON.parse(new TextDecoder().decode(metaBuf));
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0};
decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
} catch (e) {
bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''};
decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
}
}
// If local cache is further ahead than the server, push it and use it for display.
// Server uses max-semantics so this is always safe.
const _localMeta = await _loadCachedBookMeta();
const _localById = Object.fromEntries(_localMeta.map(b => [b.id, b]));
for (const b of decrypted) {
const loc = _localById[b.id];
if (loc && _cmpProgress(loc.position_anchor, loc.scroll_fraction, b.position_anchor, b.scroll_fraction) > 0) {
fetch(`/books/${b.id}/progress/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({scroll_fraction: loc.scroll_fraction, position_anchor: loc.position_anchor || ''}),
}).catch(() => {});
b.scroll_fraction = loc.scroll_fraction;
b.position_anchor = loc.position_anchor;
b.last_read = loc.last_read;
}
}
decrypted.sort((a, b) => {
if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read);
if (a.last_read) return -1;
if (b.last_read) return 1;
return b.uploaded_at.localeCompare(a.uploaded_at);
});
_saveBookMeta(decrypted); // persist for offline
renderBookList(decrypted);
} catch (e) {
// Network error — try offline fallback
const cachedBooks = await _loadCachedBookMeta();
if (cachedBooks.length) {
cachedBooks.sort((a, b) => {
if (a.last_read && b.last_read) return b.last_read.localeCompare(a.last_read);
if (a.last_read) return -1;
if (b.last_read) return 1;
return (b.uploaded_at || '').localeCompare(a.uploaded_at || '');
});
for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0};
const uploadArea = $('book-upload-area');
if (uploadArea) uploadArea.style.display = 'none';
renderBookList(cachedBooks);
const notice = document.createElement('p');
notice.className = 'muted offline-notice';
notice.textContent = 'Offline — gespeicherte Bücher. Fortschritt wird beim nächsten Öffnen synchronisiert.';
listEl.prepend(notice);
} else {
if (listEl) listEl.innerHTML = `<p class="muted">Fehler beim Laden der Bücher: ${e.message}</p>`;
}
}
}
const _brokenBooks = new Set();
function markBookBroken(bookId) {
_brokenBooks.add(bookId);
const item = document.querySelector(`.book-item[data-book-id="${bookId}"]`);
if (!item) return;
if (!item.querySelector('.book-broken-btn')) {
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-danger book-broken-btn';
btn.title = 'Buch konnte nicht geöffnet werden — Datei erneut hochladen';
btn.textContent = '!';
btn.onclick = () => repairBook(bookId);
item.querySelector('.book-item-actions').prepend(btn);
}
}
function _extractIsbnFromOpf(opfDoc) {
const identifiers = opfDoc.querySelectorAll('metadata > identifier, metadata > *|identifier');
for (const el of identifiers) {
const digits = (el.textContent || '').replace(/[^0-9Xx]/g, '');
if (digits.length === 10 || digits.length === 13) return digits;
}
return '';
}
function repairBook(bookId) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.epub,.pdf';
input.onchange = async () => {
const file = input.files[0];
if (!file) return;
const item = document.querySelector(`.book-item[data-book-id="${bookId}"]`);
const btn = item?.querySelector('.book-broken-btn');
if (btn) btn.textContent = '…';
try {
const key = await getOrCreateEncKey();
const buf = await file.arrayBuffer();
const isPdf = /\.pdf$/i.test(file.name);
const type = isPdf ? 'pdf' : 'epub';
let title = file.name.replace(/\.(epub|pdf)$/i, ''), author = '', isbn = '';
try {
if (isPdf) {
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() || '';
} else {
const zip = await JSZip.loadAsync(buf.slice(0));
const containerXml = await zip.file('META-INF/container.xml').async('text');
const opfPath = new DOMParser()
.parseFromString(containerXml, 'application/xml')
.querySelector('rootfile')?.getAttribute('full-path');
if (opfPath) {
const opfDoc = new DOMParser().parseFromString(
await zip.file(opfPath).async('text'), 'application/xml');
title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title;
author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || '';
isbn = _extractIsbnFromOpf(opfDoc);
}
}
} catch (e) { /* keep filename as title */ }
// Preserve folder/shelfTag assigned before this repair — replacing the book's
// data shouldn't silently wipe organization the user already set up.
const prevMeta = bookMetaCache[bookId] || {};
const metaJson = new TextEncoder().encode(JSON.stringify({
title, author, filename: file.name, type,
isbn: isbn || prevMeta.isbn || '',
folder: prevMeta.folder || '',
shelfTag: prevMeta.shelfTag || '',
}));
const [metaEnc, dataEnc] = await Promise.all([
encryptBytes(key, metaJson),
encryptBytes(key, buf),
]);
const res = await fetch(`/books/${bookId}/replace-data/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({
data_ct: dataEnc.ciphertext, data_iv: dataEnc.iv,
meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv,
}),
});
if (!(await res.json()).ok) throw new Error('Serverfehler');
await _evictCachedBook(bookId);
_brokenBooks.delete(bookId);
if (btn) btn.remove();
openBook(bookId);
} catch (e) {
if (btn) btn.textContent = '!';
await customAlert('Reparatur fehlgeschlagen: ' + e.message);
}
};
input.click();
}
let _lastBookListData = [];
const _bookShowReadKey = 'diora_book_show_read';
let _bookShowRead = localStorage.getItem(_bookShowReadKey) === '1';
function _onBookShowReadToggle(checked) {
_bookShowRead = checked;
localStorage.setItem(_bookShowReadKey, checked ? '1' : '0');
renderBookList(_lastBookListData);
}
async function toggleBookRead(bookId, currentlyRead) {
const verb = currentlyRead ? 'als ungelesen markieren' : 'als gelesen markieren';
if (!await customConfirm(`Dieses Buch ${verb}?`)) return;
try {
const res = await fetch(`/books/${bookId}/read/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({is_read: !currentlyRead}),
});
const data = await res.json();
if (!data.ok) throw new Error('Serverfehler');
const book = _lastBookListData.find(b => b.id === bookId);
if (book) book.is_read = data.is_read;
renderBookList(_lastBookListData);
} catch (e) {
await customAlert('Konnte Lesestatus nicht ändern: ' + e.message);
}
}
// Folder assigned to a book lives inside its encrypted meta blob (like title/author), so the
// server never sees plaintext folder names — no schema change needed, and it rides along for
// free in /api/sync/. One level only: folders don't nest.
let _currentBookFolder = null; // null = root view (folder tiles + unfiled books)
function _recentlyReadBooks(books) {
return books
.filter(b => b.last_read)
.sort((a, b) => b.last_read.localeCompare(a.last_read))
.slice(0, 7);
}
function _renderBookItemHtml(b) {
const pct = Math.round((b.scroll_fraction || 0) * 100);
const keyWarning = b.keyOk === false ? '<span title="Falscher Schlüssel – importiere den richtigen Schlüssel, um dieses Buch zu öffnen" style="color:var(--accent,#e63946);margin-left:4px;">⚠ falscher Schlüssel</span>' : '';
const broken = _brokenBooks.has(b.id);
return `<div class="book-item" data-book-id="${b.id}">
<div class="book-item-info">
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' <span class="muted book-read-badge">✓ gelesen</span>' : ''}</strong>
<span class="muted book-author">${escapeHtml(b.author)}</span>
<span class="book-item-meta-row">
${pct > 0 ? `<span class="muted book-progress">${pct} % gelesen${b.pages ? ` (≈ ${Math.max(1, Math.round(b.pages * pct / 100))} von ${b.pages} Seiten)` : ''}</span>` : (b.pages ? `<span class="muted book-progress">ca. ${b.pages} Seiten</span>` : '')}
${b.shelfTag ? `<span class="book-shelf-badge" title="Regal-Kategorie">${escapeHtml(b.shelfTag)}</span>` : ''}
</span>
</div>
<div class="book-item-actions">
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Erst den richtigen Schlüssel importieren"' : ''}>Öffnen</button>
<div class="book-item-menu">
<button class="btn btn-sm book-menu-toggle" title="Weitere Optionen" onclick="toggleBookMenu(this)">⋮</button>
<div class="book-item-menu-list">
${broken ? `<button class="book-menu-item book-menu-item--danger book-broken-btn" onclick="repairBook(${b.id})">🔧 Reparieren (Datei erneut hochladen)</button>` : ''}
${b.has_highlights ? `<button class="book-menu-item" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳ Markierungen &amp; Notizen herunterladen</button>` : ''}
<button class="book-menu-item" onclick="assignBookFolder(${b.id})">📁 Ordner zuweisen</button>
<button class="book-menu-item" onclick="lookupBookMetadata(${b.id})">🏷️ Metadaten abrufen (Regal)</button>
<button class="book-menu-item" onclick="toggleBookRead(${b.id}, ${!!b.is_read})">${b.is_read ? '↺ Als ungelesen markieren' : '✓ Als gelesen markieren'}</button>
<button class="book-menu-item book-menu-item--danger" onclick="deleteBook(${b.id})">🗑 Löschen</button>
</div>
</div>
</div>
</div>`;
}
function toggleBookMenu(toggleBtn) {
// Looked up relative to the clicked button (not by id) because the same book — and
// thus the same rendered menu — can appear twice at once: once in the "Zuletzt
// gelesen" section and again further down in the folder/unfiled list.
const menu = toggleBtn.closest('.book-item-menu')?.querySelector('.book-item-menu-list');
if (!menu) return;
const willOpen = !menu.classList.contains('open');
document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open'));
if (willOpen) menu.classList.add('open');
}
// Close any open book menu on an outside click, or right after an item inside it is
// clicked (the toggle button manages its own open/close state above, so it's excluded).
document.addEventListener('click', (e) => {
if (e.target.closest('.book-menu-toggle')) return;
document.querySelectorAll('.book-item-menu-list.open').forEach(m => m.classList.remove('open'));
});
function _openBookFolder(name) {
_currentBookFolder = name;
renderBookList(_lastBookListData);
}
function renderBookList(books) {
const listEl = $('book-list');
if (!listEl) return;
_lastBookListData = books;
if (!books.length) {
listEl.innerHTML = '';
return;
}
let html = '';
// Always-visible "recently read" section, independent of folder navigation and the
// read/unread filter below — it's a shortcut back into whatever was open last. Same
// vertical list-item look as the rest of the list (not a card shelf).
const recent = _recentlyReadBooks(books);
if (recent.length) {
html += '<div class="book-recent-section">'
+ '<h3 class="book-recent-title">Zuletzt gelesen</h3>'
+ recent.map(_renderBookItemHtml).join('')
+ '</div>';
}
const visible = _bookShowRead ? books : books.filter(b => !b.is_read);
if (!visible.length) {
html += books.length
? '<p class="muted">Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.</p>'
: '';
listEl.innerHTML = html;
return;
}
const grouped = new Map();
for (const b of visible) {
const key = b.folder || '';
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(b);
}
// Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root.
if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) {
_currentBookFolder = null;
}
if (_currentBookFolder === null) {
const folderNames = [...grouped.keys()].filter(k => k !== '').sort((a, b) => a.localeCompare(b, 'de'));
if (folderNames.length) {
html += '<div class="book-folder-grid">'
+ folderNames.map(name => `<button class="book-folder-tile" data-folder="${escapeHtml(name)}">
<span class="book-folder-tile-icon">📁</span>
<span class="book-folder-tile-name">${escapeHtml(name)}</span>
<span class="book-folder-tile-count">${grouped.get(name).length}</span>
</button>`).join('')
+ '</div>';
}
html += (grouped.get('') || []).map(_renderBookItemHtml).join('');
} else {
html += `<div class="book-folder-header">
<button class="btn btn-sm" onclick="_openBookFolder(null)">← Alle Ordner</button>
<strong>${escapeHtml(_currentBookFolder)}</strong>
</div>`;
html += grouped.get(_currentBookFolder).map(_renderBookItemHtml).join('');
}
listEl.innerHTML = html;
listEl.querySelectorAll('.book-folder-tile').forEach(tile => {
tile.addEventListener('click', () => _openBookFolder(tile.dataset.folder));
});
}
// Shared by assignBookFolder/lookupBookMetadata: re-encrypts the meta blob with the given
// field overrides applied on top of whatever is already cached, and pushes it to the server.
async function _updateBookMeta(bookId, overrides) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return false;
const cached = bookMetaCache[bookId] || {};
const merged = {
title: cached.title || book.title,
author: cached.author || book.author,
filename: cached.filename || '',
type: cached.type || book.type || 'epub',
isbn: cached.isbn || book.isbn || '',
folder: cached.folder || book.folder || '',
shelfTag: cached.shelfTag || book.shelfTag || '',
pages: cached.pages || book.pages || 0,
...overrides,
};
const key = await getOrCreateEncKey();
const metaJson = new TextEncoder().encode(JSON.stringify(merged));
const metaEnc = await encryptBytes(key, metaJson);
const res = await fetch(`/books/${bookId}/meta/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv}),
});
const data = await res.json();
if (!data.ok) throw new Error('Serverfehler');
Object.assign(book, overrides);
bookMetaCache[bookId] = merged;
_saveBookMeta(_lastBookListData);
return true;
}
async function assignBookFolder(bookId) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return;
const folders = [...new Set(_lastBookListData.map(b => b.folder).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de'));
const hint = folders.length ? ` Vorhandene Ordner: ${folders.join(', ')}.` : '';
const result = await customPrompt(`Ordner für „${book.title}“ (leer = kein Ordner).${hint}`, book.folder || '');
if (result === null) return;
const folder = result.trim();
if (folder === (book.folder || '')) return;
try {
await _updateBookMeta(bookId, {folder});
if (folder) _currentBookFolder = folder;
renderBookList(_lastBookListData);
} catch (e) {
await customAlert('Konnte Ordner nicht ändern: ' + e.message);
}
}
// Fallback for books uploaded before ISBN extraction existed (or repaired/re-encrypted
// without it): pulls the already-accessible encrypted book bytes, decrypts and unzips just
// far enough to read the OPF identifiers — same source openBook() itself parses — without
// doing a full reader render. EPUB only; PDFs never carried a reliable ISBN via pdf.js.
async function _extractIsbnFromBookFile(bookId) {
try {
const key = await getOrCreateEncKey();
let data_ct, data_iv;
const cached = await _getCachedBook(bookId);
if (cached) {
({data_ct, data_iv} = cached);
} else {
const res = await fetch(`/books/${bookId}/data/`);
({data_ct, data_iv} = await res.json());
}
const plain = await decryptBytes(key, data_iv, data_ct);
const zip = await JSZip.loadAsync(plain);
const containerXml = await zip.file('META-INF/container.xml').async('text');
const opfPath = new DOMParser()
.parseFromString(containerXml, 'application/xml')
.querySelector('rootfile')?.getAttribute('full-path');
if (!opfPath) return '';
const opfDoc = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml');
return _extractIsbnFromOpf(opfDoc);
} catch (e) {
return '';
}
}
async function _fetchShelfLookup(isbn, title, author) {
const params = new URLSearchParams();
if (isbn) params.set('isbn', isbn);
if (title) params.set('title', title);
if (author) params.set('author', author);
const res = await fetch(`/books/metadata-lookup/?${params.toString()}`);
return res.json();
}
// Manually triggered (never automatic) — looks up which library shelf/DDC category this
// book falls under via the server-side DNB→Open Library proxy, purely as an organizational
// hint (shown as a badge). See books/views.py:lookup_book_metadata for why this one call is
// allowed to briefly touch the server with a plaintext ISBN/title/author.
//
// Fallback chain when there's no ISBN (older upload, or a book that never had one — e.g.
// every PDF, since pdf.js doesn't expose one): (1) extract ISBN from the file itself, for
// EPUBs, (2) server-side title/author text search (less precise — could match the wrong
// edition/translation), (3) if that also comes up empty, ask the user to type in an ISBN
// by hand (from the book's cover/an online store) and retry once.
async function lookupBookMetadata(bookId) {
const book = _lastBookListData.find(b => b.id === bookId);
if (!book) return;
const cached = bookMetaCache[bookId] || {};
let isbn = cached.isbn || book.isbn || '';
const isPdf = (cached.type || book.type) === 'pdf';
const title = cached.title || book.title || '';
const author = cached.author || book.author || '';
if (!isbn && !isPdf) {
// Older upload without a stored ISBN — extract it from the book file itself (may take
// a moment for large books, since it downloads/decrypts the full file if not cached).
isbn = await _extractIsbnFromBookFile(bookId);
}
try {
let data = await _fetchShelfLookup(isbn, title, author);
if (!data.label) {
const manual = await customPrompt(
'Keine Regal-Kategorie per ISBN/Titel/Autor gefunden. ISBN manuell eingeben (z. B. vom Buchrücken oder Online-Shop), oder leer lassen zum Abbrechen:', ''
);
const manualIsbn = (manual || '').replace(/[^0-9Xx]/g, '');
if (manualIsbn) {
isbn = manualIsbn;
data = await _fetchShelfLookup(isbn, title, author);
}
}
// Persist whatever we ended up with either way, so a repeat lookup never needs to
// re-extract/re-search from scratch.
const overrides = {};
if (isbn) overrides.isbn = isbn;
if (data.label) overrides.shelfTag = data.label;
if (Object.keys(overrides).length) {
await _updateBookMeta(bookId, overrides);
renderBookList(_lastBookListData);
}
if (!data.label) {
await customAlert('Keine Regal-Kategorie gefunden (weder DNB noch Open Library, per ISBN oder Titel/Autor).');
}
} catch (e) {
await customAlert('Metadaten-Abruf fehlgeschlagen: ' + e.message);
}
}
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 = 'Bitte gib dein Passwort ein.'; return; }
if (statusEl) statusEl.textContent = 'Schlüssel wird abgeleitet …';
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 = '✓ Entsperrt';
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 = 'Fehler: ' + err.message;
}
}
async function uploadEbook(file) {
const statusEl = $('book-upload-status');
if (file.size > DIORA_CONFIG.ebookMaxBytes) {
if (statusEl) statusEl.textContent = `Datei zu groß (höchstens ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
return false;
}
return _importEbookBuffer(await file.arrayBuffer(), file.name, statusEl);
}
// Shared tail of every import path (local file, cloud): parse metadata, encrypt
// with the user's key and POST ciphertext. Plaintext never leaves the browser.
async function _importEbookBuffer(buf, filename, statusEl) {
const isPdf = /\.pdf$/i.test(filename);
const isEpub = /\.epub$/i.test(filename);
if (!isPdf && !isEpub) {
if (statusEl) statusEl.textContent = 'Nur .epub- und .pdf-Dateien werden unterstützt.';
return false;
}
if (buf.byteLength > DIORA_CONFIG.ebookMaxBytes) {
if (statusEl) statusEl.textContent = `Datei zu groß (höchstens ${DIORA_CONFIG.ebookMaxBytes / 1024 / 1024} MB).`;
return false;
}
if (statusEl) statusEl.textContent = 'Verschlüssele …';
try {
let title = filename.replace(/\.(epub|pdf)$/i, '');
let author = '';
let isbn = '';
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() || '';
isbn = _extractIsbnFromOpf(opfDoc);
}
} catch (e) { /* use filename as title */ }
}
const key = await getOrCreateEncKey();
const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename, type, isbn, folder: '', shelfTag: ''}));
const [metaEnc, dataEnc] = await Promise.all([
encryptBytes(key, metaJson),
encryptBytes(key, buf),
]);
if (statusEl) statusEl.textContent = 'Lade hoch …';
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}“ hochgeladen`;
loadBookList();
return true;
}
if (statusEl) statusEl.textContent = 'Fehler: ' + (data.error ? _germanError(data.error) : 'Hochladen fehlgeschlagen');
return false;
} catch (e) {
if (statusEl) statusEl.textContent = 'Hochladen fehlgeschlagen: ' + e.message;
return false;
}
}
// ---------------------------------------------------------------------------
// Cloud import — browse a user-configured WebDAV server and pull a book in.
//
// The download is proxied by the server because the remote host is a different
// origin and sends no CORS headers. It still lands here as raw bytes and goes
// through _importEbookBuffer like any local file, so what gets stored is
// ciphertext the server cannot read.
// ---------------------------------------------------------------------------
let _cloudSources = [];
let _cloudSourceId = null;
let _cloudPath = '';
async function toggleCloudImport() {
const panel = $('cloud-browser');
if (!panel) return;
if (panel.style.display !== 'none') { closeCloudImport(); return; }
try {
const res = await fetch('/books/cloud/sources/');
_cloudSources = (await res.json()).sources || [];
} catch (e) {
await customAlert('Cloud-Verbindungen konnten nicht geladen werden: ' + e.message);
return;
}
if (!_cloudSources.length) {
await customAlert(
'Noch keine Cloud-Verbindung eingerichtet. Unter Einstellungen → '
+ '„Cloud-Verbindungen für Bücher“ kannst du eine hinzufügen.'
);
return;
}
const select = $('cloud-source-select');
if (select) {
select.innerHTML = '';
for (const source of _cloudSources) {
const option = document.createElement('option');
option.value = source.id;
option.textContent = source.label;
select.appendChild(option);
}
select.style.display = _cloudSources.length > 1 ? '' : 'none';
}
panel.style.display = '';
_cloudSourceId = _cloudSources[0].id;
_cloudBrowse('');
}
function closeCloudImport() {
const panel = $('cloud-browser');
if (panel) panel.style.display = 'none';
}
function _onCloudSourceChange(value) {
_cloudSourceId = parseInt(value, 10);
_cloudBrowse('');
}
function _cloudParentPath(path) {
const parts = path.split('/').filter(Boolean);
parts.pop();
return parts.join('/');
}
function _formatBytes(n) {
if (!n) return '';
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return Math.round(n / 1024) + ' KB';
return (n / 1024 / 1024).toFixed(1) + ' MB';
}
function _cloudEntryRow(label, onClick, sizeText) {
const li = document.createElement('li');
li.className = 'cloud-entry';
const button = document.createElement('button');
button.type = 'button';
button.className = 'cloud-entry-name';
button.textContent = label;
button.addEventListener('click', onClick);
li.appendChild(button);
if (sizeText) {
const size = document.createElement('span');
size.className = 'cloud-entry-size';
size.textContent = sizeText;
li.appendChild(size);
}
return li;
}
async function _cloudBrowse(path) {
const listEl = $('cloud-entries');
const pathEl = $('cloud-path');
const statusEl = $('cloud-status');
if (!listEl) return;
if (statusEl) statusEl.textContent = 'Lade…';
listEl.innerHTML = '';
let data;
try {
const res = await fetch(`/books/cloud/${_cloudSourceId}/browse/?path=${encodeURIComponent(path)}`);
data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
} catch (e) {
if (statusEl) statusEl.textContent = 'Fehler: ' + e.message;
return;
}
_cloudPath = data.path || '';
if (pathEl) pathEl.textContent = '/' + _cloudPath;
if (statusEl) statusEl.textContent = '';
if (_cloudPath) {
listEl.appendChild(_cloudEntryRow('⬑ ..', () => _cloudBrowse(_cloudParentPath(_cloudPath))));
}
const entries = (data.entries || []).filter(e => e.is_dir || e.is_book);
if (!entries.length && statusEl) {
statusEl.textContent = 'Keine Bücher oder Ordner in diesem Verzeichnis.';
}
for (const entry of entries) {
listEl.appendChild(entry.is_dir
? _cloudEntryRow('📁 ' + entry.name, () => _cloudBrowse(entry.path))
: _cloudEntryRow('📖 ' + entry.name, () => _cloudImport(entry), _formatBytes(entry.size)));
}
}
async function _cloudImport(entry) {
const statusEl = $('cloud-status');
if (statusEl) statusEl.textContent = `Lade „${entry.name}“…`;
try {
const res = await fetch(`/books/cloud/${_cloudSourceId}/fetch/?path=${encodeURIComponent(entry.path)}`);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
const buf = await res.arrayBuffer();
if (await _importEbookBuffer(buf, entry.name, statusEl) && statusEl) {
statusEl.textContent = `✓ „${entry.name}“ importiert`;
}
} catch (e) {
if (statusEl) statusEl.textContent = 'Import fehlgeschlagen: ' + 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, pivotPage) {
const myGen = ++_pdfRenderGen;
const pdf = currentPdfDoc || await pdfjsLib.getDocument({data: new Uint8Array(arrayBuffer.slice(0))}).promise;
if (_pdfRenderGen !== myGen) return null;
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 pdfVp = document.createElement('div');
pdfVp.id = 'pdf-viewport';
contentEl.appendChild(pdfVp);
const containerWidth = readerSettings.pdfSpread
? contentEl.clientWidth - 32
: Math.min(contentEl.clientWidth - 32, 900);
const pageWidth = readerSettings.pdfSpread ? (containerWidth - 8) / 2 : containerWidth;
const dpr = window.devicePixelRatio || 1;
// Pass 1: build DOM structure and size all canvases (no pixel rendering yet)
const pageEntries = [];
let spreadContainer = null;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; }
const page = await pdf.getPage(pageNum);
const naturalVp = page.getViewport({scale: 1});
const scale = scaleOverride != null ? scaleOverride
: Math.max(0.5, pageWidth / 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}`;
const inner = document.createElement('div');
inner.className = 'pdf-page-inner';
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page';
canvas.width = Math.round(viewport.width * dpr);
canvas.height = Math.round(viewport.height * dpr);
canvas.style.width = viewport.width + 'px';
canvas.style.height = viewport.height + 'px';
inner.appendChild(canvas);
wrapper.appendChild(inner);
if (!readerSettings.pdfSpread) {
pdfVp.appendChild(wrapper);
} else if (pageNum === 1) {
wrapper.classList.add('pdf-spread-cover');
pdfVp.appendChild(wrapper);
spreadContainer = null;
} else {
if (pageNum % 2 === 0) {
spreadContainer = document.createElement('div');
spreadContainer.className = 'pdf-spread-wrapper';
pdfVp.appendChild(spreadContainer);
}
if (spreadContainer) spreadContainer.appendChild(wrapper);
else pdfVp.appendChild(wrapper);
}
pageEntries.push({page, canvas, viewport});
}
pdfTotalPages = pdf.numPages;
// Scroll to pivot page immediately — DOM and canvas sizes are set
const pivot = Math.max(1, Math.min(pdf.numPages, pivotPage || 1));
const pivotEl = document.getElementById(`pdf-page-${pivot}`);
if (pivotEl && pivot > 1) pivotEl.scrollIntoView({block: 'start'});
// Pass 2: render pixels from pivot outward (pivot→end, then pivot-1→start)
const order = [];
for (let i = pivot - 1; i < pdf.numPages; i++) order.push(i);
for (let i = pivot - 2; i >= 0; i--) order.push(i);
for (const idx of order) {
if (_pdfRenderGen !== myGen) { contentEl.innerHTML = ''; return null; }
const {page, canvas, viewport} = pageEntries[idx];
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
await page.render({canvasContext: ctx, viewport}).promise;
}
return {title: pdfTitle, author: pdfAuthor, toc, numPages: pdf.numPages};
}
// ---------------------------------------------------------------------------
// Read-aloud (TTS) — server-side Piper synthesis, one sentence per request.
// EPUB only (no block model for PDFs). Highlights the sentence currently
// playing and scrolls it into view; playback advances sentence-by-sentence,
// then block-by-block, until stopped or the book ends.
// ---------------------------------------------------------------------------
const TTS_MAX_CHARS = 480; // stay under the server's 500-char cap with margin
let ttsActive = false;
let ttsPaused = false;
let ttsRunToken = 0;
let ttsAudio = null;
let ttsCurrentMark = null;
let ttsBarEl = null;
let ttsLang = localStorage.getItem('diora_tts_lang') || 'de';
function setTtsLang(lang) {
ttsLang = lang;
localStorage.setItem('diora_tts_lang', lang);
}
function _ttsSplitSentences(text) {
const raw = text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean);
const pieces = raw.length ? raw : [text];
const out = [];
for (const piece of pieces) {
let rest = piece;
while (rest.length > TTS_MAX_CHARS) {
let cut = rest.lastIndexOf(' ', TTS_MAX_CHARS);
if (cut <= 0) cut = TTS_MAX_CHARS;
out.push(rest.slice(0, cut).trim());
rest = rest.slice(cut).trim();
}
if (rest) out.push(rest);
}
return out;
}
async function _ttsFetchAudio(sentence) {
const resp = await fetch('/tts/synthesize/', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({text: sentence, lang: ttsLang}),
});
if (!resp.ok) throw new Error('tts request failed');
return await resp.blob();
}
function _ttsClearHighlight() {
if (ttsCurrentMark && ttsCurrentMark.parentNode) {
const parent = ttsCurrentMark.parentNode;
while (ttsCurrentMark.firstChild) parent.insertBefore(ttsCurrentMark.firstChild, ttsCurrentMark);
parent.removeChild(ttsCurrentMark);
parent.normalize();
}
ttsCurrentMark = null;
}
// Finds `sentence` as a substring of block.textContent starting at fromOffset
// (so repeated sentence text earlier in the block isn't matched again), turns
// it into a DOM Range via the same char-offset addressing highlights use, and
// wraps it in a <mark>. Returns the offset to resume searching from.
function _ttsHighlightSentence(block, sentence, fromOffset) {
_ttsClearHighlight();
const full = block.textContent;
const idx = full.indexOf(sentence, fromOffset);
if (idx === -1) return fromOffset;
const start = _nodeAtCharOffset(block, idx);
const end = _nodeAtCharOffset(block, idx + sentence.length);
if (!start || !end) return idx + sentence.length;
try {
const range = document.createRange();
range.setStart(start.node, start.offset);
range.setEnd(end.node, end.offset);
const mark = document.createElement('mark');
mark.className = 'tts-current';
range.surroundContents(mark);
ttsCurrentMark = mark;
_ttsScrollIntoView(mark);
} catch (e) {}
return idx + sentence.length;
}
function _ttsScrollIntoView(el) {
const contentEl = $('reader-content');
if (!contentEl) return;
const top = el.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
if (top < 40 || top > contentEl.clientHeight - 80) {
_suppressScrollJumpDetect();
contentEl.scrollBy({top: top - contentEl.clientHeight * 0.3, behavior: 'smooth'});
}
}
function _ttsPlayBlob(blob) {
return new Promise((resolve) => {
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
ttsAudio = audio;
const done = () => { URL.revokeObjectURL(url); resolve(); };
audio.addEventListener('ended', done);
audio.addEventListener('error', done);
if (!ttsPaused) audio.play().catch(done);
});
}
// Walks blocks/sentences from startBlockIndex onward, fetching one sentence
// ahead while the current one plays so there's no gap between them.
async function _ttsPlayLoop(blocks, startBlockIndex, runToken) {
function* sentenceStream() {
for (let bi = startBlockIndex; bi < blocks.length; bi++) {
const block = blocks[bi];
const text = (block.textContent || '').trim();
if (!text) continue;
for (const sentence of _ttsSplitSentences(text)) yield {block, sentence};
}
}
const iter = sentenceStream();
let cur = iter.next();
if (cur.done) { stopReadAloud(); return; }
let curFetch = _ttsFetchAudio(cur.value.sentence);
let lastBlock = null;
let blockSearchOffset = 0;
while (!cur.done) {
if (runToken !== ttsRunToken) return;
const {block, sentence} = cur.value;
const next = iter.next();
const nextFetch = next.done ? null : _ttsFetchAudio(next.value.sentence).catch(() => null);
let audioBlob;
try {
audioBlob = await curFetch;
} catch (e) {
stopReadAloud();
return;
}
if (runToken !== ttsRunToken) return;
if (block !== lastBlock) { lastBlock = block; blockSearchOffset = 0; }
blockSearchOffset = _ttsHighlightSentence(block, sentence, blockSearchOffset);
await _ttsPlayBlob(audioBlob);
if (runToken !== ttsRunToken) return;
cur = next;
curFetch = nextFetch;
}
if (runToken === ttsRunToken) stopReadAloud();
}
function _ttsTogglePause() {
ttsPaused = !ttsPaused;
if (ttsAudio) {
if (ttsPaused) ttsAudio.pause();
else ttsAudio.play().catch(() => {});
}
_ttsUpdateBar();
}
function _ttsUpdateBar() {
if (!ttsBarEl) return;
const btn = ttsBarEl.querySelector('.tts-pause-btn');
if (btn) btn.textContent = ttsPaused ? '▶' : '⏸';
}
function _ttsShowBar() {
_ttsRemoveBar();
const bar = document.createElement('div');
bar.className = 'tts-bar';
bar.innerHTML = `
<button type="button" class="tts-pause-btn" title="Pause/Weiter">⏸</button>
<button type="button" class="tts-stop-btn" title="Vorlesen beenden">■</button>
`;
document.body.appendChild(bar);
ttsBarEl = bar;
bar.querySelector('.tts-pause-btn').addEventListener('click', _ttsTogglePause);
bar.querySelector('.tts-stop-btn').addEventListener('click', stopReadAloud);
}
function _ttsRemoveBar() {
if (ttsBarEl) { ttsBarEl.remove(); ttsBarEl = null; }
}
function toggleReadAloud() {
if (ttsActive) stopReadAloud();
else startReadAloud();
}
function startReadAloud() {
if (ttsActive) return;
if (currentPdfDoc) {
const toast = document.createElement('div');
toast.className = 'reader-toast';
toast.textContent = 'Vorlesen ist aktuell nur für EPUB-Bücher verfügbar.';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2200);
return;
}
const contentEl = $('reader-content');
if (!contentEl) return;
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
if (!blocks.length) return;
const [anchorBlock] = _anchorParts(getPositionAnchor(contentEl));
const startIndex = (anchorBlock >= 0 && anchorBlock < blocks.length) ? anchorBlock : 0;
ttsActive = true;
ttsPaused = false;
ttsRunToken++;
const runToken = ttsRunToken;
const btn = $('reader-tts-btn');
if (btn) { btn.classList.add('active'); btn.title = 'Vorlesen beenden'; }
_ttsShowBar();
_ttsPlayLoop(blocks, startIndex, runToken);
}
function stopReadAloud() {
ttsRunToken++;
ttsActive = false;
ttsPaused = false;
if (ttsAudio) {
try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {}
ttsAudio = null;
}
_ttsClearHighlight();
_ttsRemoveBar();
const btn = $('reader-tts-btn');
if (btn) { btn.classList.remove('active'); btn.title = 'Vorlesen'; }
}
// ---------------------------------------------------------------------------
// 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;
let _repairProgressAnchor = false; // stored anchor didn't fit this book
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) {
_repairProgressAnchor = 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();
try {
let fraction = 0, anchor = '';
try {
const progressRes = await fetch('/books/');
const allBooks = await progressRes.json();
const bookData = allBooks.find(b => b.id === bookId);
fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
anchor = bookData ? (bookData.position_anchor || '') : '';
} catch {
// offline — use cached meta
const cached = await _loadCachedBookMeta();
const bookData = cached.find(b => b.id === bookId);
fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
anchor = bookData ? (bookData.position_anchor || '') : '';
}
_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));
}
// An anchor past the end of this book can only come from a broken
// render. The server keeps the furthest block index, so it would block
// every real position from now on: overwrite it with the next save.
const [storedBlock] = _anchorParts(anchor);
if (storedBlock >= contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR).length) {
_repairProgressAnchor = true;
}
}
} catch (e) {}
// 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);
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) {}
}
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;
}
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));
_updateCachedBookProgress(currentBookId, fraction, anchor, force || (_repairProgressAnchor && !!anchor));
const repair = _repairProgressAnchor && !!anchor;
if (repair) _repairProgressAnchor = false;
const body = JSON.stringify({scroll_fraction: fraction, position_anchor: anchor, ...((force || repair) && {force: true})});
_lastProgressBeacon = {url: `/books/${currentBookId}/progress/`, body};
try {
await fetch(`/books/${currentBookId}/progress/`, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body,
});
if (force) {
const toast = document.createElement('div');
toast.className = 'reader-toast';
toast.textContent = 'Position gesetzt';
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 1800);
}
} catch (e) {}
}
function closeReader() {
exitReaderImmersiveMode();
stopReadAloud();
// 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;
}
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', '<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);
}
// ---------------------------------------------------------------------------
// Reader Settings
// ---------------------------------------------------------------------------
// Typography defaults. Phones keep the long-standing ones; a wide screen
// (desktop browser, diora desktop) starts bigger and wider.
const _READER_TYPE_DEFAULTS_NARROW = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, fontWeight: null };
const _READER_TYPE_DEFAULTS_WIDE = { fontSize: 20, lineHeight: 1.6, maxWidth: 90, fontWeight: 375 };
const _READER_WIDTH_MAX = 140; // ch, the width slider's upper end ("Full" stays separate)
function _readerTypeDefaults() {
return window.matchMedia('(min-width: 900px)').matches
? _READER_TYPE_DEFAULTS_WIDE : _READER_TYPE_DEFAULTS_NARROW;
}
function loadReaderSettings(bookId) {
// Reset to defaults, then apply per-book overrides
Object.assign(readerSettings, { theme: 'dark', fontFamily: 'serif', noBold: false,
pdfZoom: 100, pdfInverted: false, pdfPaginated: false, pdfSpread: false },
_readerTypeDefaults());
try {
const saved = JSON.parse(localStorage.getItem(`diora_reader_settings_${bookId}`) || '{}');
// Settings saved before v2 hold every value, changed or not. A value equal
// to the old default was never chosen, so it gives way to today's default.
if (!saved.v) {
for (const [key, old] of Object.entries(_READER_TYPE_DEFAULTS_NARROW)) {
if (saved[key] === old) delete saved[key];
}
}
delete saved.v;
Object.assign(readerSettings, saved);
if (saved.pdfPaginated === undefined) {
readerSettings.pdfPaginated = window.innerWidth < 768;
}
} catch (e) {}
}
function saveReaderSettings() {
if (currentBookId) {
localStorage.setItem(`diora_reader_settings_${currentBookId}`, JSON.stringify({ ...readerSettings, v: 2 }));
}
}
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');
const fontMap = {
serif: "'Source Serif 4', Georgia, 'Times New Roman', serif",
sans: 'system-ui, -apple-system, sans-serif',
humanist: 'Verdana, Geneva, sans-serif',
mono: "'Courier New', monospace",
};
contentEl.style.fontFamily = fontMap[readerSettings.fontFamily] || fontMap.serif;
contentEl.classList.toggle('reader-no-bold', !!readerSettings.noBold);
// Weight: null keeps the stylesheet's. --reader-weight lets old WebViews
// without variable-font support (which drop weights like 425) pick a
// static cut instead.
contentEl.style.fontWeight = '';
if (readerSettings.fontWeight) {
contentEl.style.fontWeight = String(readerSettings.fontWeight);
contentEl.style.setProperty('--reader-weight', String(readerSettings.fontWeight));
} else {
contentEl.style.removeProperty('--reader-weight');
}
if (_currentPositionAnchor && currentBookId) {
_suppressScrollJumpDetect();
requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor));
}
}
// 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');
// Annotations (marker mode + margin panel) are EPUB-only for now — PDFs have no text layer yet
const marginBtn = $('reader-margin-btn');
if (marginBtn) marginBtn.style.display = isPdf ? 'none' : '';
if (isPdf && readerAnnotationsMarginOpen) toggleAnnotationsMargin();
document.querySelectorAll('.marker-mode-btn').forEach(b => { b.style.display = isPdf ? 'none' : ''; });
if (isPdf && markerModeActive) toggleMarkerMode();
}
// Font weight slider: the variable Source Serif 4 takes any weight, the
// system fonts only their fixed cuts (multiples of 100).
function _readerWeightStep() {
return readerSettings.fontFamily === 'serif' ? 25 : 100;
}
function _readerWeight() {
const step = _readerWeightStep();
const w = readerSettings.fontWeight
|| parseInt(getComputedStyle($('reader-content')).fontWeight, 10) || 400;
return Math.round(w / step) * step;
}
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 = `
<label>Schrift <button class="btn btn-sm" id="rs-font-minus">−</button> <input type="range" id="rs-font" min="12" max="24" step="1" value="${readerSettings.fontSize}"> <button class="btn btn-sm" id="rs-font-plus">+</button> <span id="rs-font-val">${readerSettings.fontSize}px</span></label>
<label>Zeilen <button class="btn btn-sm" id="rs-line-minus">−</button> <input type="range" id="rs-line" min="12" max="30" step="1" value="${Math.round(readerSettings.lineHeight * 10)}"> <button class="btn btn-sm" id="rs-line-plus">+</button> <span id="rs-line-val">${readerSettings.lineHeight}</span></label>
<label>Breite <button class="btn btn-sm" id="rs-width-minus">−</button> <input type="range" id="rs-width" min="40" max="${_READER_WIDTH_MAX}" step="5" value="${Math.min(readerSettings.maxWidth, _READER_WIDTH_MAX)}"> <button class="btn btn-sm" id="rs-width-plus">+</button> <span id="rs-width-val">${readerSettings.maxWidth}ch</span></label>
<button class="btn btn-sm" id="rs-width-full">Volle Breite</button>
<label>Stärke <button class="btn btn-sm" id="rs-weight-minus">−</button> <input type="range" id="rs-weight" min="200" max="800" step="${_readerWeightStep()}" value="${_readerWeight()}"> <button class="btn btn-sm" id="rs-weight-plus">+</button> <span id="rs-weight-val">${_readerWeight()}</span></label>
<button class="btn btn-sm ${readerSettings.fontFamily === 'serif' ? 'active' : ''}" data-rs-font="serif">Serif</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'sans' ? 'active' : ''}" data-rs-font="sans">Sans</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'humanist' ? 'active' : ''}" data-rs-font="humanist">Verdana</button>
<button class="btn btn-sm ${readerSettings.fontFamily === 'mono' ? 'active' : ''}" data-rs-font="mono">Mono</button>
<button class="btn btn-sm ${readerSettings.noBold ? 'active' : ''}" id="rs-no-bold">Kein Fett</button>
<button class="btn btn-sm ${readerSettings.theme === 'dark' ? 'active' : ''}" data-rs-theme="dark">Dunkel</button>
<button class="btn btn-sm ${readerSettings.theme === 'sepia' ? 'active' : ''}" data-rs-theme="sepia">Sepia</button>
<button class="btn btn-sm ${readerSettings.theme === 'bright' ? 'active' : ''}" data-rs-theme="bright">Hell</button>
`;
} else {
panel.innerHTML = `
<label>Zoom <button class="btn btn-sm" id="rs-zoom-minus">−</button> <input type="range" id="rs-zoom" min="50" max="200" step="10" value="${readerSettings.pdfZoom}"> <button class="btn btn-sm" id="rs-zoom-plus">+</button> <span id="rs-zoom-val">${readerSettings.pdfZoom}%</span></label>
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" id="rs-invert">Invertieren</button>
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread">Doppelseite</button>
`;
}
overlay.insertBefore(panel, $('reader-body-row'));
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();
});
panel.querySelector('#rs-font-minus').addEventListener('click', () => {
fontRange.value = Math.max(+fontRange.min, parseInt(fontRange.value, 10) - 1);
fontRange.dispatchEvent(new Event('input'));
});
panel.querySelector('#rs-font-plus').addEventListener('click', () => {
fontRange.value = Math.min(+fontRange.max, parseInt(fontRange.value, 10) + 1);
fontRange.dispatchEvent(new Event('input'));
});
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();
});
panel.querySelector('#rs-line-minus').addEventListener('click', () => {
lineRange.value = Math.max(+lineRange.min, parseInt(lineRange.value, 10) - 1);
lineRange.dispatchEvent(new Event('input'));
});
panel.querySelector('#rs-line-plus').addEventListener('click', () => {
lineRange.value = Math.min(+lineRange.max, parseInt(lineRange.value, 10) + 1);
lineRange.dispatchEvent(new Event('input'));
});
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-minus').addEventListener('click', () => {
widthRange.value = Math.max(+widthRange.min, parseInt(widthRange.value, 10) - 5);
widthRange.dispatchEvent(new Event('input'));
});
panel.querySelector('#rs-width-plus').addEventListener('click', () => {
widthRange.value = Math.min(+widthRange.max, parseInt(widthRange.value, 10) + 5);
widthRange.dispatchEvent(new Event('input'));
});
panel.querySelector('#rs-width-full').addEventListener('click', () => {
readerSettings.maxWidth = 999;
widthRange.value = 90;
widthVal.textContent = 'full';
applyReaderSettings(false);
saveReaderSettings();
});
const weightRange = panel.querySelector('#rs-weight');
const weightVal = panel.querySelector('#rs-weight-val');
weightRange.addEventListener('input', () => {
readerSettings.fontWeight = parseInt(weightRange.value, 10);
weightVal.textContent = readerSettings.fontWeight;
applyReaderSettings(false);
saveReaderSettings();
});
panel.querySelector('#rs-weight-minus').addEventListener('click', () => {
weightRange.value = Math.max(+weightRange.min, parseInt(weightRange.value, 10) - +weightRange.step);
weightRange.dispatchEvent(new Event('input'));
});
panel.querySelector('#rs-weight-plus').addEventListener('click', () => {
weightRange.value = Math.min(+weightRange.max, parseInt(weightRange.value, 10) + +weightRange.step);
weightRange.dispatchEvent(new Event('input'));
});
panel.querySelectorAll('[data-rs-font]').forEach(btn => {
btn.addEventListener('click', () => {
readerSettings.fontFamily = btn.dataset.rsFont;
panel.querySelectorAll('[data-rs-font]').forEach(b => b.classList.toggle('active', b === btn));
// The system fonts only have cuts at multiples of 100.
weightRange.step = _readerWeightStep();
if (readerSettings.fontWeight) {
readerSettings.fontWeight = Math.round(readerSettings.fontWeight / weightRange.step) * weightRange.step;
}
applyReaderSettings(false);
saveReaderSettings();
weightRange.value = _readerWeight();
weightVal.textContent = _readerWeight();
});
});
panel.querySelector('#rs-no-bold').addEventListener('click', function () {
readerSettings.noBold = !readerSettings.noBold;
this.classList.toggle('active', readerSettings.noBold);
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');
zoomRange.addEventListener('input', () => applyPdfZoom(parseInt(zoomRange.value, 10)));
panel.querySelector('#rs-zoom-minus').addEventListener('click', () =>
applyPdfZoom(Math.max(50, readerSettings.pdfZoom - 10)));
panel.querySelector('#rs-zoom-plus').addEventListener('click', () =>
applyPdfZoom(Math.min(200, readerSettings.pdfZoom + 10)));
panel.querySelector('#rs-invert').addEventListener('click', function () {
readerSettings.pdfInverted = !readerSettings.pdfInverted;
this.classList.toggle('active', readerSettings.pdfInverted);
applyReaderSettings(true);
saveReaderSettings();
});
panel.querySelector('#rs-spread').addEventListener('click', function () {
readerSettings.pdfSpread = !readerSettings.pdfSpread;
this.classList.toggle('active', readerSettings.pdfSpread);
saveReaderSettings();
reRenderPdf();
});
}
}
async function applyPdfZoom(newZoom) {
readerSettings.pdfZoom = newZoom;
const zoomRange = document.getElementById('rs-zoom');
const zoomVal = document.getElementById('rs-zoom-val');
if (zoomRange) zoomRange.value = newZoom;
if (zoomVal) zoomVal.textContent = newZoom + '%';
saveReaderSettings();
if (readerSettings.pdfPaginated) {
pdfSmartZoomPage(pdfCurrentPage);
} else {
await reRenderPdf();
}
}
function _currentScrollPage(contentEl) {
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
const viewTop = contentEl.scrollTop;
for (const w of wrappers) {
if (w.offsetTop + w.offsetHeight > viewTop) {
const n = parseInt(w.id.replace('pdf-page-', ''), 10);
if (n) return n;
}
}
return pdfCurrentPage || 1;
}
async function reRenderPdf() {
if (!currentPdfBuffer) return;
const contentEl = $('reader-content');
if (!contentEl) return;
const pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _currentScrollPage(contentEl);
await renderPdf(currentPdfBuffer, contentEl, undefined, pivot);
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 pdfVp = document.getElementById('pdf-viewport');
if (pdfVp) pdfVp.style.zoom = 1;
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);
reRenderPdf();
}
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 _pdfTouchStart(e) {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
_pinchStartDist = Math.hypot(dx, dy);
_pinchStartZoom = readerSettings.pdfZoom;
_isPinching = true;
const ce = $('reader-content');
if (ce) ce.classList.add('pinch-active');
} else {
_touchStartX = e.touches[0].clientX;
_isPinching = false;
}
}
function _pdfTouchMove(e) {
if (e.touches.length !== 2 || !_isPinching) return;
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.hypot(dx, dy);
if (_pinchStartDist === 0) return;
const liveZoom = Math.max(50, Math.min(200, _pinchStartZoom * (dist / _pinchStartDist)));
const vp = document.getElementById('pdf-viewport');
if (vp) vp.style.zoom = liveZoom / 100;
}
function _pdfTouchEnd(e) {
if (_isPinching) {
_isPinching = false;
const ce = $('reader-content');
if (ce) ce.classList.remove('pinch-active');
const vp = document.getElementById('pdf-viewport');
const liveZoom = vp ? parseFloat(vp.style.zoom) * 100 : readerSettings.pdfZoom;
const snapped = Math.max(50, Math.min(200, Math.round(liveZoom / 10) * 10));
applyPdfZoom(snapped);
return;
}
if (!readerSettings.pdfPaginated) return;
const delta = e.changedTouches[0].clientX - _touchStartX;
if (delta > 50) pdfGoToPage(pdfCurrentPage - 1);
else if (delta < -50) 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
) * (readerSettings.pdfZoom / 100);
// 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) {
_saveAnnotationsCache(bookId, 'bookmarks', ct, iv); // persist for offline
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, iv, ct);
currentBookmarks = JSON.parse(new TextDecoder().decode(plain));
} else {
currentBookmarks = [];
}
} catch (e) {
// offline fallback
const cached = await _loadAnnotationsCache(bookId, 'bookmarks');
if (cached?.ct) {
try {
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, cached.iv, cached.ct);
currentBookmarks = JSON.parse(new TextDecoder().decode(plain));
} catch { currentBookmarks = []; }
} else {
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};
_saveAnnotationsCache(currentBookId, 'bookmarks', ciphertext, iv); // keep local cache current
await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body,
});
bookmarksDirty = false;
} catch (e) {
_markPendingSync(currentBookId, 'bookmarks');
}
}
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 = `Seite ${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 = `★ Lesezeichen: ${label}`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2200);
}
function openBookmarksSidebar() {
if (!currentBookmarks.length) {
openSidebar('Lesezeichen', '<p class="muted">Noch keine Lesezeichen. Tippe beim Lesen auf ★.</p>');
return;
}
let html = '<ul style="list-style:none;padding:0;">';
for (const bm of currentBookmarks) {
html += `<li class="bookmark-entry">
<button class="btn-link" data-jump-bookmark="${escapeHtml(bm.id)}" style="flex:1;text-align:left;">
${escapeHtml(bm.label)}
</button>
<button class="btn-icon" data-delete-bookmark="${escapeHtml(bm.id)}" title="Löschen">✕</button>
</li>`;
}
html += '</ul>';
openSidebar('Lesezeichen', 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();
_suppressScrollJumpDetect();
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 = `
<input type="text" id="reader-search-input" class="search-input" placeholder="Suchen …" style="width:160px;">
<button class="btn-icon" id="rs-search-prev" title="Vorheriger Treffer">↑</button>
<button class="btn-icon" id="rs-search-next" title="Nächster Treffer">↓</button>
<span id="rs-search-count" class="muted"></span>
<button class="btn-icon" id="rs-search-clear" title="Close">✕</button>
`;
overlay.insertBefore(bar, $('reader-body-row'));
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;
_suppressScrollJumpDetect();
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) {
_saveAnnotationsCache(bookId, 'highlights', ct, iv); // persist for offline
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, iv, ct);
currentHighlights = JSON.parse(new TextDecoder().decode(plain));
} else {
currentHighlights = [];
}
applyHighlightsToContent();
} catch (e) {
// offline fallback
const cached = await _loadAnnotationsCache(bookId, 'highlights');
if (cached?.ct) {
try {
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, cached.iv, cached.ct);
currentHighlights = JSON.parse(new TextDecoder().decode(plain));
} catch { currentHighlights = []; }
} else {
currentHighlights = [];
}
applyHighlightsToContent();
}
}
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};
_saveAnnotationsCache(currentBookId, 'highlights', ciphertext, iv); // keep local cache current
await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body,
});
highlightsDirty = false;
} catch (e) {
_markPendingSync(currentBookId, 'highlights');
}
}
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) {}
}
_repositionMarginMarkers();
}
// Resolves a highlight/note's stored anchor back to a live DOM Range. Shared by
// rendering, margin-marker positioning and export ordering — all three need the
// same "where in the current DOM is this annotation" answer.
function resolveAnchorRange(h) {
const contentEl = $('reader-content');
if (!contentEl || !h.anchor) return null;
let range = null;
try {
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
const startBlock = blocks[h.anchor.startBlockIndex];
const endBlock = blocks[h.anchor.endBlockIndex] ?? startBlock;
if (startBlock && endBlock) {
const start = _nodeAtCharOffset(startBlock, h.anchor.startChar);
const end = _nodeAtCharOffset(endBlock, h.anchor.endChar);
if (start && end) {
range = document.createRange();
range.setStart(start.node, start.offset);
range.setEnd(end.node, end.offset);
}
}
} catch (e) {}
// Fallback: quote substring search within the chapter
if (!range && h.anchor.quote) {
const chapterEl = contentEl.querySelector(`[data-epub-src="${CSS.escape(h.anchor.chapterSrc || '')}"]`)
|| contentEl;
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;
}
}
}
return range;
}
function renderHighlight(h) {
// Freeform notes carry no in-text mark — their anchor exists purely to
// position the note card in the margin (resolveAnchorRange handles that
// directly from the stored xpath, no DOM wrapper needed).
if (h.type === 'note') return;
const range = resolveAnchorRange(h);
if (!range) return;
try {
const mark = document.createElement('mark');
mark.dataset.highlightId = h.id;
mark.className = 'epub-highlight';
mark.dataset.color = h.color || 'yellow';
range.surroundContents(mark);
} catch (e) {}
}
// Character-offset addressing within a block element (p, li, ...), rather
// than XPath-to-a-specific-text-node: XPath sibling indices shift whenever
// ANY highlight in the same block gets wrapped/unwrapped in a <mark> (that
// splits/merges text nodes), silently corrupting OTHER highlights' anchors
// in the same block. A block's overall textContent length/order is never
// touched by mark-wrapping, so a character offset into it stays valid no
// matter how many other highlights come and go around it.
function _charOffsetInBlock(block, node, nodeOffset) {
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let count = 0, n;
while ((n = walker.nextNode())) {
if (n === node) return count + nodeOffset;
count += n.textContent.length;
}
return count;
}
function _nodeAtCharOffset(block, targetOffset) {
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
let count = 0, lastNode = null, n;
while ((n = walker.nextNode())) {
lastNode = n;
const len = n.textContent.length;
if (targetOffset <= count + len) {
return {node: n, offset: Math.max(0, targetOffset - count)};
}
count += len;
}
return lastNode ? {node: lastNode, offset: lastNode.textContent.length} : null;
}
function _blockAncestor(node) {
const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
return el ? el.closest(EPUB_BLOCK_SELECTOR) : null;
}
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 startBlock = _blockAncestor(range.startContainer);
const endBlock = _blockAncestor(range.endContainer) || startBlock;
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
return {
type: 'epub',
chapterSrc: chapterEl?.getAttribute('data-epub-src') || '',
startBlockIndex: startBlock ? blocks.indexOf(startBlock) : -1,
startChar: startBlock ? _charOffsetInBlock(startBlock, range.startContainer, range.startOffset) : 0,
endBlockIndex: endBlock ? blocks.indexOf(endBlock) : -1,
endChar: endBlock ? _charOffsetInBlock(endBlock, range.endContainer, range.endOffset) : 0,
quote: range.toString().slice(0, 200),
};
}
// Marker mode — armed via the pen button in the reader header. Text selection
// only opens the highlight popover while this is active, so normal selection
// (e.g. to copy text) isn't hijacked, and the feature has a discoverable
// on/off switch instead of being an invisible "just select text" gesture.
let markerModeActive = false;
function toggleMarkerMode() {
markerModeActive = !markerModeActive;
document.querySelectorAll('.marker-mode-btn').forEach(b => b.classList.toggle('active', markerModeActive));
}
// Note editing/creation happens in the margin column on desktop, but a note
// bottom sheet (see showNoteBottomSheet) on narrow viewports — typing into a
// 150px margin strip on a phone keyboard is unpleasant, and opening that
// column at all is exactly the friction we're trying to remove for mobile
// note-taking. Reviewing existing notes in depth is expected to stay a
// desktop activity; this only affects where the editor for a note appears.
function _isMobileReader() {
return window.innerWidth <= 600;
}
function handleReaderSelection(e) {
// Clicking an existing highlight always shows its tooltip, regardless of marker mode
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;
}
if (!markerModeActive) return;
// Don't hijack taps on real interactive content (footnote refs, external
// links) — but plenty of EPUBs wrap plain reading text in a bare, hrefless
// <a id="..."> used only as an internal jump target (sanitizeEpubHtml
// strips its href but the tag itself stays); those aren't actually
// clickable and must not block note-taking, so check for a surviving
// href/footnote-ref rather than just "is inside some <a>".
if (e.target.closest('button, [data-footnote-ref], a[href]')) return;
dismissHighlightPopover();
const sel = window.getSelection();
if (sel && !sel.isCollapsed && sel.rangeCount) {
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);
return;
}
// A plain tap with nothing selected — in marker mode this means "drop a
// freeform note here" instead of doing nothing, so a note-only annotation
// never depends on finding the margin's precise click target (especially
// hard on a narrow phone screen — see createFreeformNote/_openNoteEditor).
if (typeof e.clientX === 'number' && typeof e.clientY === 'number') {
const range = _wordRangeAtPoint(e.clientX, e.clientY);
if (range) createFreeformNote(range);
}
}
function showHighlightPopover(range) {
const rect = range.getBoundingClientRect();
const popover = document.createElement('div');
popover.id = 'highlight-popover';
popover.className = 'highlight-popover';
popover.innerHTML = `
<button class="hl-color-btn" data-hl-color="yellow" style="background:#f1c40f" title="Gelb">A</button>
<button class="hl-color-btn" data-hl-color="green" style="background:#2ecc71" title="Grün">A</button>
<button class="hl-color-btn" data-hl-color="blue" style="background:#3498db" title="Blau">A</button>
<button class="hl-color-btn" data-hl-color="red" style="background:#e63946" title="Rot">A</button>
<button class="hl-note-btn" title="Notiz hinzufügen">✎</button>
`;
popover.style.visibility = 'hidden'; // measure before showing, to avoid a flash at the wrong spot
document.body.appendChild(popover);
currentHighlightPopover = popover;
// Clamp within the viewport — a selection near the top/edge of the page must
// not produce an invisible, off-screen popover (flip below when there's no
// room above; keep it horizontally on-screen).
const pRect = popover.getBoundingClientRect();
const minTop = window.scrollY + 8;
const maxTop = window.scrollY + window.innerHeight - pRect.height - 8;
let top = rect.top + window.scrollY - 44;
if (top < minTop) top = rect.bottom + window.scrollY + 8; // no room above -> try below
top = Math.max(minTop, Math.min(top, maxTop));
let left = rect.left + window.scrollX + rect.width / 2 - pRect.width / 2;
left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - pRect.width - 8));
popover.style.top = top + 'px';
popover.style.left = left + 'px';
popover.style.visibility = '';
// 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) : '<span class="muted">Keine Notiz</span>';
popover.innerHTML = `
<div style="font-size:12px;padding-bottom:4px;">${noteText}</div>
<div style="display:flex;gap:6px;">
<button class="btn btn-sm" data-hl-edit-note="${escapeHtml(h?.id || '')}">Notiz bearbeiten</button>
<button class="btn btn-sm btn-danger" data-hl-delete="${escapeHtml(h?.id || '')}">Löschen</button>
</div>
`;
popover.style.visibility = 'hidden';
document.body.appendChild(popover);
currentHighlightPopover = popover;
const pRect = popover.getBoundingClientRect();
const minTop = window.scrollY + 8;
const maxTop = window.scrollY + window.innerHeight - pRect.height - 8;
let top = rect.bottom + window.scrollY + 4;
if (top > maxTop) top = rect.top + window.scrollY - pRect.height - 4; // flip above if no room below
top = Math.max(minTop, Math.min(top, maxTop));
let left = rect.left + window.scrollX;
left = Math.max(window.scrollX + 8, Math.min(left, window.scrollX + window.innerWidth - pRect.width - 8));
popover.style.top = top + 'px';
popover.style.left = left + 'px';
popover.style.visibility = '';
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(),
type: 'highlight',
anchor,
color,
note: '',
createdAt: new Date().toISOString(),
};
currentHighlights.push(h);
highlightsDirty = true;
window.getSelection()?.removeAllRanges();
dismissHighlightPopover();
renderHighlight(h);
_repositionMarginMarkers();
debounceSaveHighlights();
}
function createHighlightWithNote(range) {
const anchor = buildEpubAnchor(range);
const h = {
id: crypto.randomUUID(),
type: 'highlight',
anchor,
color: 'yellow',
note: '',
createdAt: new Date().toISOString(),
};
currentHighlights.push(h);
highlightsDirty = true;
window.getSelection()?.removeAllRanges();
dismissHighlightPopover();
renderHighlight(h);
_repositionMarginMarkers();
_openNoteEditor(h);
}
// Every "add/edit a note" entry point routes here — notes are always edited
// right where they conceptually live (the margin), never in the separate
// right-hand sidebar, EXCEPT on narrow viewports, where the margin itself is
// too cramped to type into and a full-width bottom sheet takes over instead.
// `pending` is only set for a not-yet-created freeform note (see
// createFreeformNote); h is null in that case until the user actually saves
// text, so an empty tap doesn't leave a permanent ghost entry behind.
function _openNoteEditor(h, pending) {
if (_isMobileReader()) {
showNoteBottomSheet(h, pending);
} else {
if (!readerAnnotationsMarginOpen) toggleAnnotationsMargin();
editNoteInlineInMargin(h, pending);
}
}
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);
}
}
_repositionMarginMarkers();
debounceSaveHighlights();
}
function dismissHighlightPopover() {
if (currentHighlightPopover) {
currentHighlightPopover.remove();
currentHighlightPopover = null;
}
}
// ---------------------------------------------------------------------------
// Margin panel — a "page margin" strip left of the reader content. Shows small
// markers next to the on-screen highlights/notes, and lets you click empty
// space to anchor a freeform note at that line's height (EPUB only).
// ---------------------------------------------------------------------------
let readerAnnotationsMarginOpen = false;
function toggleAnnotationsMargin() {
const el = $('reader-margin');
const markersEl = $('reader-margin-markers');
if (!el || !markersEl || currentPdfDoc) return;
readerAnnotationsMarginOpen = !readerAnnotationsMarginOpen;
el.classList.toggle('open', readerAnnotationsMarginOpen);
if (readerAnnotationsMarginOpen) {
// Reposition once now (pre-transition) and once more after the width
// transition settles, since content reflow can shift line heights.
_repositionMarginMarkers();
el.addEventListener('transitionend', _repositionMarginMarkers, {once: true});
} else {
markersEl.innerHTML = '';
}
}
// Renders each highlight/note at its text position in the margin. Entries
// with note text become a readable "post-it" card (the whole point of the
// margin — you can read notes there, not just see that one exists); a plain
// highlight with no note is just a small color dot. Cards are stacked
// top-to-bottom so overlapping anchors don't overlap visually.
function _repositionMarginMarkers() {
const markersEl = $('reader-margin-markers');
if (!markersEl) return;
if (markersEl.querySelector('.margin-note-editor')) return; // don't blow away an active inline edit
markersEl.innerHTML = '';
if (!readerAnnotationsMarginOpen || currentPdfDoc) return;
const areaRect = markersEl.getBoundingClientRect();
// A generous pre-filter band: a note card can be considerably taller than
// its own anchor point, so a card whose anchor is somewhat outside the
// strictly-visible area can still be partially (or fully) on-screen once
// rendered. A tight band here excluded it before it ever got a chance to
// be measured — the exact bug reported ("tall notes stay hidden until
// scrolled much further than expected").
const band = Math.max(areaRect.height, 400);
const entries = [];
for (const h of currentHighlights) {
let range;
try { range = resolveAnchorRange(h); } catch (e) { continue; }
if (!range) continue;
const rect = range.getBoundingClientRect();
if (!rect.width && !rect.height) continue; // detached/invalid
const y = rect.top + rect.height / 2 - areaRect.top;
if (y < -band || y > areaRect.height + band) continue;
entries.push({h, y});
}
entries.sort((a, b) => a.y - b.y);
let nextTop = -Infinity;
const GAP = 4;
const rendered = [];
for (const {h, y} of entries) {
const hasText = !!h.note;
const el = document.createElement('div');
el.className = 'margin-note ' + (hasText ? 'margin-note-text' : 'margin-note-dot');
el.dataset.highlightId = h.id;
if (h.type === 'note') {
if (hasText) el.classList.add('margin-note-freeform');
} else {
el.dataset.color = h.color || 'yellow';
}
if (hasText) el.textContent = h.note;
const desiredTop = hasText ? y - 8 : y - 6;
const top = Math.max(desiredTop, nextTop);
el.style.top = top + 'px';
markersEl.appendChild(el);
const measured = el.getBoundingClientRect().height || (hasText ? 20 : 12);
nextTop = top + measured + GAP;
rendered.push({el, top, measured});
}
// Now that actual card heights are known, do the real cull: drop anything
// that ended up entirely outside the visible band after stacking (e.g.
// pushed down by a very tall card above it).
for (const {el, top, measured} of rendered) {
if (top + measured < -40 || top > areaRect.height + 40) el.remove();
}
}
function handleMarginClick(e) {
const markerBtn = e.target.closest('.margin-note');
if (markerBtn) {
const h = currentHighlights.find(x => x.id === markerBtn.dataset.highlightId);
if (h) _openNoteEditor(h);
return;
}
// Empty space in the markers area → place a new freeform note anchored to
// the first word of whatever line sits at this click height.
if (!readerAnnotationsMarginOpen || currentPdfDoc) return;
const markersEl = $('reader-margin-markers');
if (!markersEl || !markersEl.contains(e.target)) return;
// If an editor is already open, this click just blurs/commits it (see its
// own blur handler) — it shouldn't also create a second, unrelated note.
if (markersEl.querySelector('.margin-note-editor')) return;
const contentEl = $('reader-content');
if (!contentEl) return;
const x = contentEl.getBoundingClientRect().left + 4;
const range = _wordRangeAtPoint(x, e.clientY);
if (range) createFreeformNote(range);
}
// Finds the word starting at (or just after) the given viewport point, by
// probing near the left edge of the reader content at that height — since
// text is left-aligned, that lands at (or very near) the start of the line.
function _wordRangeAtPoint(x, y) {
let caret = null;
if (document.caretRangeFromPoint) {
caret = document.caretRangeFromPoint(x, y);
} else if (document.caretPositionFromPoint) {
const pos = document.caretPositionFromPoint(x, y);
if (pos) {
caret = document.createRange();
caret.setStart(pos.offsetNode, pos.offset);
}
}
if (!caret) return null;
const contentEl = $('reader-content');
if (!contentEl || !contentEl.contains(caret.startContainer)) return null;
let node = caret.startContainer;
let offset = caret.startOffset;
if (node.nodeType !== Node.TEXT_NODE) {
const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT);
walker.currentNode = node;
node = walker.nextNode();
if (!node) return null;
offset = 0;
}
const text = node.textContent;
let start = offset;
while (start < text.length && /\s/.test(text[start])) start++;
if (start >= text.length) return null;
let wordStart = start;
while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--;
let wordEnd = start;
while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++;
if (wordEnd <= wordStart) return null;
const range = document.createRange();
range.setStart(node, wordStart);
range.setEnd(node, wordEnd);
return range;
}
function createFreeformNote(range) {
const anchor = buildEpubAnchor(range);
// Where the note's card/dot would sit in the margin, computed from the live
// range right now (before it goes stale) — used to position the editor,
// whether or not the margin is actually open.
let top = 0;
const markersEl = $('reader-margin-markers');
if (markersEl) {
const areaRect = markersEl.getBoundingClientRect();
const rangeRect = range.getBoundingClientRect();
top = Math.max(0, rangeRect.top + rangeRect.height / 2 - areaRect.top - 8);
}
// The highlight object itself isn't created yet — see _openNoteEditor's
// `pending` param — so a tap that's immediately cancelled or left empty
// never leaves a ghost entry in currentHighlights.
_openNoteEditor(null, {anchor, top});
}
// Commits an edit for both note-editor UIs (inline margin + mobile bottom
// sheet): updates an existing note in place, or — only if there's actual
// text — creates and persists a not-yet-existing freeform note. An empty
// tap/cancel on a pending note must never leave a permanent ghost entry.
function _commitNoteEdit(h, pending, text) {
if (h) {
if (h.note !== text) {
h.note = text;
highlightsDirty = true;
debounceSaveHighlights();
}
return h;
}
if (!text) return null;
const newH = {
id: crypto.randomUUID(), type: 'note', anchor: pending.anchor,
color: null, note: text, createdAt: new Date().toISOString(),
};
currentHighlights.push(newH);
highlightsDirty = true;
debounceSaveHighlights();
return newH;
}
// Edit a note's text right where it lives in the margin, instead of jumping
// to the separate right-hand sidebar — the whole point of setting a note in
// the margin is that you shouldn't have to look away from it to write it.
// `h` is null for a not-yet-created freeform note (see createFreeformNote);
// `pending` then carries its anchor + intended margin position instead.
function editNoteInlineInMargin(h, pending) {
const markersEl = $('reader-margin-markers');
if (!markersEl) return;
markersEl.querySelector('.margin-note-editor')?.remove();
let top = pending?.top || 0;
if (h) {
const existingCard = markersEl.querySelector(`[data-highlight-id="${h.id}"]`);
top = existingCard ? (parseFloat(existingCard.style.top) || 0) : 0;
if (existingCard) existingCard.style.display = 'none';
}
const editor = document.createElement('div');
editor.className = 'margin-note-editor';
editor.style.top = top + 'px';
editor.innerHTML = '<textarea class="margin-note-textarea"></textarea>';
markersEl.appendChild(editor);
const textarea = editor.querySelector('textarea');
textarea.value = h?.note || '';
function commit() {
_commitNoteEdit(h, pending, textarea.value.trim());
editor.remove();
_repositionMarginMarkers();
}
textarea.addEventListener('blur', commit);
textarea.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
textarea.blur(); // triggers commit()
} else if (e.key === 'Escape') {
// Stop this from bubbling to the document-level Escape handler, which
// would otherwise close the whole reader (it doesn't know we just want
// to cancel this one edit) — see the global keydown listener.
e.stopPropagation();
textarea.removeEventListener('blur', commit); // discard, don't save
editor.remove();
_repositionMarginMarkers();
}
});
textarea.focus();
textarea.select();
}
// Mobile alternative to editNoteInlineInMargin — a full-width sheet instead
// of squeezing a textarea into the 150px margin column (see _openNoteEditor).
let currentNoteBottomSheet = null;
function dismissNoteBottomSheet() {
currentNoteBottomSheet?.remove();
currentNoteBottomSheet = null;
}
function showNoteBottomSheet(h, pending) {
dismissNoteBottomSheet();
const sheet = document.createElement('div');
sheet.className = 'note-bottom-sheet';
sheet.innerHTML = `
<div class="note-bottom-sheet-backdrop"></div>
<div class="note-bottom-sheet-panel">
<textarea class="note-bottom-sheet-textarea" placeholder="Notiz…"></textarea>
<div class="note-bottom-sheet-actions">
<button class="btn btn-sm" data-nbs-cancel>Abbrechen</button>
<button class="btn btn-sm btn-primary" data-nbs-save>Speichern</button>
</div>
</div>
`;
document.body.appendChild(sheet);
currentNoteBottomSheet = sheet;
const textarea = sheet.querySelector('.note-bottom-sheet-textarea');
textarea.value = h?.note || '';
function commit() {
_commitNoteEdit(h, pending, textarea.value.trim());
dismissNoteBottomSheet();
if (readerAnnotationsMarginOpen) _repositionMarginMarkers();
}
function cancel() { dismissNoteBottomSheet(); }
sheet.querySelector('[data-nbs-save]').addEventListener('click', commit);
sheet.querySelector('[data-nbs-cancel]').addEventListener('click', cancel);
sheet.querySelector('.note-bottom-sheet-backdrop').addEventListener('click', cancel);
textarea.addEventListener('keydown', e => {
if (e.key === 'Escape') { e.stopPropagation(); cancel(); }
});
requestAnimationFrame(() => {
sheet.classList.add('open');
textarea.focus();
});
}
// Same plain-text export as exportAnnotations(), but callable straight from the
// book list (no open reader, so no live DOM/TOC to resolve percent position or
// chapter titles against — falls back to the raw chapterSrc path and orders by
// the anchor's block index, which is already a global reading-order index).
async function downloadBookAnnotationsFromList(bookId, btnEl) {
if (btnEl) btnEl.disabled = true;
try {
const res = await fetch(`/books/${bookId}/highlights/`);
const {ct, iv} = await res.json();
if (!ct) { await customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; }
const key = await getOrCreateEncKey();
const plain = await decryptBytes(key, iv, ct);
const highlights = JSON.parse(new TextDecoder().decode(plain));
if (!highlights.length) { await customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.'); return; }
highlights.sort((a, b) => {
const ab = a.anchor?.startBlockIndex ?? 0, bb = b.anchor?.startBlockIndex ?? 0;
if (ab !== bb) return ab - bb;
return (a.anchor?.startChar ?? 0) - (b.anchor?.startChar ?? 0);
});
const bookTitle = bookMetaCache[bookId]?.title || `Buch #${bookId}`;
const lines = [`${bookTitle} — Markierungen & Notizen`, `Exportiert: ${new Date().toISOString().slice(0, 10)}`, ''];
let lastChapter = null;
for (const h of highlights) {
const chapterTitle = h.anchor?.chapterSrc || '';
if (chapterTitle !== lastChapter) {
lines.push(`── ${chapterTitle || 'Abschnitt ohne Titel'} ──`);
lastChapter = chapterTitle;
}
if (h.type === 'note') {
lines.push(`[Notiz] bei: „${(h.anchor?.quote || '').trim()}“`);
if (h.note) lines.push(` ${h.note}`);
} else {
lines.push(`[Markierung · ${_hlColorName(h.color || 'yellow')}] „${(h.anchor?.quote || '').trim()}“`);
if (h.note) lines.push(` Notiz: ${h.note}`);
}
lines.push('');
}
const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-notizen.txt`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
await customAlert('Export fehlgeschlagen: ' + e.message);
} finally {
if (btnEl) btnEl.disabled = false;
}
}
// ---------------------------------------------------------------------------
// Export annotations — walks all highlights/notes of the current book in
// reading order and offers them as a downloadable plain-text file.
// ---------------------------------------------------------------------------
function _tocTitleForChapterSrc(chapterSrc) {
if (!chapterSrc) return '';
const entry = currentBookToc.find(t => (t.href || '').split('#')[0] === chapterSrc);
return entry ? entry.label : '';
}
// Rough "how far through the book" position for a resolved range — there's no
// real page concept for reflowable EPUB text, so this is a coordinate of last
// resort: percent of total scrollable content height, good enough to roughly
// relocate a highlight/note later.
function _percentFromRange(range) {
const contentEl = $('reader-content');
if (!contentEl || !range) return null;
// Share of the text before the range, like the reading progress.
const block = _blockAncestor(range.startContainer);
if (block && contentEl.contains(block)) {
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
const i = blocks.indexOf(block);
if (i >= 0) {
const idx = _bookCharIndex(contentEl);
const at = idx.starts[i] + _charOffsetInBlock(block, range.startContainer, range.startOffset);
return Math.max(0, Math.min(100, (at / idx.total) * 100));
}
}
if (!contentEl.scrollHeight) return null;
const contentRect = contentEl.getBoundingClientRect();
const rangeRect = range.getBoundingClientRect();
if (!rangeRect.width && !rangeRect.height) return null;
const offsetWithinScroll = (rangeRect.top - contentRect.top) + contentEl.scrollTop;
return Math.max(0, Math.min(100, (offsetWithinScroll / contentEl.scrollHeight) * 100));
}
function _sortedAnnotations() {
const resolved = currentHighlights.map(h => {
let range;
try { range = resolveAnchorRange(h); } catch (e) { range = null; }
return {h, range};
});
resolved.sort((a, b) => {
if (!a.range || !b.range) return 0;
return a.range.compareBoundaryPoints(Range.START_TO_START, b.range);
});
return resolved;
}
// Highlight colours as the export and the list name them.
function _hlColorName(color) {
return ({yellow: 'gelb', green: 'grün', blue: 'blau', red: 'rot'})[color] || color;
}
function exportAnnotations() {
if (!currentHighlights.length) {
customAlert('In diesem Buch gibt es noch keine Markierungen oder Notizen.');
return;
}
const resolved = _sortedAnnotations();
const titleEl = $('reader-title');
const bookTitle = (titleEl?.textContent || 'Buch').split(' — ')[0];
const lines = [`${bookTitle} — Markierungen & Notizen`, `Exportiert: ${new Date().toISOString().slice(0, 10)}`, ''];
let lastChapter = null;
for (const {h, range} of resolved) {
const chapterTitle = _tocTitleForChapterSrc(h.anchor?.chapterSrc) || h.anchor?.chapterSrc || '';
if (chapterTitle !== lastChapter) {
lines.push(`── ${chapterTitle || 'Abschnitt ohne Titel'} ──`);
lastChapter = chapterTitle;
}
const pct = _percentFromRange(range);
const coord = pct !== null ? ` (≈${_formatBookPercent(pct / 100)}% im Buch)` : '';
if (h.type === 'note') {
lines.push(`[Notiz]${coord} bei: „${(h.anchor?.quote || '').trim()}“`);
if (h.note) lines.push(` ${h.note}`);
} else {
lines.push(`[Markierung · ${_hlColorName(h.color || 'yellow')}]${coord} „${(h.anchor?.quote || '').trim()}“`);
if (h.note) lines.push(` Notiz: ${h.note}`);
}
lines.push('');
}
const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-notizen.txt`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
// List of every highlight/note in the book, in reading order, each jumpable —
// the same pattern as the bookmarks sidebar, but for annotations. Unlike the
// margin panel (which only shows what's currently on-screen), this is a full
// overview, useful once a book has annotations scattered throughout.
function openAnnotationsSidebar() {
if (!currentHighlights.length) {
openSidebar('Markierungen & Notizen', '<p class="muted">Noch keine Markierungen oder Notizen.</p>');
return;
}
const resolved = _sortedAnnotations();
let html = '<ul style="list-style:none;padding:0;">';
for (const {h, range} of resolved) {
const pct = _percentFromRange(range);
const pctLabel = pct !== null ? `≈${_formatBookPercent(pct / 100)}%` : '';
const kind = h.type === 'note' ? 'Notiz' : `Markierung${h.color ? ' · ' + _hlColorName(h.color) : ''}`;
const preview = h.note || h.anchor?.quote || '(kein Text)';
html += `<li class="bookmark-entry">
<button class="btn-link" data-jump-annotation="${escapeHtml(h.id)}" style="flex:1;text-align:left;">
<span class="muted" style="font-size:11px;">${escapeHtml(kind)} ${pctLabel}</span><br>
${escapeHtml(preview.slice(0, 80))}
</button>
<button class="btn-icon" data-delete-annotation="${escapeHtml(h.id)}" title="Löschen">✕</button>
</li>`;
}
html += '</ul>';
openSidebar('Markierungen & Notizen', html);
const body = $('sidebar-body');
body.addEventListener('click', function _annClick(e) {
const jumpBtn = e.target.closest('[data-jump-annotation]');
const delBtn = e.target.closest('[data-delete-annotation]');
if (jumpBtn) {
body.removeEventListener('click', _annClick);
jumpToAnnotation(jumpBtn.dataset.jumpAnnotation);
}
if (delBtn) {
deleteHighlight(delBtn.dataset.deleteAnnotation);
openAnnotationsSidebar(); // re-render
}
});
}
function jumpToAnnotation(id) {
const h = currentHighlights.find(x => x.id === id);
if (!h) return;
closeSidebar();
_suppressScrollJumpDetect();
setTimeout(() => {
const contentEl = $('reader-content');
if (!contentEl) return;
let range;
try { range = resolveAnchorRange(h); } catch (e) { range = null; }
if (range) {
const top = range.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
contentEl.scrollBy({top: top - 16, behavior: 'smooth'});
}
}, 50);
}
// ---------------------------------------------------------------------------
// Footnote popover
// ---------------------------------------------------------------------------
let currentFootnotePopover = null;
let _footnotePopoverTimer = null;
function showFootnotePopover(link) {
dismissFootnotePopover();
const targetEl = document.getElementById(link.dataset.footnoteRef);
if (!targetEl) return;
// The note's paragraph, also when the id sits on an inline element in it
// (<p><span><a id="F400" …>143</a></span> Note…</p>).
const inline = /^(A|SPAN|SUP|SUB|B|I|EM|STRONG|SMALL)$/.test(targetEl.tagName);
const container = targetEl.closest('.footnote') ||
(inline && targetEl.closest('p, li, dd, blockquote')) || targetEl.parentElement;
if (!container) return;
const clone = container.cloneNode(true);
clone.querySelector('sup')?.remove();
clone.querySelectorAll('.epub-pagenum').forEach(e => e.remove());
// The note's own number at its start, a link back to the text ("1 Note…").
const first = Array.from(clone.querySelectorAll('a')).find(a => a.textContent.trim());
const num = first ? first.textContent.trim() : '';
if (/^[\[(]?\d{1,4}[\])]?\.?$/.test(num) && clone.textContent.trim().startsWith(num)) first.remove();
// Only the number: the note (a poem, a long quote) runs on in the next
// paragraphs, up to the next note's anchor.
if (!clone.textContent.trim()) {
let len = 0;
for (let next = container.nextElementSibling; next && len < 6000; next = next.nextElementSibling) {
if (/^H[1-6]$/.test(next.tagName) || next.querySelector('[id]')) break;
if (!next.textContent.trim()) continue;
clone.appendChild(next.cloneNode(true));
len += next.textContent.length;
}
}
const popover = document.createElement('div');
popover.className = 'footnote-popover';
const _overlay = document.getElementById('reader-overlay');
['reader-theme-sepia', 'reader-theme-bright'].forEach(cls => {
if (_overlay?.classList.contains(cls)) popover.classList.add(cls);
});
popover.innerHTML = clone.innerHTML;
const closeBtn = document.createElement('button');
closeBtn.className = 'footnote-popover-close';
closeBtn.textContent = '✕';
closeBtn.addEventListener('click', dismissFootnotePopover);
popover.appendChild(closeBtn);
const rect = link.getBoundingClientRect();
popover.style.top = (rect.bottom + 8) + 'px';
popover.style.left = Math.max(8, rect.left - 8) + 'px';
popover.addEventListener('mouseenter', () => clearTimeout(_footnotePopoverTimer));
popover.addEventListener('mouseleave', () => {
_footnotePopoverTimer = setTimeout(dismissFootnotePopover, 250);
});
document.body.appendChild(popover);
currentFootnotePopover = popover;
currentFootnotePopover._refLink = link;
const pr = popover.getBoundingClientRect();
if (pr.right > window.innerWidth - 8) popover.style.left = (window.innerWidth - pr.width - 8) + 'px';
if (pr.bottom > window.innerHeight - 8) popover.style.top = (rect.top - pr.height - 8) + 'px';
}
function dismissFootnotePopover() {
clearTimeout(_footnotePopoverTimer);
currentFootnotePopover?.remove();
currentFootnotePopover = null;
}
// ---------------------------------------------------------------------------
// Radio sidebar (compact player)
// ---------------------------------------------------------------------------
function openRadioSidebar() {
const stationName = currentStation ? escapeHtml(currentStation.name) : '— kein Sender —';
const track = currentTrack ? escapeHtml(currentTrack) : '';
const vol = document.getElementById('volume')?.value ?? 204;
const rows = [...document.querySelectorAll('#saved-tbody tr[data-id]')];
const stationsHtml = rows.length
? rows.map(r => `<li><button class="btn btn-sm rsb-play-btn" data-url="${escapeHtml(r.dataset.url || '')}" data-name="${escapeHtml(r.dataset.name || '')}">${escapeHtml(r.dataset.name || '')}</button></li>`).join('')
: `<li class="muted">Keine gespeicherten Sender.</li>`;
const html = `
<div class="rsb-nowplaying">
<div class="rsb-station-name">${stationName}</div>
${track ? `<div class="rsb-track muted">${track}</div>` : ''}
</div>
<div class="rsb-controls">
<button class="btn ${isPlaying ? 'playing' : ''}" id="rsb-playstop">${isPlaying ? '⏹ Stopp' : '▶ Abspielen'}</button>
<label class="rsb-vol">Lautst. <input type="range" id="rsb-volume" min="0" max="255" value="${vol}"></label>
</div>
<ul class="rsb-station-list">${stationsHtml}</ul>
`;
openSidebar('Radio', html);
const body = $('sidebar-body');
body.querySelector('#rsb-playstop').addEventListener('click', togglePlayStop);
body.querySelector('#rsb-volume').addEventListener('input', function () {
const v = this.value;
const mainSlider = document.getElementById('volume');
const mainNum = document.getElementById('volume-num');
if (mainSlider) mainSlider.value = v;
if (mainNum) mainNum.value = v;
audio.volume = v / 255;
});
body.querySelectorAll('.rsb-play-btn').forEach(btn => {
btn.addEventListener('click', () => playStation(btn.dataset.url, btn.dataset.name, null));
});
}
// ---------------------------------------------------------------------------
// 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.
}
// 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);
}
// Restore persisted read-aloud language
const ttsLangSelect = $('reader-tts-lang');
if (ttsLangSelect) ttsLangSelect.value = ttsLang;
// 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();
const showReadToggle = $('book-show-read-toggle');
if (showReadToggle) showReadToggle.checked = _bookShowRead;
// Restore active tab: a URL hash (shared/bookmarked link, or browser
// back/forward) wins over the last-used tab remembered in localStorage —
// except when offline, where books (usable offline) always takes over.
const offlineOverride = !navigator.onLine && IS_AUTHENTICATED;
const hashTab = (location.hash || '').replace(/^#/, '').split('/')[0];
if (!offlineOverride && TOP_TABS.includes(hashTab)) {
_routeFromHash();
} else {
const savedTab = localStorage.getItem('diora_active_tab') || 'radio';
const savedRadioTab = localStorage.getItem('diora_active_radio_tab') || 'saved';
showTab(offlineOverride ? 'books' : savedTab);
showRadioTab(savedRadioTab);
}
// React to connectivity changes
window.addEventListener('offline', () => {
showOfflineBanner(true);
if (IS_AUTHENTICATED) showTab('books');
});
window.addEventListener('online', async () => {
showOfflineBanner(false);
await _syncPendingAnnotations();
loadBookList(); // loadBookList pushes any locally-advanced progress automatically
});
// Show banner immediately if already offline at startup
if (!navigator.onLine) showOfflineBanner(true);
// Footnote hover (desktop) + tap (mobile) via event delegation
const _readerContentEl = $('reader-content');
if (_readerContentEl) {
_readerContentEl.addEventListener('mouseover', e => {
const link = e.target.closest('[data-footnote-ref]');
if (!link) return;
clearTimeout(_footnotePopoverTimer);
_footnotePopoverTimer = setTimeout(() => showFootnotePopover(link), 120);
});
_readerContentEl.addEventListener('mouseout', e => {
if (!e.target.closest('[data-footnote-ref]')) return;
_footnotePopoverTimer = setTimeout(dismissFootnotePopover, 250);
});
_readerContentEl.addEventListener('click', e => {
const link = e.target.closest('[data-footnote-ref]');
if (!link) return;
e.preventDefault();
if (currentFootnotePopover?._refLink === link) {
dismissFootnotePopover();
} else {
showFootnotePopover(link);
}
});
// Text selection → highlight color/note popover
_readerContentEl.addEventListener('mouseup', handleReaderSelection);
_readerContentEl.addEventListener('touchend', e => {
const target = e.target;
const touch = e.changedTouches && e.changedTouches[0];
const clientX = touch ? touch.clientX : undefined;
const clientY = touch ? touch.clientY : undefined;
setTimeout(() => handleReaderSelection({target, clientX, clientY}), 50);
});
}
// Margin ("page margin") panel — click a marker to view/edit, click empty
// space to anchor a new freeform note at that line's height
const _readerMarginEl = $('reader-margin');
if (_readerMarginEl) {
_readerMarginEl.addEventListener('click', handleMarginClick);
}
})();