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') user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress')
book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress') book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress')
scroll_fraction = models.FloatField(default=0.0) scroll_fraction = models.FloatField(default=0.0)
position_anchor = models.CharField(max_length=30, blank=True, default='')
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:

View file

@ -1,5 +1,6 @@
import base64 import base64
import json import json
import re
from django.conf import settings from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse
@ -27,13 +28,14 @@ def book_list(request):
b['uploaded_at'] = b['uploaded_at'].isoformat() b['uploaded_at'] = b['uploaded_at'].isoformat()
# Include saved scroll_fraction for each book # Include saved scroll_fraction for each book
progress_map = { 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 p in EBookProgress.objects.filter(user=request.user)
} }
for b in books: for b in books:
prog = progress_map.get(b['id']) prog = progress_map.get(b['id'])
b['scroll_fraction'] = prog[0] if prog else 0.0 b['scroll_fraction'] = prog[0] if prog else 0.0
b['last_read'] = prog[1].isoformat() if prog else None b['last_read'] = prog[1].isoformat() if prog else None
b['position_anchor'] = prog[2] if prog else ''
return JsonResponse(books, safe=False) 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 = float(body.get('scroll_fraction', 0.0))
scroll_fraction = max(0.0, min(1.0, scroll_fraction)) 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( progress, _ = EBookProgress.objects.get_or_create(
user=request.user, user=request.user,
book=book, book=book,
) )
progress.scroll_fraction = scroll_fraction 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}) return JsonResponse({'ok': True})

View file

@ -2572,8 +2572,51 @@ let currentBookId = null;
let currentBookToc = []; let currentBookToc = [];
let currentImageMap = {}; let currentImageMap = {};
let readerScrollSaveTimer = null; let readerScrollSaveTimer = null;
let _resizeObserver = null;
let _currentPositionAnchor = '';
const bookMetaCache = {}; // id → {title, author, type} 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 // Reader settings
let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark', let readerSettings = { fontSize: 16, lineHeight: 1.8, maxWidth: 65, theme: 'dark',
pdfZoom: 100, pdfInverted: false, pdfPaginated: false }; pdfZoom: 100, pdfInverted: false, pdfPaginated: false };
@ -3086,22 +3129,21 @@ async function openBook(bookId) {
const allBooks = await progressRes.json(); const allBooks = await progressRes.json();
const bookData = allBooks.find(b => b.id === bookId); const bookData = allBooks.find(b => b.id === bookId);
const fraction = bookData ? (bookData.scroll_fraction || 0) : 0; const fraction = bookData ? (bookData.scroll_fraction || 0) : 0;
if (fraction > 0) { const anchor = bookData ? (bookData.position_anchor || '') : '';
if (isPdf && readerSettings.pdfPaginated && pdfTotalPages > 1) { _currentPositionAnchor = anchor;
pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1); if (isPdf && readerSettings.pdfPaginated && pdfTotalPages > 1) {
} else { if (fraction > 0) pdfCurrentPage = Math.max(1, Math.round(fraction * (pdfTotalPages - 1)) + 1);
// For EPUB: wait for all images to load so scrollHeight is final } else if (!isPdf) {
if (!isPdf) { // Wait for all images so scrollHeight is final
const imgs = Array.from(contentEl.querySelectorAll('img')); const imgs = Array.from(contentEl.querySelectorAll('img'));
if (imgs.length) { if (imgs.length) {
await Promise.all(imgs.map(img => await Promise.all(imgs.map(img =>
img.complete ? Promise.resolve() img.complete ? Promise.resolve()
: new Promise(r => { img.onload = r; img.onerror = r; }) : new Promise(r => { img.onload = r; img.onerror = r; })
)); ));
} }
} await new Promise(r => requestAnimationFrame(r));
// One more rAF to let the browser recalculate layout after image load if (!restoreFromAnchor(contentEl, anchor) && fraction > 0) {
await new Promise(r => requestAnimationFrame(r));
contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight); contentEl.scrollTop = fraction * (contentEl.scrollHeight - contentEl.clientHeight);
} }
} }
@ -3135,6 +3177,16 @@ async function openBook(bookId) {
_scrollDebounce = setTimeout(saveReaderProgress, 2000); _scrollDebounce = setTimeout(saveReaderProgress, 2000);
}, {passive: true}); }, {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) // Determine which station to play (null = use default, {url:''} = disabled)
const focusStation = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION const focusStation = USER_FOCUS_STATION === null ? DEFAULT_FOCUS_STATION
: (USER_FOCUS_STATION.url ? USER_FOCUS_STATION : null); : (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)); fraction = Math.min(1.0, Math.max(0.0, fraction));
let anchor = '';
if (!currentPdfDoc) {
anchor = getPositionAnchor(contentEl);
_currentPositionAnchor = anchor;
}
// Cache for sendBeacon on unload // Cache for sendBeacon on unload
_lastProgressBeacon = { _lastProgressBeacon = {
url: `/books/${currentBookId}/progress/`, url: `/books/${currentBookId}/progress/`,
body: JSON.stringify({scroll_fraction: fraction}), body: JSON.stringify({scroll_fraction: fraction, position_anchor: anchor}),
}; };
try { try {
@ -3263,6 +3321,11 @@ function closeReader() {
clearInterval(readerScrollSaveTimer); clearInterval(readerScrollSaveTimer);
readerScrollSaveTimer = null; readerScrollSaveTimer = null;
} }
if (_resizeObserver) {
_resizeObserver.disconnect();
_resizeObserver = null;
}
_currentPositionAnchor = '';
// Clear search before wiping content // Clear search before wiping content
clearReaderSearch(); clearReaderSearch();
@ -3403,6 +3466,9 @@ function applyReaderSettings(isPdf) {
contentEl.style.fontSize = readerSettings.fontSize + 'px'; contentEl.style.fontSize = readerSettings.fontSize + 'px';
contentEl.style.lineHeight = readerSettings.lineHeight; contentEl.style.lineHeight = readerSettings.lineHeight;
contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch'); contentEl.style.setProperty('--reader-max-width', readerSettings.maxWidth + 'ch');
if (_currentPositionAnchor && currentBookId) {
requestAnimationFrame(() => restoreFromAnchor($('reader-content'), _currentPositionAnchor));
}
} }
// Theme // Theme