Reader: Prozentanzeige mit Nachkommastelle bei langen Büchern (SW v27)
All checks were successful
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 15s

Bei sehr langen Büchern (ab ca. 1 Mio. Zeichen, entspricht etwa 600-700
Druckseiten) bewegt sich die ganzzahlige Prozentanzeige beim Lesen kaum noch
— eine Nachkommastelle macht sie wieder aussagekräftig. Betrifft die
Fortschrittsanzeige im Reader-Header sowie die "≈X% im Buch"-Koordinate bei
Annotationen-Export und -Verzeichnis.

Formatierung über toLocaleString() (Komma bei de, Punkt bei en, etc.). Das
Fortschrittsfeld ist dafür von <input type="number"> auf type="text" mit
inputmode="decimal" umgestellt — number-Inputs erzwingen intern immer einen
Punkt, unabhängig von der Locale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-08-04 13:51:05 +02:00
parent 7bfce034ba
commit 9648730be6
3 changed files with 32 additions and 12 deletions

View file

@ -2769,8 +2769,28 @@ let currentImageMap = {};
let readerScrollSaveTimer = null; let readerScrollSaveTimer = null;
let _resizeObserver = null; let _resizeObserver = null;
let _currentPositionAnchor = ''; let _currentPositionAnchor = '';
let _currentBookCharCount = 0;
const bookMetaCache = {}; // id → {title, author, type} 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 // Reader "scrolled too far" safety net — offers a toast to jump back after a
// sudden, large scroll (accidental fling, fat-fingered keybind, etc.). Only // sudden, large scroll (accidental fling, fat-fingered keybind, etc.). Only
@ -3722,6 +3742,9 @@ async function openBook(bookId) {
contentEl.appendChild(frag); contentEl.appendChild(frag);
await new Promise(r => requestAnimationFrame(r)); await new Promise(r => requestAnimationFrame(r));
} }
// Used to decide whole-percent vs one-decimal display further down —
// see _formatBookPercent().
_currentBookCharCount = contentEl.textContent.length;
} }
currentBookToc = toc; currentBookToc = toc;
@ -3752,14 +3775,10 @@ async function openBook(bookId) {
if (progressInput) { if (progressInput) {
progressInput.style.display = ''; progressInput.style.display = '';
if (isPdf) { if (isPdf) {
progressInput.min = 1;
progressInput.max = numPages;
progressInput.value = 1; progressInput.value = 1;
if (progressSuffix) progressSuffix.textContent = `/ ${numPages}`; if (progressSuffix) progressSuffix.textContent = `/ ${numPages}`;
} else { } else {
progressInput.min = 0; progressInput.value = _formatBookPercent(0);
progressInput.max = 100;
progressInput.value = 0;
if (progressSuffix) progressSuffix.textContent = '%'; if (progressSuffix) progressSuffix.textContent = '%';
} }
@ -3774,8 +3793,8 @@ async function openBook(bookId) {
contentEl.scrollBy({top: top - 8, behavior: 'smooth'}); contentEl.scrollBy({top: top - 8, behavior: 'smooth'});
} }
} else { } else {
const pct = Math.min(100, Math.max(0, parseInt(this.value, 10) || 0)); const pct = Math.min(100, Math.max(0, _parseLocalePercent(this.value)));
this.value = pct; this.value = _formatBookPercent(pct / 100);
contentEl.scrollTop = (pct / 100) * contentEl.scrollHeight; contentEl.scrollTop = (pct / 100) * contentEl.scrollHeight;
} }
}); });
@ -3847,7 +3866,7 @@ async function openBook(bookId) {
progressInput.value = currentPage; progressInput.value = currentPage;
} else { } else {
const f = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1); const f = contentEl.scrollTop / (contentEl.scrollHeight - contentEl.clientHeight || 1);
progressInput.value = Math.round(f * 100); progressInput.value = _formatBookPercent(f);
} }
}); });
@ -4061,6 +4080,7 @@ function closeReader() {
// Reset all state // Reset all state
currentBookId = null; currentBookId = null;
currentBookToc = []; currentBookToc = [];
_currentBookCharCount = 0;
currentPdfDoc = null; currentPdfDoc = null;
currentPdfBuffer = null; currentPdfBuffer = null;
currentBookmarks = []; currentBookmarks = [];
@ -5599,7 +5619,7 @@ function exportAnnotations() {
lastChapter = chapterTitle; lastChapter = chapterTitle;
} }
const pct = _percentFromRange(range); const pct = _percentFromRange(range);
const coord = pct !== null ? ` (≈${Math.round(pct)}% im Buch)` : ''; const coord = pct !== null ? ` (≈${_formatBookPercent(pct / 100)}% im Buch)` : '';
if (h.type === 'note') { if (h.type === 'note') {
lines.push(`[Note]${coord} near: "${(h.anchor?.quote || '').trim()}"`); lines.push(`[Note]${coord} near: "${(h.anchor?.quote || '').trim()}"`);
if (h.note) lines.push(` ${h.note}`); if (h.note) lines.push(` ${h.note}`);
@ -5635,7 +5655,7 @@ function openAnnotationsSidebar() {
let html = '<ul style="list-style:none;padding:0;">'; let html = '<ul style="list-style:none;padding:0;">';
for (const {h, range} of resolved) { for (const {h, range} of resolved) {
const pct = _percentFromRange(range); const pct = _percentFromRange(range);
const pctLabel = pct !== null ? `${Math.round(pct)}%` : ''; const pctLabel = pct !== null ? `${_formatBookPercent(pct / 100)}%` : '';
const kind = h.type === 'note' ? 'Note' : `Highlight${h.color ? ' · ' + h.color : ''}`; const kind = h.type === 'note' ? 'Note' : `Highlight${h.color ? ' · ' + h.color : ''}`;
const preview = h.note || h.anchor?.quote || '(no text)'; const preview = h.note || h.anchor?.quote || '(no text)';
html += `<li class="bookmark-entry"> html += `<li class="bookmark-entry">

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use. * diora service worker caches the app shell for offline use.
*/ */
const CACHE = 'diora-v26'; const CACHE = 'diora-v27';
const PODCAST_CACHE = 'diora-podcast-v1'; const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',

View file

@ -335,7 +335,7 @@
<span id="reader-title" class="reader-title"></span> <span id="reader-title" class="reader-title"></span>
<div class="reader-header-actions"> <div class="reader-header-actions">
<span class="reader-progress-wrap"> <span class="reader-progress-wrap">
<input type="number" id="reader-progress-input" class="volume-num" min="0" max="100" value="0" style="display:none;"> <input type="text" inputmode="decimal" id="reader-progress-input" class="volume-num" value="0" style="display:none;">
<span id="reader-progress-suffix" class="muted"></span> <span id="reader-progress-suffix" class="muted"></span>
</span> </span>
<button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search"></button> <button class="btn-icon" id="reader-search-btn" onclick="toggleReaderSearch()" title="Search"></button>