Add anchor-based reading position tracking
All checks were successful
Build and push Docker image / build (push) Successful in 12s
Test / test (push) Successful in 15s

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 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-04-01 15:41:30 +02:00
parent 0c6846e71f
commit 1af07c7952
4 changed files with 110 additions and 19 deletions

View file

@ -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),
),
]

View file

@ -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:

View file

@ -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})

View file

@ -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,12 +3129,12 @@ 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) {
const anchor = bookData ? (bookData.position_anchor || '') : '';
_currentPositionAnchor = anchor;
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) {
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 =>
@ -3099,9 +3142,8 @@ async function openBook(bookId) {
: 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));
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