From 1af07c7952889f0fdb43c277a4f72a8fcd2b3e3e Mon Sep 17 00:00:00 2001 From: marwin Date: Wed, 1 Apr 2026 15:41:30 +0200 Subject: [PATCH] Add anchor-based reading position tracking Replaces scroll_fraction-only position tracking with element-based anchors ("{index}:{innerFraction}"). Position is now stable across font size changes and different screen sizes. A ResizeObserver restores the anchor on viewport/orientation changes. Falls back to scroll_fraction for books without a saved anchor. Co-Authored-By: Claude Sonnet 4.6 --- .../0003_ebookprogress_position_anchor.py | 16 +++ books/models.py | 1 + books/views.py | 12 ++- static/js/app.js | 100 +++++++++++++++--- 4 files changed, 110 insertions(+), 19 deletions(-) create mode 100644 books/migrations/0003_ebookprogress_position_anchor.py diff --git a/books/migrations/0003_ebookprogress_position_anchor.py b/books/migrations/0003_ebookprogress_position_anchor.py new file mode 100644 index 0000000..93f2a44 --- /dev/null +++ b/books/migrations/0003_ebookprogress_position_anchor.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('books', '0002_ebookbookmarks_ebookhighlights'), + ] + + operations = [ + migrations.AddField( + model_name='ebookprogress', + name='position_anchor', + field=models.CharField(blank=True, default='', max_length=30), + ), + ] diff --git a/books/models.py b/books/models.py index ef19afd..33b48a1 100644 --- a/books/models.py +++ b/books/models.py @@ -21,6 +21,7 @@ class EBookProgress(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress') book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress') scroll_fraction = models.FloatField(default=0.0) + position_anchor = models.CharField(max_length=30, blank=True, default='') updated_at = models.DateTimeField(auto_now=True) class Meta: diff --git a/books/views.py b/books/views.py index a068074..4f6e1e3 100644 --- a/books/views.py +++ b/books/views.py @@ -1,5 +1,6 @@ import base64 import json +import re from django.conf import settings from django.http import JsonResponse @@ -27,13 +28,14 @@ def book_list(request): b['uploaded_at'] = b['uploaded_at'].isoformat() # Include saved scroll_fraction for each book progress_map = { - p.book_id: (p.scroll_fraction, p.updated_at) + p.book_id: (p.scroll_fraction, p.updated_at, p.position_anchor) for p in EBookProgress.objects.filter(user=request.user) } for b in books: prog = progress_map.get(b['id']) b['scroll_fraction'] = prog[0] if prog else 0.0 b['last_read'] = prog[1].isoformat() if prog else None + b['position_anchor'] = prog[2] if prog else '' return JsonResponse(books, safe=False) @@ -127,12 +129,18 @@ def save_progress(request, pk): scroll_fraction = float(body.get('scroll_fraction', 0.0)) scroll_fraction = max(0.0, min(1.0, scroll_fraction)) + raw_anchor = body.get('position_anchor', '') + position_anchor = '' + if isinstance(raw_anchor, str) and re.fullmatch(r'\d{1,7}:\d(\.\d{1,6})?', raw_anchor): + position_anchor = raw_anchor + progress, _ = EBookProgress.objects.get_or_create( user=request.user, book=book, ) progress.scroll_fraction = scroll_fraction - progress.save(update_fields=['scroll_fraction', 'updated_at']) + progress.position_anchor = position_anchor + progress.save(update_fields=['scroll_fraction', 'position_anchor', 'updated_at']) return JsonResponse({'ok': True}) diff --git a/static/js/app.js b/static/js/app.js index 944c100..59a2f4d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2572,8 +2572,51 @@ let currentBookId = null; let currentBookToc = []; let currentImageMap = {}; let readerScrollSaveTimer = null; +let _resizeObserver = null; +let _currentPositionAnchor = ''; const bookMetaCache = {}; // id → {title, author, type} +const EPUB_BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, dt, dd, figcaption, div:not(:has(*))'; + +function getPositionAnchor(contentEl) { + const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); + if (!blocks.length) return ''; + const containerTop = contentEl.getBoundingClientRect().top; + const containerBottom = containerTop + contentEl.clientHeight; + let bestIndex = 0; + let bestDelta = Infinity; + for (let i = 0; i < blocks.length; i++) { + const rect = blocks[i].getBoundingClientRect(); + if (rect.height < 1) continue; + if (rect.top > containerBottom) break; + const delta = rect.top - containerTop; + if (delta <= 0 && Math.abs(delta) < Math.abs(bestDelta)) { + bestIndex = i; + bestDelta = delta; + } else if (delta > 0 && bestDelta === Infinity) { + bestIndex = i; + bestDelta = delta; + } + } + const rect = blocks[bestIndex].getBoundingClientRect(); + const innerFraction = Math.max(0, Math.min(1, (containerTop - rect.top) / (rect.height || 1))); + return `${bestIndex}:${innerFraction.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; +} + // Reader settings let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', pdfZoom: 100, pdfInverted: false, pdfPaginated: false }; @@ -3086,22 +3129,21 @@ async function openBook(bookId) { const allBooks = await progressRes.json(); const bookData = allBooks.find(b => b.id === bookId); const fraction = bookData ? (bookData.scroll_fraction || 0) : 0; - if (fraction > 0) { - if (isPdf && readerSettings.pdfPaginated && pdfTotalPages > 1) { - pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1); - } else { - // For EPUB: wait for all images to load so scrollHeight is final - if (!isPdf) { - 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; }) - )); - } - } - // One more rAF to let the browser recalculate layout after image load - await new Promise(r => requestAnimationFrame(r)); + const anchor = bookData ? (bookData.position_anchor || '') : ''; + _currentPositionAnchor = anchor; + if (isPdf && readerSettings.pdfPaginated && pdfTotalPages > 1) { + if (fraction > 0) pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1); + } else if (!isPdf) { + // Wait for all 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) { contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight); } } @@ -3135,6 +3177,16 @@ async function openBook(bookId) { _scrollDebounce = setTimeout(saveReaderProgress, 2000); }, {passive: true}); + // Restore anchor on viewport resize (e.g. screen rotation, font zoom) + if (!isPdf) { + _resizeObserver = new ResizeObserver(() => { + if (_currentPositionAnchor) { + requestAnimationFrame(() => restoreFromAnchor(contentEl, _currentPositionAnchor)); + } + }); + _resizeObserver.observe(contentEl); + } + // Determine which station to play (null = use default, {url:''} = disabled) const focusStation = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION : (USER_FOCUS_STATION.url ? USER_FOCUS_STATION : null); @@ -3235,10 +3287,16 @@ async function saveReaderProgress() { } fraction = Math.min(1.0, Math.max(0.0, fraction)); + let anchor = ''; + if (!currentPdfDoc) { + anchor = getPositionAnchor(contentEl); + _currentPositionAnchor = anchor; + } + // Cache for sendBeacon on unload _lastProgressBeacon = { url: `/books/${currentBookId}/progress/`, - body: JSON.stringify({scroll_fraction: fraction}), + body: JSON.stringify({scroll_fraction: fraction, position_anchor: anchor}), }; try { @@ -3263,6 +3321,11 @@ function closeReader() { clearInterval(readerScrollSaveTimer); readerScrollSaveTimer = null; } + if (_resizeObserver) { + _resizeObserver.disconnect(); + _resizeObserver = null; + } + _currentPositionAnchor = ''; // Clear search before wiping content clearReaderSearch(); @@ -3403,6 +3466,9 @@ function applyReaderSettings(isPdf) { contentEl.style.fontSize = readerSettings.fontSize + 'px'; contentEl.style.lineHeight = readerSettings.lineHeight; contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch'); + if (_currentPositionAnchor && currentBookId) { + requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor)); + } } // Theme