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')
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
||||||
|
|
|
||||||
100
static/js/app.js
100
static/js/app.js
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue