diora-web/static/js/app/64-reader-settings.js
mrwnslz a7d4d92f9e
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 8m19s
Test / e2e (push) Successful in 1m52s
Reader: offene Notizleiste schiebt den Text nicht mehr aus der Mitte; sw v107
Die Leiste nimmt ihre Breite links weg; dieselbe Breite kommt rechts als
Abstand dazu, soweit Platz ist (die Spalte wird dafür nie schmaler). Beides
gleitet gleichzeitig, der Text bleibt stehen. Neu berechnet beim Öffnen und
Schließen der Leiste, bei Fenstergröße und Zeilenlänge/Schrift.

e2e: Test dafür (breit: Spalte bleibt, schmal: weicht aus, bleibt gleich
breit, liegt nicht unter der Leiste); Service Worker in den Tests aus (sein
Neuladen störte mitten im Test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:43:03 +02:00

329 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use strict';
// ---------------------------------------------------------------------------
// 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();
// Line length or font changed: the column's place next to an open margin too.
if (readerAnnotationsMarginOpen) _centerReaderColumn();
}
// 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();
}