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>
329 lines
15 KiB
JavaScript
329 lines
15 KiB
JavaScript
'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();
|
||
}
|