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 <noreply@anthropic.com>
This commit is contained in:
parent
0c6846e71f
commit
1af07c7952
4 changed files with 110 additions and 19 deletions
16
books/migrations/0003_ebookprogress_position_anchor.py
Normal file
16
books/migrations/0003_ebookprogress_position_anchor.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
||||
|
|
|
|||
100
static/js/app.js
100
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue