Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
32 klassische Skripte in der alten Reihenfolge (01-core … 99-init), ohne Änderung am Code darin: geschnitten nur zwischen Anweisungen, je Datei 'use strict' wie vorher für die ganze Datei. Geprüft (acorn): kein Code, der beim Laden läuft, braucht einen Namen aus einer späteren Datei. Globale Funktionen und let/const bleiben global, die App-Schichten (native.js, native-eink.js, …) umhüllen sie wie bisher. Template und sw.js-SHELL listen die Dateien; diora-android build_web nimmt static/js rekursiv. Getestet: 14 Browser-Tests (e2e), Desktop-App (Buch, Lesestand, Leiste im Reader, Hell/Dunkel). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a16fcac388
commit
656520fe8e
35 changed files with 6962 additions and 6868 deletions
6864
static/js/app.js
6864
static/js/app.js
File diff suppressed because it is too large
Load diff
153
static/js/app/01-core.js
Normal file
153
static/js/app/01-core.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 });
|
||||
}
|
||||
683
static/js/app/10-radio.js
Normal file
683
static/js/app/10-radio.js
Normal file
|
|
@ -0,0 +1,683 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)
|
||||
// The last position written (it's in the outbox too, in case the beacon is lost).
|
||||
if (_lastProgressBeacon) navigator.sendBeacon(`/books/${_lastProgressBeacon.book}/progress/`, _progressBody(_lastProgressBeacon));
|
||||
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();
|
||||
}
|
||||
// Back in front: maybe the book was read on another device meanwhile.
|
||||
if (document.visibilityState === 'visible' && currentBookId) _checkNewerPositionElsewhere();
|
||||
});
|
||||
|
||||
// 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'})">
|
||||
▶ ${escapeHtml(r.station_name)}
|
||||
</button>
|
||||
<span class="muted">${r.play_count}×</span>
|
||||
</li>`;
|
||||
}
|
||||
html += '</ul>';
|
||||
container.innerHTML = html;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function escapeAttr(s) {
|
||||
return String(s).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 || '',
|
||||
})})'>
|
||||
▶ 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 = '▶ Abspielen';
|
||||
play.addEventListener('click', () => searchPlay(url, name, data));
|
||||
const save = document.createElement('button');
|
||||
save.className = 'btn btn-sm';
|
||||
save.innerHTML = '★ 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">★</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})">
|
||||
▶ 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);
|
||||
}
|
||||
}
|
||||
173
static/js/app/14-focus.js
Normal file
173
static/js/app/14-focus.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
});
|
||||
173
static/js/app/16-stations.js
Normal file
173
static/js/app/16-stations.js
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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">★ 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
67
static/js/app/20-tabs.js
Normal file
67
static/js/app/20-tabs.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
141
static/js/app/24-ambient.js
Normal file
141
static/js/app/24-ambient.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
}
|
||||
98
static/js/app/26-sidebar.js
Normal file
98
static/js/app/26-sidebar.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
46
static/js/app/28-serviceworker.js
Normal file
46
static/js/app/28-serviceworker.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
30
static/js/app/29-m3u.js
Normal file
30
static/js/app/29-m3u.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 = '';
|
||||
}
|
||||
91
static/js/app/30-contrast.js
Normal file
91
static/js/app/30-contrast.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Light and dark: the switch is in base.html (every page has it). The reader's
|
||||
// "Wie die App" follows it.
|
||||
window.addEventListener('diora-theme', () => {
|
||||
if (currentBookId) applyReaderSettings(!!currentPdfDoc);
|
||||
});
|
||||
|
||||
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'));
|
||||
}
|
||||
61
static/js/app/32-crypto.js
Normal file
61
static/js/app/32-crypto.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
54
static/js/app/34-wallpaper.js
Normal file
54
static/js/app/34-wallpaper.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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}')`;
|
||||
const contrastBtn = document.getElementById('contrast-toggle');
|
||||
if (contrastBtn) contrastBtn.hidden = false; // only with a picture behind the page
|
||||
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);
|
||||
}
|
||||
}
|
||||
262
static/js/app/40-epub.js
Normal file
262
static/js/app/40-epub.js
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
298
static/js/app/44-books.js
Normal file
298
static/js/app/44-books.js
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
if (_readPos && _readPos.hold === 'jump') _readPos.hold = false;
|
||||
}
|
||||
|
||||
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;
|
||||
// Until it's gone, the place before the jump stays the reading position.
|
||||
if (_readPos && !_readPos.hold) _readPos.hold = 'jump';
|
||||
toast.querySelector('.reader-toast-btn').addEventListener('click', () => {
|
||||
const contentEl = $('reader-content');
|
||||
if (contentEl) {
|
||||
_suppressScrollJumpDetect();
|
||||
contentEl.scrollTo({top: backTop, behavior: 'smooth'});
|
||||
}
|
||||
_dismissScrollJumpToast();
|
||||
// Back where it was: that's no new reading.
|
||||
_readerInteractedAt = 0;
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (_scrollJumpToastEl !== toast) return;
|
||||
_dismissScrollJumpToast();
|
||||
// Stayed there: the far place is where the reading goes on.
|
||||
_noteReaderInteraction();
|
||||
if (_readPos) _readPos.key = null;
|
||||
}, 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);
|
||||
}
|
||||
198
static/js/app/46-readpos.js
Normal file
198
static/js/app/46-readpos.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading position: the place last read wins, on any device (docs/progress-sync.md).
|
||||
//
|
||||
// A position counts as read when the reader got there by reading (scrolling,
|
||||
// turning pages, choosing a chapter); its time is that moment, not the time it
|
||||
// is saved. So re-saving an unchanged place, on a timer or on closing, carries
|
||||
// the old time and can never beat a newer place read on another device. A book
|
||||
// just opened at the server's place has read nothing here yet: nothing is sent.
|
||||
// Own writes wait in a small outbox (localStorage) until the server has them.
|
||||
// ---------------------------------------------------------------------------
|
||||
let _serverOffset = 0; // the server's clock minus this device's (ms)
|
||||
let _readPos = null; // the open book: {bookId, ready, key, readAt (device ms), moved, hold}
|
||||
let _readerInteractedAt = 0;
|
||||
|
||||
function _progressDevice() {
|
||||
return window.DIORA_DEVICE_NAME || 'Browser';
|
||||
}
|
||||
|
||||
function _noteServerNow(ms) {
|
||||
if (typeof ms === 'number' && ms > 1e12) _serverOffset = ms - Date.now();
|
||||
}
|
||||
|
||||
function _noteServerNowHeader(res) {
|
||||
try { _noteServerNow(Number(res.headers.get('X-Server-Now'))); } catch (e) {}
|
||||
}
|
||||
|
||||
// The reader did something (scroll wheel, touch, key, mouse): the next change
|
||||
// of position is reading, not a layout settling.
|
||||
function _noteReaderInteraction() {
|
||||
_readerInteractedAt = Date.now();
|
||||
if (_readPos && _readPos.hold === 'until-moved') _readPos.hold = false;
|
||||
}
|
||||
window.__dioraReaderInteraction = _noteReaderInteraction;
|
||||
|
||||
// While a jump is only a look (a footnote's place, "Zurück zum Text"), the
|
||||
// place before it stays the reading position. true: until the next
|
||||
// interaction; false: released now.
|
||||
window.__dioraHoldProgress = function (on) {
|
||||
if (_readPos) _readPos.hold = on ? 'until-moved' : false;
|
||||
};
|
||||
|
||||
function _positionKey(anchor, fraction) {
|
||||
return anchor || 'f' + (fraction || 0).toFixed(4);
|
||||
}
|
||||
|
||||
// Called with every computed position: a change that follows an interaction is reading.
|
||||
function _noteReadingPosition(anchor, fraction) {
|
||||
if (!_readPos || !_readPos.ready || _readPos.hold) return;
|
||||
const key = _positionKey(anchor, fraction);
|
||||
if (key === _readPos.key) return;
|
||||
_readPos.key = key;
|
||||
if (Date.now() - _readerInteractedAt < 10000) {
|
||||
_readPos.readAt = Date.now();
|
||||
_readPos.moved = true;
|
||||
}
|
||||
}
|
||||
|
||||
function _progressOutboxKey() {
|
||||
return `diora_progress_outbox_${window.USER_ID || 0}`;
|
||||
}
|
||||
|
||||
function _progressOutbox() {
|
||||
try { return JSON.parse(localStorage.getItem(_progressOutboxKey()) || '{}') || {}; } catch (e) { return {}; }
|
||||
}
|
||||
|
||||
function _progressOutboxPut(entry) {
|
||||
try {
|
||||
const box = _progressOutbox();
|
||||
const old = box[entry.book];
|
||||
if (old && !entry.force && old.read_at > entry.read_at) return;
|
||||
box[entry.book] = entry;
|
||||
localStorage.setItem(_progressOutboxKey(), JSON.stringify(box));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function _progressOutboxDone(bookId, readAt) {
|
||||
try {
|
||||
const box = _progressOutbox();
|
||||
if (box[bookId] && box[bookId].read_at === readAt) {
|
||||
delete box[bookId];
|
||||
localStorage.setItem(_progressOutboxKey(), JSON.stringify(box));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function _progressBody(e) {
|
||||
return JSON.stringify({
|
||||
scroll_fraction: e.fraction, position_anchor: e.anchor, read_at: e.read_at,
|
||||
sent_at: Date.now(), device: e.device, ...(e.force && {force: true}),
|
||||
});
|
||||
}
|
||||
|
||||
// Send one queued position; true once the server has it (stored or not: it decides).
|
||||
async function _sendProgress(e) {
|
||||
try {
|
||||
const res = await fetch(`/books/${e.book}/progress/`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
|
||||
body: _progressBody(e),
|
||||
});
|
||||
if (!(res.ok || (res.status >= 400 && res.status < 500))) return false;
|
||||
try { _noteServerNow((await res.json()).server_now); } catch (err) {}
|
||||
_progressOutboxDone(e.book, e.read_at);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let _progressFlushing = null;
|
||||
function _flushProgressOutbox() {
|
||||
if (!_progressFlushing) {
|
||||
_progressFlushing = (async () => {
|
||||
for (const e of Object.values(_progressOutbox())) await _sendProgress(e);
|
||||
})().finally(() => { _progressFlushing = null; });
|
||||
}
|
||||
return _progressFlushing;
|
||||
}
|
||||
|
||||
// A book's state from the server list, made newer by an own write the server doesn't have yet.
|
||||
function _withPendingProgress(b) {
|
||||
const e = _progressOutbox()[b.id];
|
||||
if (!e) return b;
|
||||
const at = e.read_at + _serverOffset;
|
||||
if (b.read_at != null && at < b.read_at && !e.force) return b;
|
||||
return {...b, scroll_fraction: e.fraction, position_anchor: e.anchor, read_at: at,
|
||||
device: e.device, last_read: new Date(at).toISOString()};
|
||||
}
|
||||
|
||||
function _bookRecency(b) {
|
||||
if (typeof b.read_at === 'number') return b.read_at;
|
||||
const t = b.last_read ? Date.parse(b.last_read) : NaN;
|
||||
return isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
function _readerToast(text, button, onClick, ms) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'reader-toast reader-toast-action';
|
||||
toast.innerHTML = '<span></span><button type="button" class="reader-toast-btn"></button>';
|
||||
toast.querySelector('span').textContent = text;
|
||||
toast.querySelector('button').textContent = button;
|
||||
document.body.appendChild(toast);
|
||||
const done = () => toast.remove();
|
||||
toast.querySelector('button').addEventListener('click', () => { done(); onClick(); });
|
||||
setTimeout(done, ms || 10000);
|
||||
return toast;
|
||||
}
|
||||
|
||||
function _jumpToPosition(anchor, fraction) {
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
_suppressScrollJumpDetect();
|
||||
if (currentPdfDoc && readerSettings.pdfPaginated && pdfTotalPages > 1) {
|
||||
pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1);
|
||||
enterPdfPaginatedMode();
|
||||
} else if (currentPdfDoc) {
|
||||
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
|
||||
} else if (!restoreFromAnchor(contentEl, anchor)) {
|
||||
restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction));
|
||||
}
|
||||
}
|
||||
|
||||
// Another device read this book after the place shown here: offer to go there.
|
||||
let _newerOfferShown = null;
|
||||
async function _checkNewerPositionElsewhere() {
|
||||
const pos = _readPos;
|
||||
if (!pos || !pos.ready || !currentBookId || pos.bookId !== currentBookId) return;
|
||||
let b;
|
||||
try {
|
||||
const res = await fetch('/books/', {cache: 'no-store'});
|
||||
_noteServerNowHeader(res);
|
||||
b = (await res.json()).find(x => x.id === pos.bookId);
|
||||
} catch (e) { return; }
|
||||
if (!b || b.read_at == null || _readPos !== pos) return;
|
||||
if (b.read_at <= pos.readAt + _serverOffset + 3000) return;
|
||||
if (_positionKey(b.position_anchor || '', b.scroll_fraction) === pos.key) return;
|
||||
const offerKey = `${pos.bookId}:${b.read_at}`;
|
||||
if (_newerOfferShown === offerKey) return;
|
||||
_newerOfferShown = offerKey;
|
||||
const where = b.device ? `Auf ${b.device}` : 'Auf einem anderen Gerät';
|
||||
_readerToast(`${where} weitergelesen · ${_formatBookPercent(b.scroll_fraction || 0)} %`, 'Dorthin', () => {
|
||||
if (_readPos !== pos) return;
|
||||
_jumpToPosition(b.position_anchor || '', b.scroll_fraction || 0);
|
||||
// That place and its time are the server's: nothing new to tell it.
|
||||
pos.key = null;
|
||||
pos.readAt = b.read_at - _serverOffset;
|
||||
pos.moved = false;
|
||||
setTimeout(() => {
|
||||
const contentEl = $('reader-content');
|
||||
if (_readPos === pos && contentEl) {
|
||||
pos.key = currentPdfDoc ? _positionKey('', b.scroll_fraction || 0) : _positionKey(getPositionAnchor(contentEl), 0);
|
||||
}
|
||||
}, 300);
|
||||
}, 15000);
|
||||
}
|
||||
window.__dioraCheckNewerPosition = _checkNewerPositionElsewhere;
|
||||
294
static/js/app/48-book-cache.js
Normal file
294
static/js/app/48-book-cache.js
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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, readAt = null) {
|
||||
const at = (readAt == null ? Date.now() : readAt) + _serverOffset;
|
||||
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;
|
||||
// The place read last, as on the server (docs/progress-sync.md).
|
||||
if (entry && (force || entry.read_at == null || at >= entry.read_at)) {
|
||||
entry.scroll_fraction = fraction;
|
||||
entry.position_anchor = anchor;
|
||||
entry.read_at = at;
|
||||
entry.device = _progressDevice();
|
||||
entry.last_read = new Date(at).toISOString();
|
||||
if (_cmpProgress(anchor, fraction, entry.furthest_anchor || '', entry.furthest_fraction || 0) > 0) {
|
||||
entry.furthest_anchor = anchor;
|
||||
entry.furthest_fraction = fraction;
|
||||
}
|
||||
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 {}
|
||||
}
|
||||
54
static/js/app/50-reader-state.js
Normal file
54
static/js/app/50-reader-state.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use strict';
|
||||
|
||||
// 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';
|
||||
}
|
||||
630
static/js/app/52-book-list.js
Normal file
630
static/js/app/52-book-list.js
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
'use strict';
|
||||
|
||||
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>';
|
||||
_flushProgressOutbox(); // own positions the server doesn't have yet
|
||||
const res = await fetch('/books/', {cache: 'no-store'});
|
||||
_noteServerNowHeader(res);
|
||||
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 || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_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 || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights});
|
||||
}
|
||||
}
|
||||
// An own position the server doesn't have yet (sent above) shows if it's the newer one.
|
||||
for (let i = 0; i < decrypted.length; i++) decrypted[i] = _withPendingProgress(decrypted[i]);
|
||||
|
||||
decrypted.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || 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()).map(_withPendingProgress);
|
||||
if (cachedBooks.length) {
|
||||
cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (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 & 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;
|
||||
}
|
||||
}
|
||||
153
static/js/app/56-cloud-import.js
Normal file
153
static/js/app/56-cloud-import.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
124
static/js/app/58-pdf.js
Normal file
124
static/js/app/58-pdf.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
'use strict';
|
||||
|
||||
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};
|
||||
}
|
||||
337
static/js/app/60-tts.js
Normal file
337
static/js/app/60-tts.js
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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';
|
||||
let _ttsBlocks = null; // the book's blocks while reading aloud
|
||||
let _ttsCur = null; // what's being read: {bi, si, sentence, block}
|
||||
|
||||
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) {
|
||||
// Listening is reading: where the voice is counts as the reading position.
|
||||
_noteReaderInteraction();
|
||||
_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 (and its sentence startSentence)
|
||||
// onward, fetching one sentence ahead while the current one plays so there's
|
||||
// no gap between them.
|
||||
async function _ttsPlayLoop(blocks, startBlockIndex, runToken, startSentence = 0) {
|
||||
function* sentenceStream() {
|
||||
for (let bi = startBlockIndex; bi < blocks.length; bi++) {
|
||||
const block = blocks[bi];
|
||||
const text = (block.textContent || '').trim();
|
||||
if (!text) continue;
|
||||
const sentences = _ttsSplitSentences(text);
|
||||
for (let si = bi === startBlockIndex ? startSentence : 0; si < sentences.length; si++) {
|
||||
yield {block, sentence: sentences[si], bi, si};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// Started in the middle of a block (previous/next sentence): find it from there.
|
||||
blockSearchOffset = _ttsOffsetOf(block, cur.value.si);
|
||||
}
|
||||
_ttsCur = {bi: cur.value.bi, si: cur.value.si, sentence, block};
|
||||
blockSearchOffset = _ttsHighlightSentence(block, sentence, blockSearchOffset);
|
||||
_ttsNotify();
|
||||
|
||||
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();
|
||||
_ttsNotify();
|
||||
}
|
||||
|
||||
// ---- read-aloud for a player bar (the shared dock in native-podcast.js) ----------
|
||||
// window.__dioraTts: state(), toggle(), stop(), next(), prev(); a 'diora-tts'
|
||||
// event on window after every change. A dock that shows it sets
|
||||
// window.__dioraTtsDock = true, and the reader's own little bar stays away.
|
||||
|
||||
function _ttsState() {
|
||||
const meta = (currentBookId && bookMetaCache[currentBookId]) || {};
|
||||
const cur = _ttsCur;
|
||||
const chapterEl = cur && cur.block.closest('[data-epub-src]');
|
||||
const blocks = _ttsBlocks || [];
|
||||
return {
|
||||
active: ttsActive,
|
||||
paused: ttsPaused,
|
||||
bookId: currentBookId,
|
||||
title: meta.title || '',
|
||||
author: meta.author || '',
|
||||
chapter: chapterEl ? _tocTitleForChapterSrc(chapterEl.dataset.epubSrc) : '',
|
||||
sentence: cur ? cur.sentence : '',
|
||||
progress: cur && blocks.length ? cur.bi / blocks.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function _ttsNotify() {
|
||||
try { window.dispatchEvent(new CustomEvent('diora-tts', {detail: _ttsState()})); } catch (e) {}
|
||||
}
|
||||
|
||||
// Read from another sentence on: the one before/after the current one.
|
||||
function _ttsRestartAt(bi, si) {
|
||||
const blocks = _ttsBlocks;
|
||||
if (!ttsActive || !blocks) return;
|
||||
ttsRunToken++;
|
||||
const runToken = ttsRunToken;
|
||||
if (ttsAudio) {
|
||||
try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {}
|
||||
ttsAudio = null;
|
||||
}
|
||||
_ttsPlayLoop(blocks, bi, runToken, si);
|
||||
}
|
||||
|
||||
// Where sentence si of a block starts in its text (for the highlight).
|
||||
function _ttsOffsetOf(block, si) {
|
||||
const full = block.textContent || '';
|
||||
let off = 0;
|
||||
_ttsSplitSentences(full.trim()).slice(0, si).forEach(s => {
|
||||
const k = full.indexOf(s, off);
|
||||
if (k >= 0) off = k + s.length;
|
||||
});
|
||||
return off;
|
||||
}
|
||||
|
||||
function _ttsSentenceCount(block) {
|
||||
const text = (block.textContent || '').trim();
|
||||
return text ? _ttsSplitSentences(text).length : 0;
|
||||
}
|
||||
|
||||
function _ttsStep(dir) {
|
||||
const cur = _ttsCur;
|
||||
const blocks = _ttsBlocks;
|
||||
if (!cur || !blocks) return;
|
||||
if (dir > 0) {
|
||||
if (cur.si + 1 < _ttsSentenceCount(cur.block)) return _ttsRestartAt(cur.bi, cur.si + 1);
|
||||
for (let bi = cur.bi + 1; bi < blocks.length; bi++) if (_ttsSentenceCount(blocks[bi])) return _ttsRestartAt(bi, 0);
|
||||
stopReadAloud();
|
||||
} else {
|
||||
if (cur.si > 0) return _ttsRestartAt(cur.bi, cur.si - 1);
|
||||
for (let bi = cur.bi - 1; bi >= 0; bi--) {
|
||||
const n = _ttsSentenceCount(blocks[bi]);
|
||||
if (n) return _ttsRestartAt(bi, n - 1);
|
||||
}
|
||||
_ttsRestartAt(cur.bi, 0);
|
||||
}
|
||||
}
|
||||
|
||||
window.__dioraTts = {
|
||||
state: _ttsState,
|
||||
toggle: () => (ttsActive ? _ttsTogglePause() : startReadAloud()),
|
||||
stop: () => stopReadAloud(),
|
||||
next: () => _ttsStep(1),
|
||||
prev: () => _ttsStep(-1),
|
||||
};
|
||||
|
||||
function _ttsUpdateBar() {
|
||||
if (!ttsBarEl) return;
|
||||
const btn = ttsBarEl.querySelector('.tts-pause-btn');
|
||||
if (btn) btn.textContent = ttsPaused ? '▶' : '⏸';
|
||||
}
|
||||
|
||||
function _ttsShowBar() {
|
||||
_ttsRemoveBar();
|
||||
if (window.__dioraTtsDock) return; // the player bar shows it
|
||||
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;
|
||||
_ttsBlocks = blocks;
|
||||
_ttsCur = null;
|
||||
|
||||
const btn = $('reader-tts-btn');
|
||||
if (btn) { btn.classList.add('active'); btn.title = 'Vorlesen beenden'; }
|
||||
_ttsShowBar();
|
||||
_ttsNotify();
|
||||
|
||||
_ttsPlayLoop(blocks, startIndex, runToken);
|
||||
}
|
||||
|
||||
function stopReadAloud() {
|
||||
ttsRunToken++;
|
||||
ttsActive = false;
|
||||
ttsPaused = false;
|
||||
if (ttsAudio) {
|
||||
try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {}
|
||||
ttsAudio = null;
|
||||
}
|
||||
_ttsClearHighlight();
|
||||
_ttsRemoveBar();
|
||||
_ttsBlocks = null;
|
||||
_ttsCur = null;
|
||||
const btn = $('reader-tts-btn');
|
||||
if (btn) { btn.classList.remove('active'); btn.title = 'Vorlesen'; }
|
||||
_ttsNotify();
|
||||
}
|
||||
641
static/js/app/62-reader.js
Normal file
641
static/js/app/62-reader.js
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Immersive reader mode — tap centre of screen to toggle bars
|
||||
// ---------------------------------------------------------------------------
|
||||
let _immBarsVisible = true;
|
||||
|
||||
function _immHandleTap(e) {
|
||||
// Ignore taps on interactive elements (buttons, links, inputs, settings panel, footnote popover)
|
||||
if (e.target.closest('button, a, input, select, label, #reader-settings-panel, .reader-header, .footnote-popover, #reader-margin, #highlight-popover, .note-bottom-sheet, .tts-bar')) return;
|
||||
// In marker mode, taps have a dedicated meaning (highlight/create a note) —
|
||||
// don't also toggle the immersive bars underneath.
|
||||
if (markerModeActive) return;
|
||||
// Don't toggle bars while the user has just finished selecting text (e.g. to highlight it)
|
||||
const sel = window.getSelection();
|
||||
if (sel && !sel.isCollapsed) return;
|
||||
// If a footnote popover is open, dismiss it instead of toggling bars
|
||||
if (currentFootnotePopover && !currentFootnotePopover.contains(e.target)) {
|
||||
dismissFootnotePopover();
|
||||
return;
|
||||
}
|
||||
_immBarsVisible = !_immBarsVisible;
|
||||
document.body.classList.toggle('reader-immersive', !_immBarsVisible);
|
||||
// Close settings panel when bars disappear
|
||||
if (!_immBarsVisible && readerSettingsPanelOpen) {
|
||||
const sp = document.getElementById('reader-settings-panel');
|
||||
if (sp) sp.remove();
|
||||
readerSettingsPanelOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function enterReaderImmersiveMode() {
|
||||
_immBarsVisible = true;
|
||||
document.body.classList.remove('reader-immersive');
|
||||
document.addEventListener('click', _immHandleTap);
|
||||
}
|
||||
|
||||
function exitReaderImmersiveMode() {
|
||||
_immBarsVisible = true;
|
||||
document.body.classList.remove('reader-immersive');
|
||||
document.removeEventListener('click', _immHandleTap);
|
||||
}
|
||||
|
||||
// Serializes openBook: it empties #reader-content, then appends the chapters
|
||||
// across several awaits. A second call in between (a second tap while a slow
|
||||
// e-reader is still decrypting) appended the whole book a second time — and
|
||||
// progress saved from that doubled DOM had block indices beyond the real book,
|
||||
// which then out-ranked every real position on the server.
|
||||
let _openBookInFlight = null;
|
||||
|
||||
async function openBook(bookId) {
|
||||
if (_openBookInFlight) {
|
||||
if (_openBookInFlight.bookId === bookId) return _openBookInFlight.promise;
|
||||
await _openBookInFlight.promise.catch(() => {});
|
||||
}
|
||||
const promise = _openBookOnce(bookId);
|
||||
_openBookInFlight = {bookId, promise};
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (_openBookInFlight && _openBookInFlight.promise === promise) _openBookInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function _openBookOnce(bookId) {
|
||||
// Nothing is saved for this book until its place is restored.
|
||||
_readPos = {bookId, ready: false, key: null, readAt: 0, moved: false, hold: false};
|
||||
const overlay = $('reader-overlay');
|
||||
const contentEl = $('reader-content');
|
||||
const titleEl = $('reader-title');
|
||||
if (!overlay || !contentEl) return;
|
||||
|
||||
titleEl.textContent = 'Lade …';
|
||||
contentEl.innerHTML = '';
|
||||
overlay.style.display = '';
|
||||
|
||||
try {
|
||||
loadReaderSettings(bookId);
|
||||
_charIndex = null;
|
||||
const key = await getOrCreateEncKey();
|
||||
let data_ct, data_iv;
|
||||
const cached = await _getCachedBook(bookId);
|
||||
if (cached) {
|
||||
({data_ct, data_iv} = cached);
|
||||
} else {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/books/${bookId}/data/`);
|
||||
} catch {
|
||||
// offline and book not cached yet
|
||||
overlay.style.display = 'none';
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'reader-toast';
|
||||
toast.textContent = 'Buch nicht offline verfügbar — einmal online öffnen zum Cachen.';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3500);
|
||||
return;
|
||||
}
|
||||
({data_ct, data_iv} = await res.json());
|
||||
_setCachedBook(bookId, data_ct, data_iv); // fire-and-forget
|
||||
}
|
||||
const plain = await decryptBytes(key, data_iv, data_ct);
|
||||
|
||||
// Revoke any previous image blob URLs
|
||||
for (const url of Object.values(currentImageMap)) URL.revokeObjectURL(url);
|
||||
currentImageMap = {};
|
||||
|
||||
const cachedMeta = bookMetaCache[bookId] || {};
|
||||
let title = cachedMeta.title || '';
|
||||
let author = cachedMeta.author || '';
|
||||
let toc = [];
|
||||
let numPages = 0;
|
||||
const isPdfBook = cachedMeta.type === 'pdf';
|
||||
|
||||
if (isPdfBook) {
|
||||
currentPdfDoc = null; // reset so renderPdf creates fresh doc
|
||||
const loadingEl = document.createElement('div');
|
||||
loadingEl.className = 'pdf-loading-overlay';
|
||||
loadingEl.innerHTML = '<span class="pdf-loading-spinner"></span>';
|
||||
overlay.appendChild(loadingEl);
|
||||
const result = await renderPdf(plain, contentEl);
|
||||
loadingEl.remove();
|
||||
title = result.title || title;
|
||||
author = result.author || author;
|
||||
toc = result.toc;
|
||||
numPages = result.numPages;
|
||||
currentPdfBuffer = plain;
|
||||
} else {
|
||||
currentPdfBuffer = null;
|
||||
// Prepared before (same book file, same preparation code): only the images to unpack.
|
||||
const prepared = await _getParsedBook(bookId, data_iv, key);
|
||||
const result = prepared ? await _restoreParsedBook(prepared, plain) : await parseEpub(plain);
|
||||
if (!prepared) {
|
||||
// Kept for the next open once the book is on screen (encrypting takes a moment).
|
||||
setTimeout(() => _setParsedBook(bookId, data_iv, key, result), 3000);
|
||||
}
|
||||
title = result.title || title;
|
||||
author = result.author || author;
|
||||
toc = result.toc;
|
||||
currentImageMap = result.imageMap;
|
||||
// Inject chapters progressively so the browser stays responsive on large books:
|
||||
// a batch per ~50 ms of work (a fixed count waited a frame every 8 chapters).
|
||||
const tmp = document.createElement('div');
|
||||
for (let i = 0; i < result.chapters.length;) {
|
||||
const frag = document.createDocumentFragment();
|
||||
const start = performance.now();
|
||||
do {
|
||||
tmp.innerHTML = result.chapters[i++];
|
||||
while (tmp.firstChild) frag.appendChild(tmp.firstChild);
|
||||
} while (i < result.chapters.length && performance.now() - start < 50);
|
||||
contentEl.appendChild(frag);
|
||||
if (i < result.chapters.length) await new Promise(r => setTimeout(r, 0));
|
||||
}
|
||||
// Used to decide whole-percent vs one-decimal display further down —
|
||||
// see _formatBookPercent().
|
||||
_currentBookCharCount = contentEl.textContent.length;
|
||||
}
|
||||
|
||||
currentBookToc = toc;
|
||||
titleEl.textContent = title + (author ? ` — ${author}` : '');
|
||||
|
||||
currentBookId = bookId;
|
||||
|
||||
// Remember the book's real (PDF) or estimated (EPUB) page count in its encrypted meta so
|
||||
// the book list can show it; rewritten only when it changed.
|
||||
const bookPages = isPdfBook ? numPages : Math.max(1, Math.round(_currentBookCharCount / CHARS_PER_BOOK_PAGE));
|
||||
if (bookPages > 0 && cachedMeta.pages !== bookPages) {
|
||||
_updateBookMeta(bookId, {pages: bookPages}).catch(() => {});
|
||||
}
|
||||
|
||||
// Load bookmarks and highlights
|
||||
await Promise.all([
|
||||
loadBookmarks(bookId),
|
||||
loadHighlights(bookId),
|
||||
]);
|
||||
|
||||
// Apply reader settings (theme, font size, etc.)
|
||||
applyReaderSettings(isPdfBook);
|
||||
|
||||
// If this book already has notes/highlights, surface them right away
|
||||
// instead of making the reader hide them behind a manual toggle.
|
||||
// Not on phones: there the margin covers the text; it stays one tap away (✎).
|
||||
if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen && !_isMobileReader()) {
|
||||
toggleAnnotationsMargin();
|
||||
}
|
||||
|
||||
// Touch: swipe (paginated) + pinch-to-zoom
|
||||
contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true});
|
||||
contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false});
|
||||
contentEl.addEventListener('touchend', _pdfTouchEnd, {passive: true});
|
||||
contentEl.addEventListener('touchcancel', _pdfTouchEnd, {passive: true});
|
||||
|
||||
// Set up progress input
|
||||
const progressInput = $('reader-progress-input');
|
||||
const progressSuffix = $('reader-progress-suffix');
|
||||
const isPdf = isPdfBook;
|
||||
|
||||
if (progressInput) {
|
||||
progressInput.style.display = '';
|
||||
if (isPdf) {
|
||||
progressInput.value = 1;
|
||||
if (progressSuffix) progressSuffix.textContent = `/ ${numPages}`;
|
||||
} else {
|
||||
progressInput.value = _formatBookPercent(0);
|
||||
if (progressSuffix) progressSuffix.textContent = '%';
|
||||
}
|
||||
|
||||
progressInput.addEventListener('change', function () {
|
||||
_suppressScrollJumpDetect();
|
||||
if (isPdf) {
|
||||
const page = Math.min(numPages, Math.max(1, parseInt(this.value, 10) || 1));
|
||||
this.value = page;
|
||||
const target = contentEl.querySelector(`#pdf-page-${page}`);
|
||||
if (target) {
|
||||
const top = target.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||||
contentEl.scrollBy({top: top - 8, behavior: 'smooth'});
|
||||
}
|
||||
} else {
|
||||
const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value)));
|
||||
this.value = _formatBookPercent(pct / 100);
|
||||
restoreFromAnchor(contentEl, _anchorForFraction(contentEl, pct / 100));
|
||||
}
|
||||
});
|
||||
|
||||
progressInput.addEventListener('click', function () { this.select(); });
|
||||
}
|
||||
|
||||
// Restore scroll position — must happen BEFORE auto-save timer is started
|
||||
_suppressScrollJumpDetect();
|
||||
let openedProgress = null;
|
||||
try {
|
||||
let fraction = 0, anchor = '';
|
||||
let bookData = null;
|
||||
try {
|
||||
const progressRes = await fetch('/books/', {cache: 'no-store'});
|
||||
_noteServerNowHeader(progressRes);
|
||||
const allBooks = await progressRes.json();
|
||||
bookData = allBooks.find(b => b.id === bookId) || null;
|
||||
} catch {
|
||||
// offline — use cached meta
|
||||
const cached = await _loadCachedBookMeta();
|
||||
bookData = cached.find(b => b.id === bookId) || null;
|
||||
}
|
||||
// The newer of the server's place and an own one it doesn't have yet.
|
||||
if (bookData) bookData = _withPendingProgress(bookData);
|
||||
fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
|
||||
anchor = bookData ? (bookData.position_anchor || '') : '';
|
||||
openedProgress = bookData;
|
||||
_currentPositionAnchor = anchor;
|
||||
if (isPdf && readerSettings.pdfPaginated) {
|
||||
if (fraction > 0 && pdfTotalPages > 1)
|
||||
pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1);
|
||||
enterPdfPaginatedMode();
|
||||
} else if (isPdf) {
|
||||
// PDF scroll mode
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
if (fraction > 0)
|
||||
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
|
||||
} else {
|
||||
// EPUB — wait for images so scrollHeight is final
|
||||
const imgs = Array.from(contentEl.querySelectorAll('img'));
|
||||
if (imgs.length) {
|
||||
await Promise.all(imgs.map(img =>
|
||||
img.complete ? Promise.resolve()
|
||||
: new Promise(r => { img.onload = r; img.onerror = r; })
|
||||
));
|
||||
}
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) {
|
||||
restoreFromAnchor(contentEl, _anchorForFraction(contentEl, fraction));
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// From here on, what the reader does is reading. The place shown has the
|
||||
// time it was read at (elsewhere, or here earlier): nothing new to send yet.
|
||||
if (_readPos && _readPos.bookId === bookId && currentBookId === bookId) {
|
||||
const shownAnchor = isPdf ? '' : getPositionAnchor(contentEl);
|
||||
const readAtServer = openedProgress && openedProgress.read_at != null ? openedProgress.read_at : null;
|
||||
_readPos.key = _positionKey(shownAnchor, openedProgress ? (openedProgress.scroll_fraction || 0) : 0);
|
||||
_readPos.readAt = readAtServer == null ? 0 : readAtServer - _serverOffset;
|
||||
_readPos.ready = true;
|
||||
_offerFurthestPosition(openedProgress);
|
||||
}
|
||||
|
||||
// Prime the "settled" baseline to wherever we just restored to, rather
|
||||
// than waiting to lazily pick up whatever scroll event happens to fire
|
||||
// first — that could otherwise be a later, unrelated layout correction.
|
||||
_scrollRestTop = contentEl.scrollTop;
|
||||
|
||||
// Update progress input on scroll
|
||||
contentEl.addEventListener('scroll', () => {
|
||||
if (!progressInput) return;
|
||||
if (isPdf) {
|
||||
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
|
||||
const cTop = contentEl.getBoundingClientRect().top;
|
||||
let currentPage = 1;
|
||||
for (const w of wrappers) {
|
||||
if (w.getBoundingClientRect().bottom > cTop + 20) {
|
||||
currentPage = parseInt(w.id.replace('pdf-page-', ''), 10) || 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
progressInput.value = currentPage;
|
||||
}
|
||||
// EPUB: shown from the position anchor, see the anchor tracking below.
|
||||
});
|
||||
|
||||
// Auto-save progress every 10s and on scroll (debounced 2s)
|
||||
// Started AFTER restore so an early visibilitychange can't overwrite with position 0
|
||||
readerScrollSaveTimer = setInterval(saveReaderProgress, 10000);
|
||||
let _scrollDebounce = null;
|
||||
contentEl.addEventListener('scroll', () => {
|
||||
clearTimeout(_scrollDebounce);
|
||||
_scrollDebounce = setTimeout(saveReaderProgress, 2000);
|
||||
}, {passive: true});
|
||||
|
||||
// Keep _currentPositionAnchor fresh independent of the slower save debounce
|
||||
// above: the resize observer below (or an immersive-bars toggle right after
|
||||
// scrolling) would otherwise restore to a stale, pre-scroll anchor.
|
||||
if (!isPdf) {
|
||||
let _anchorTrackDebounce = null;
|
||||
contentEl.addEventListener('scroll', () => {
|
||||
clearTimeout(_anchorTrackDebounce);
|
||||
_anchorTrackDebounce = setTimeout(() => {
|
||||
_currentPositionAnchor = getPositionAnchor(contentEl);
|
||||
const f = _fractionFromAnchor(contentEl, _currentPositionAnchor);
|
||||
_noteReadingPosition(_currentPositionAnchor, f === null ? 0 : f);
|
||||
if (progressInput && f !== null && document.activeElement !== progressInput) {
|
||||
progressInput.value = _formatBookPercent(f);
|
||||
}
|
||||
}, 150);
|
||||
}, {passive: true});
|
||||
}
|
||||
|
||||
// Restore anchor on viewport resize (e.g. screen rotation, font zoom)
|
||||
if (!isPdf) {
|
||||
_resizeObserver = new ResizeObserver(() => {
|
||||
if (_currentPositionAnchor) {
|
||||
_suppressScrollJumpDetect();
|
||||
requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor));
|
||||
}
|
||||
_repositionMarginMarkers();
|
||||
});
|
||||
_resizeObserver.observe(contentEl);
|
||||
}
|
||||
|
||||
// Offer to jump back after a sudden, large scroll (accidental fling,
|
||||
// fat-fingered 'g'/'G' keybind, etc.) — see _checkScrollJump above.
|
||||
_scrollRestTop = null;
|
||||
let _scrollJumpRaf = null;
|
||||
contentEl.addEventListener('scroll', () => {
|
||||
if (_scrollJumpRaf) return;
|
||||
_scrollJumpRaf = requestAnimationFrame(() => {
|
||||
_scrollJumpRaf = null;
|
||||
_checkScrollJump(contentEl);
|
||||
_repositionMarginMarkers();
|
||||
});
|
||||
}, {passive: true});
|
||||
|
||||
enterReaderImmersiveMode();
|
||||
|
||||
} catch (e) {
|
||||
console.error('openBook failed:', e);
|
||||
overlay.style.display = 'none';
|
||||
markBookBroken(bookId);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportEncKey() {
|
||||
const statusEl = $('book-key-status');
|
||||
try {
|
||||
const key = await getOrCreateEncKey();
|
||||
const raw = await crypto.subtle.exportKey('raw', key);
|
||||
const b64 = bytesToBase64(raw);
|
||||
await navigator.clipboard.writeText(b64);
|
||||
if (statusEl) statusEl.textContent = '✓ Schlüssel in die Zwischenablage kopiert';
|
||||
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000);
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = 'Export fehlgeschlagen: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function showImportKey() {
|
||||
const body = $('sidebar-body');
|
||||
openSidebar('Schlüssel importieren', `
|
||||
<p class="muted">Füge den Schlüssel ein, den du im anderen Browser exportiert hast:</p>
|
||||
<textarea id="import-key-input" class="search-input" rows="3" style="width:100%;resize:none;font-family:monospace;font-size:0.75rem;"></textarea>
|
||||
<button class="btn" style="margin-top:8px;" data-import-key-apply>Übernehmen</button>
|
||||
<p class="muted" style="margin-top:8px;">Das ersetzt den Schlüssel in diesem Browser. Hier hochgeladene Bücher sind erst wieder lesbar, wenn du den Schlüssel zurück abgleichst.</p>
|
||||
`);
|
||||
body.addEventListener('click', async function _importClick(e) {
|
||||
if (!e.target.closest('[data-import-key-apply]')) return;
|
||||
body.removeEventListener('click', _importClick);
|
||||
const b64 = (body.querySelector('#import-key-input')?.value || '').trim();
|
||||
const statusEl = $('book-key-status');
|
||||
try {
|
||||
const raw = base64ToBytes(b64);
|
||||
const importedKey = await crypto.subtle.importKey('raw', raw, {name: 'AES-GCM', length: 256}, true, ['encrypt', 'decrypt']);
|
||||
const re_exported = await crypto.subtle.exportKey('raw', importedKey);
|
||||
localStorage.setItem(`diora_enc_key_${window.USER_ID}`, bytesToBase64(re_exported));
|
||||
closeSidebar();
|
||||
if (statusEl) statusEl.textContent = '✓ Schlüssel importiert – Bücher werden neu geladen …';
|
||||
await loadBookList();
|
||||
if (statusEl) setTimeout(() => { statusEl.textContent = ''; }, 3000);
|
||||
} catch (e) {
|
||||
if ($('book-key-status')) $('book-key-status').textContent = 'Import fehlgeschlagen: ungültiger Schlüssel';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteBook(bookId) {
|
||||
if (!await customConfirm('Dieses Buch löschen? Das lässt sich nicht rückgängig machen.', { danger: true })) return;
|
||||
try {
|
||||
const res = await fetch(`/books/${bookId}/delete/`, {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': getCsrfToken()},
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) loadBookList();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Opened somewhere before the furthest place reached: offer it.
|
||||
function _offerFurthestPosition(p) {
|
||||
if (!p) return;
|
||||
const furthest = p.furthest_fraction || 0;
|
||||
if (furthest - (p.scroll_fraction || 0) < 0.02 || furthest >= 0.999) return;
|
||||
const pos = _readPos;
|
||||
_readerToast(`Weiteste Stelle: ${_formatBookPercent(furthest)} %`, 'Dorthin', () => {
|
||||
if (_readPos !== pos) return;
|
||||
_noteReaderInteraction();
|
||||
_jumpToPosition(p.furthest_anchor || '', furthest);
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
async function saveReaderProgress(force = false) {
|
||||
if (!currentBookId) return;
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
let fraction;
|
||||
if (readerSettings.pdfPaginated && currentPdfDoc && pdfTotalPages > 1) {
|
||||
fraction = (pdfCurrentPage - 1) / (pdfTotalPages - 1);
|
||||
} else {
|
||||
fraction = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
|
||||
}
|
||||
|
||||
// Chapter wrappers carry unique ids; seeing one twice means the book was
|
||||
// rendered twice. Positions from that DOM are wrong — don't store them.
|
||||
if (!currentPdfDoc && contentEl.querySelectorAll('#epub-chapter-0').length > 1) {
|
||||
console.warn('Book rendered twice, progress not saved');
|
||||
return;
|
||||
}
|
||||
|
||||
// Not restored yet (or another book): what's shown isn't a place read.
|
||||
const pos = _readPos;
|
||||
if (!pos || !pos.ready || pos.bookId !== currentBookId) return;
|
||||
// A jump that's only a look: the place before it stays.
|
||||
if (pos.hold && !force) return;
|
||||
|
||||
let anchor = '';
|
||||
if (!currentPdfDoc) {
|
||||
anchor = getPositionAnchor(contentEl);
|
||||
_currentPositionAnchor = anchor;
|
||||
// EPUB: share of the text, the same on every device and layout.
|
||||
const textFraction = _fractionFromAnchor(contentEl, anchor);
|
||||
if (textFraction !== null) fraction = textFraction;
|
||||
}
|
||||
fraction = Math.min(1.0, Math.max(0.0, fraction));
|
||||
|
||||
_noteReadingPosition(anchor, fraction);
|
||||
if (force) {
|
||||
pos.readAt = Date.now();
|
||||
pos.moved = true;
|
||||
}
|
||||
// Nothing read here since opening: the server has this place already.
|
||||
if (!pos.moved) return;
|
||||
|
||||
const entry = {book: currentBookId, fraction, anchor, read_at: pos.readAt, device: _progressDevice(), ...(force && {force: true})};
|
||||
const sig = `${entry.book}|${anchor}|${fraction.toFixed(5)}|${entry.read_at}`;
|
||||
_lastProgressBeacon = entry;
|
||||
if (!force && sig === pos.sentSig) return; // unchanged since the last save
|
||||
pos.sentSig = sig;
|
||||
_progressOutboxPut(entry);
|
||||
_updateCachedBookProgress(currentBookId, fraction, anchor, force, pos.readAt);
|
||||
const sent = await _sendProgress(entry);
|
||||
if (!sent) pos.sentSig = null; // try again with the next save
|
||||
if (sent && force) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'reader-toast';
|
||||
toast.textContent = 'Position gesetzt';
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 1800);
|
||||
}
|
||||
}
|
||||
|
||||
function closeReader() {
|
||||
exitReaderImmersiveMode();
|
||||
stopReadAloud();
|
||||
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none
|
||||
saveReaderProgress();
|
||||
_readPos = null; // nothing more is saved for this book
|
||||
if (bookmarksDirty) saveBookmarks();
|
||||
if (highlightsDirty) saveHighlights();
|
||||
|
||||
const overlay = $('reader-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
if (readerScrollSaveTimer) {
|
||||
clearInterval(readerScrollSaveTimer);
|
||||
readerScrollSaveTimer = null;
|
||||
}
|
||||
if (_resizeObserver) {
|
||||
_resizeObserver.disconnect();
|
||||
_resizeObserver = null;
|
||||
}
|
||||
_currentPositionAnchor = '';
|
||||
|
||||
// Close the margin panel
|
||||
readerAnnotationsMarginOpen = false;
|
||||
const marginEl = $('reader-margin');
|
||||
if (marginEl) marginEl.classList.remove('open');
|
||||
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);
|
||||
}
|
||||
327
static/js/app/64-reader-settings.js
Normal file
327
static/js/app/64-reader-settings.js
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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: 'app', 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];
|
||||
}
|
||||
}
|
||||
// Before v3 the reader's theme was always stored, "dark" by default: that now means "Wie die App".
|
||||
if ((saved.v || 0) < 3 && saved.theme === 'dark') delete saved.theme;
|
||||
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: 3 }));
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
const readerTheme = readerSettings.theme === 'app'
|
||||
? (document.documentElement.getAttribute('data-theme') === 'light' ? 'bright' : 'dark')
|
||||
: readerSettings.theme;
|
||||
if (readerTheme === 'sepia') overlay.classList.add('reader-theme-sepia');
|
||||
else if (readerTheme === '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 === 'app' ? 'active' : ''}" data-rs-theme="app">Wie die App</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();
|
||||
}
|
||||
212
static/js/app/66-pdf-paginated.js
Normal file
212
static/js/app/66-pdf-paginated.js
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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};
|
||||
}
|
||||
166
static/js/app/68-bookmarks.js
Normal file
166
static/js/app/68-bookmarks.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
170
static/js/app/70-search.js
Normal file
170
static/js/app/70-search.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
424
static/js/app/72-highlights.js
Normal file
424
static/js/app/72-highlights.js
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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} = _epubBlocks(contentEl);
|
||||
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;
|
||||
}
|
||||
}
|
||||
408
static/js/app/74-margin.js
Normal file
408
static/js/app/74-margin.js
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
// Where each highlight sits in the book's scroll coordinates. Finding a range
|
||||
// and measuring it is the expensive part, and scrolling doesn't change it:
|
||||
// kept until the layout does (content, width, height) or the anchor changes.
|
||||
let _marginPosCache = null;
|
||||
const _marginCardHeights = new Map(); // `${stripWidth}|${note}` (or 'dot') → px
|
||||
|
||||
function _marginDocY(h, contentEl) {
|
||||
const {blocks} = _epubBlocks(contentEl);
|
||||
const key = `${contentEl.clientWidth}x${contentEl.scrollHeight}`;
|
||||
if (!_marginPosCache || _marginPosCache.blocks !== blocks || _marginPosCache.key !== key) {
|
||||
_marginPosCache = {blocks, key, byId: new Map()};
|
||||
}
|
||||
const hit = _marginPosCache.byId.get(h.id);
|
||||
if (hit && hit.anchor === h.anchor) return hit.y;
|
||||
let y = null;
|
||||
try {
|
||||
const range = resolveAnchorRange(h);
|
||||
const rect = range && range.getBoundingClientRect();
|
||||
if (rect && (rect.width || rect.height)) {
|
||||
y = rect.top + rect.height / 2 - contentEl.getBoundingClientRect().top + contentEl.scrollTop;
|
||||
}
|
||||
} catch (e) {}
|
||||
_marginPosCache.byId.set(h.id, {anchor: h.anchor, y});
|
||||
return y;
|
||||
}
|
||||
|
||||
// The cards live on one layer in the book's coordinates, built when the
|
||||
// highlights or the layout change; scrolling only moves the layer (a transform,
|
||||
// no layout). Rebuilding and measuring the cards on every scroll frame made a
|
||||
// long book with the margin open stutter.
|
||||
let _marginLayerKey = null;
|
||||
|
||||
function _repositionMarginMarkers() {
|
||||
const markersEl = $('reader-margin-markers');
|
||||
if (!markersEl) return;
|
||||
if (!readerAnnotationsMarginOpen || currentPdfDoc) {
|
||||
markersEl.innerHTML = '';
|
||||
_marginLayerKey = null;
|
||||
return;
|
||||
}
|
||||
if (markersEl.querySelector('.margin-note-editor')) return; // don't blow away an active inline edit
|
||||
const contentEl = $('reader-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
const {blocks} = _epubBlocks(contentEl);
|
||||
const areaRect = markersEl.getBoundingClientRect();
|
||||
const width = Math.round(areaRect.width);
|
||||
const key = `${width}x${contentEl.clientWidth}x${contentEl.scrollHeight}|` +
|
||||
currentHighlights.map(h => `${h.id}:${h.color || ''}:${h.note || ''}`).join('|');
|
||||
let layer = markersEl.querySelector('.margin-layer');
|
||||
if (!layer || key !== _marginLayerKey || !_marginPosCache || _marginPosCache.blocks !== blocks) {
|
||||
markersEl.innerHTML = '';
|
||||
layer = document.createElement('div');
|
||||
layer.className = 'margin-layer';
|
||||
markersEl.appendChild(layer);
|
||||
_renderMarginLayer(layer, contentEl, width);
|
||||
_marginLayerKey = key;
|
||||
}
|
||||
// Book coordinates → this strip's.
|
||||
const shift = contentEl.getBoundingClientRect().top - contentEl.scrollTop - areaRect.top;
|
||||
layer.style.transform = `translateY(${Math.round(shift)}px)`;
|
||||
}
|
||||
|
||||
// Renders each highlight/note at its text position, in book coordinates.
|
||||
// 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 a small colour dot. Cards are stacked top to
|
||||
// bottom so overlapping anchors don't overlap visually.
|
||||
function _renderMarginLayer(layer, contentEl, width) {
|
||||
const entries = [];
|
||||
for (const h of currentHighlights) {
|
||||
const y = _marginDocY(h, contentEl);
|
||||
if (y !== null) entries.push({h, y});
|
||||
}
|
||||
entries.sort((a, b) => a.y - b.y);
|
||||
|
||||
// All cards first, then their heights in one go (each measured right after
|
||||
// adding it, the browser laid out the whole book once per card). Heights are
|
||||
// kept per note text and strip width.
|
||||
const cards = entries.map(({h, y}) => {
|
||||
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;
|
||||
layer.appendChild(el);
|
||||
return {el, y, hasText, key: hasText ? `${width}|${h.note}` : 'dot'};
|
||||
});
|
||||
if (_marginCardHeights.size > 500) _marginCardHeights.clear();
|
||||
const unmeasured = cards.filter(c => !_marginCardHeights.has(c.key));
|
||||
for (const c of unmeasured) _marginCardHeights.set(c.key, c.el.getBoundingClientRect().height || (c.hasText ? 20 : 12));
|
||||
|
||||
let nextTop = -Infinity;
|
||||
const GAP = 4;
|
||||
for (const {el, y, hasText, key} of cards) {
|
||||
const top = Math.max(hasText ? y - 8 : y - 6, nextTop);
|
||||
el.style.top = top + 'px';
|
||||
nextTop = top + _marginCardHeights.get(key) + GAP;
|
||||
}
|
||||
}
|
||||
|
||||
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}"]`);
|
||||
// The card sits on the moving layer: where it shows in the strip right now.
|
||||
top = existingCard ? existingCard.getBoundingClientRect().top - markersEl.getBoundingClientRect().top : 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();
|
||||
_marginLayerKey = null; // the edited card was hidden: draw the layer anew
|
||||
_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();
|
||||
_marginLayerKey = null;
|
||||
_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;
|
||||
}
|
||||
}
|
||||
157
static/js/app/76-export.js
Normal file
157
static/js/app/76-export.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
}
|
||||
76
static/js/app/78-footnotes.js
Normal file
76
static/js/app/78-footnotes.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
}
|
||||
44
static/js/app/80-radio-sidebar.js
Normal file
44
static/js/app/80-radio-sidebar.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
150
static/js/app/99-init.js
Normal file
150
static/js/app/99-init.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
'use strict';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(); // sends the positions read offline first (the progress outbox)
|
||||
});
|
||||
|
||||
// Show banner immediately if already offline at startup
|
||||
if (!navigator.onLine) showOfflineBanner(true);
|
||||
|
||||
// Reading position: positions reached by the reader's own doing count (see _noteReadingPosition).
|
||||
{
|
||||
const content = $('reader-content');
|
||||
if (content) {
|
||||
for (const type of ['wheel', 'touchstart', 'mousedown']) {
|
||||
content.addEventListener(type, _noteReaderInteraction, {passive: true, capture: true});
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', () => { if (currentBookId) _noteReaderInteraction(); }, true);
|
||||
if (IS_AUTHENTICATED) _flushProgressOutbox();
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
})();
|
||||
|
|
@ -2,10 +2,41 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v105';
|
||||
const CACHE = 'diora-v106';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app.js',
|
||||
'/static/js/app/01-core.js',
|
||||
'/static/js/app/10-radio.js',
|
||||
'/static/js/app/14-focus.js',
|
||||
'/static/js/app/16-stations.js',
|
||||
'/static/js/app/20-tabs.js',
|
||||
'/static/js/app/24-ambient.js',
|
||||
'/static/js/app/26-sidebar.js',
|
||||
'/static/js/app/28-serviceworker.js',
|
||||
'/static/js/app/29-m3u.js',
|
||||
'/static/js/app/30-contrast.js',
|
||||
'/static/js/app/32-crypto.js',
|
||||
'/static/js/app/34-wallpaper.js',
|
||||
'/static/js/app/40-epub.js',
|
||||
'/static/js/app/44-books.js',
|
||||
'/static/js/app/46-readpos.js',
|
||||
'/static/js/app/48-book-cache.js',
|
||||
'/static/js/app/50-reader-state.js',
|
||||
'/static/js/app/52-book-list.js',
|
||||
'/static/js/app/56-cloud-import.js',
|
||||
'/static/js/app/58-pdf.js',
|
||||
'/static/js/app/60-tts.js',
|
||||
'/static/js/app/62-reader.js',
|
||||
'/static/js/app/64-reader-settings.js',
|
||||
'/static/js/app/66-pdf-paginated.js',
|
||||
'/static/js/app/68-bookmarks.js',
|
||||
'/static/js/app/70-search.js',
|
||||
'/static/js/app/72-highlights.js',
|
||||
'/static/js/app/74-margin.js',
|
||||
'/static/js/app/76-export.js',
|
||||
'/static/js/app/78-footnotes.js',
|
||||
'/static/js/app/80-radio-sidebar.js',
|
||||
'/static/js/app/99-init.js',
|
||||
'/static/js/podcasts-web.js',
|
||||
'/static/js/home.js',
|
||||
'/static/js/feeds.js',
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@
|
|||
|
||||
<!-- ===== TABS ===== -->
|
||||
<div class="tabs" id="tabs">
|
||||
{# Order = app.js TOP_TABS. Home needs an account: hidden, but kept in place for the order. #}
|
||||
{# Order = TOP_TABS (static/js/app/20-tabs.js). Home needs an account: hidden, but kept in place for the order. #}
|
||||
<button class="tab-btn" onclick="showTab('home')"{% if not user.is_authenticated %} style="display:none;"{% endif %}>Home</button>
|
||||
<button class="tab-btn active" onclick="showTab('radio')">Radio</button>
|
||||
<button class="tab-btn" onclick="showTab('focus')">Fokus</button>
|
||||
|
|
@ -347,7 +347,39 @@
|
|||
window.USER_ID = {{ user.id|default:"null" }};
|
||||
let USER_FOCUS_STATION = {{ focus_station_json|safe }};
|
||||
</script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
{# app.js, split by area (docs/refactoring.md): classic scripts sharing one global scope, in this order. #}
|
||||
<script src="/static/js/app/01-core.js"></script>
|
||||
<script src="/static/js/app/10-radio.js"></script>
|
||||
<script src="/static/js/app/14-focus.js"></script>
|
||||
<script src="/static/js/app/16-stations.js"></script>
|
||||
<script src="/static/js/app/20-tabs.js"></script>
|
||||
<script src="/static/js/app/24-ambient.js"></script>
|
||||
<script src="/static/js/app/26-sidebar.js"></script>
|
||||
<script src="/static/js/app/28-serviceworker.js"></script>
|
||||
<script src="/static/js/app/29-m3u.js"></script>
|
||||
<script src="/static/js/app/30-contrast.js"></script>
|
||||
<script src="/static/js/app/32-crypto.js"></script>
|
||||
<script src="/static/js/app/34-wallpaper.js"></script>
|
||||
<script src="/static/js/app/40-epub.js"></script>
|
||||
<script src="/static/js/app/44-books.js"></script>
|
||||
<script src="/static/js/app/46-readpos.js"></script>
|
||||
<script src="/static/js/app/48-book-cache.js"></script>
|
||||
<script src="/static/js/app/50-reader-state.js"></script>
|
||||
<script src="/static/js/app/52-book-list.js"></script>
|
||||
<script src="/static/js/app/56-cloud-import.js"></script>
|
||||
<script src="/static/js/app/58-pdf.js"></script>
|
||||
<script src="/static/js/app/60-tts.js"></script>
|
||||
<script src="/static/js/app/62-reader.js"></script>
|
||||
<script src="/static/js/app/64-reader-settings.js"></script>
|
||||
<script src="/static/js/app/66-pdf-paginated.js"></script>
|
||||
<script src="/static/js/app/68-bookmarks.js"></script>
|
||||
<script src="/static/js/app/70-search.js"></script>
|
||||
<script src="/static/js/app/72-highlights.js"></script>
|
||||
<script src="/static/js/app/74-margin.js"></script>
|
||||
<script src="/static/js/app/76-export.js"></script>
|
||||
<script src="/static/js/app/78-footnotes.js"></script>
|
||||
<script src="/static/js/app/80-radio-sidebar.js"></script>
|
||||
<script src="/static/js/app/99-init.js"></script>
|
||||
<script src="/static/js/podcasts-web.js"></script>
|
||||
<script src="/static/js/home.js"></script>
|
||||
<script src="/static/js/feeds.js"></script>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue