diora-web/static/js/app/64-reader-settings.js
2026-11-04 15:37:12 +01:00

425 lines
21 KiB
JavaScript
Raw Permalink 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;
}
// Where the settings shown are kept: the book's id, or 'article' (all articles, 65-article.js).
let _readerSettingsKey = null;
function loadReaderSettings(bookId) {
_readerSettingsKey = bookId;
// Reset to defaults, then apply per-book overrides
Object.assign(readerSettings, { theme: 'app', fontFamily: 'serif', noBold: false,
pdfZoom: 100, pdfInverted: false, pdfPageColors: 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() {
const key = currentBookId || _readerSettingsKey;
if (key) {
localStorage.setItem(`diora_reader_settings_${key}`, 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');
// Leserfarben: another theme changes the filter's colours; switching it on or off draws
// the pages anew (with or without their pictures laid over them).
if (isPdf && currentPdfDoc) {
_pdfColorFilter();
if (_pdfView && _pdfView.colorsKey !== _pdfColorsKey()) reRenderPdf();
}
// 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;
}
// "Scrollen | Seiten" and, in pages, whether the notes stand at the page's foot
// and whether two pages lie side by side.
// The apps' e-reader mode always reads in pages: no choice there.
function _readerModeButtons() {
if (!window.DioraPages || DioraPages.isForced()) return '';
const pages = DioraPages.isOn();
return `
<button class="btn btn-sm ${pages ? '' : 'active'}" data-rs-mode="scroll">Scrollen</button>
<button class="btn btn-sm ${pages ? 'active' : ''}" data-rs-mode="pages">Seiten</button>
<button class="btn btn-sm ${DioraPages.pageNotes() ? 'active' : ''}" id="rs-page-notes" style="${pages ? '' : 'display:none'}"
title="Die Fußnoten stehen unten auf der Seite, auf der auf sie verwiesen wird">Fußnoten unten</button>
<button class="btn btn-sm ${DioraPages.spread() ? 'active' : ''}" id="rs-page-spread" style="${pages ? '' : 'display:none'}"
title="Zwei Seiten nebeneinander wie ein aufgeschlagenes Buch — ab einer Breite von 900 Pixeln">Doppelseite</button>`;
}
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>
${_readerModeButtons()}
`;
} 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.pdfPaginated ? '' : 'active'}" data-rs-pdfmode="scroll">Scrollen</button>
<button class="btn btn-sm ${readerSettings.pdfPaginated ? 'active' : ''}" data-rs-pdfmode="pages">Seiten</button>
<button class="btn btn-sm ${!readerSettings.pdfInverted && !readerSettings.pdfPageColors ? 'active' : ''}" data-rs-pdfcolors="original"
title="Die Seiten wie gedruckt">Originalfarben</button>
<button class="btn btn-sm ${!readerSettings.pdfInverted && readerSettings.pdfPageColors ? 'active' : ''}" data-rs-pdfcolors="reader"
title="Text und Hintergrund in den Farben des Lesers (Dunkel, Sepia), Bilder bleiben, wie sie sind">Leserfarben</button>
<button class="btn btn-sm ${readerSettings.pdfInverted ? 'active' : ''}" data-rs-pdfcolors="invert"
title="Die ganze Seite umgekehrt, auch Bilder (Farben bleiben ungefähr)">Invertieren</button>
<button class="btn btn-sm ${readerSettings.pdfSpread ? 'active' : ''}" id="rs-spread"${readerSettings.pdfPaginated ? ' style="display:none"' : ''}>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();
});
});
// Scrolling or pages (63-pages.js): per device, for every book.
const syncMode = () => {
const pages = DioraPages.isOn();
panel.querySelectorAll('[data-rs-mode]').forEach(b => b.classList.toggle('active', (b.dataset.rsMode === 'pages') === pages));
const notes = panel.querySelector('#rs-page-notes');
if (notes) {
notes.style.display = pages ? '' : 'none';
notes.classList.toggle('active', DioraPages.pageNotes());
}
const spread = panel.querySelector('#rs-page-spread');
if (spread) {
spread.style.display = pages ? '' : 'none';
spread.classList.toggle('active', DioraPages.spread());
}
};
panel.querySelectorAll('[data-rs-mode]').forEach(btn => {
btn.addEventListener('click', () => { DioraPages.setOn(btn.dataset.rsMode === 'pages'); syncMode(); });
});
panel.querySelector('#rs-page-notes')?.addEventListener('click', () => { DioraPages.togglePageNotes(); syncMode(); });
panel.querySelector('#rs-page-spread')?.addEventListener('click', () => { DioraPages.setSpread(!DioraPages.spread()); syncMode(); });
} else {
const zoomRange = panel.querySelector('#rs-zoom');
// While the slider moves only the number follows; the pages are drawn anew once it rests.
let zoomTimer = 0;
zoomRange.addEventListener('input', () => {
const z = parseInt(zoomRange.value, 10);
const val = panel.querySelector('#rs-zoom-val');
if (val) val.textContent = z + '%';
clearTimeout(zoomTimer);
zoomTimer = setTimeout(() => applyPdfZoom(z), 250);
});
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)));
// The pages' colours: as printed, in the reader's colours, or inverted.
panel.querySelectorAll('[data-rs-pdfcolors]').forEach(btn => {
btn.addEventListener('click', () => {
const which = btn.dataset.rsPdfcolors;
readerSettings.pdfInverted = which === 'invert';
readerSettings.pdfPageColors = which === 'reader';
panel.querySelectorAll('[data-rs-pdfcolors]').forEach(b => b.classList.toggle('active', b === btn));
applyReaderSettings(true);
saveReaderSettings();
});
});
// Scrolling or page by page (phones and e-readers start with pages).
panel.querySelectorAll('[data-rs-pdfmode]').forEach(btn => {
btn.addEventListener('click', () => {
const pages = btn.dataset.rsPdfmode === 'pages';
if (pages === !!readerSettings.pdfPaginated || !currentPdfDoc) return;
const contentEl = $('reader-content');
const page = pages ? _pdfTopPage(contentEl) : pdfCurrentPage;
readerSettings.pdfPaginated = pages;
saveReaderSettings();
panel.querySelectorAll('[data-rs-pdfmode]').forEach(b => b.classList.toggle('active', b === btn));
const spread = panel.querySelector('#rs-spread');
if (spread) spread.style.display = pages ? 'none' : '';
if (pages) {
pdfCurrentPage = page;
// Spreads are a scrolling thing: one page at a time here.
if (readerSettings.pdfSpread) reRenderPdf(page).then(() => enterPdfPaginatedMode());
else enterPdfPaginatedMode();
} else {
exitPdfPaginatedMode(page);
}
});
});
panel.querySelector('#rs-spread').addEventListener('click', function () {
readerSettings.pdfSpread = !readerSettings.pdfSpread;
this.classList.toggle('active', readerSettings.pdfSpread);
saveReaderSettings();
reRenderPdf();
});
}
}
/**
* The PDF at another zoom (%). Scrolling, [anchor] (_pdfPointAt: where the fingers were) stays
* in place, else the middle of the screen; page by page, the page is fitted anew (_pdfFitPage).
*/
async function applyPdfZoom(newZoom, anchor) {
const old = readerSettings.pdfZoom;
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 (!currentPdfDoc) return;
if (readerSettings.pdfPaginated) {
await _pdfShowPage(pdfCurrentPage);
return;
}
if (newZoom === old) return;
if (!anchor) {
const r = $('reader-content').getBoundingClientRect();
anchor = _pdfPointAt(r.left + r.width / 2, r.top + r.height / 2);
}
if (!_pdfRezoomScroll(newZoom / old, anchor)) await reRenderPdf();
}
// The page at the top of the view (scrolling) — 58-pdf.js keeps the pages in order.
function _currentScrollPage(contentEl) {
return _pdfTopPage(contentEl);
}
/** Lay the open PDF out again (zoom, spreads, another width), at [pivot] or the page shown now. */
async function reRenderPdf(pivot) {
if (!currentPdfDoc) return;
const contentEl = $('reader-content');
if (!contentEl) return;
if (!pivot) pivot = readerSettings.pdfPaginated ? pdfCurrentPage : _pdfTopPage(contentEl);
await renderPdf(null, contentEl, undefined, pivot);
if (readerSettings.pdfPaginated) enterPdfPaginatedMode();
}