fix(reader): save progress on real position change, not on detected intent
The userMoved gate from ce3ae31 broke progress saving entirely for
EPUBs. The flag was set only in the app's navigation wrappers
(goLeft/goRight, keys, slider, search/TOC/bookmark/back-stack jumps),
but foliate-js's paginator handles the most common reading gestures
itself — touch-swipe paging, scrolled-mode reading, in-content links,
selection auto-advance — dispatching relocate directly without ever
calling those wrappers. Every one of those relocations hit the
"if (!this.userMoved) return" guard, so the position never saved at
all on EPUB; only tap-zone-paged formats (comics/fixed layout) kept
saving, which matched the intermittent reports.
Detecting intent was the wrong tool: the set of foliate-internal
navigation paths is open-ended and lives in a forked dependency.
Compare the position itself instead:
- The relocate handler records the latest CFI (lastCfi), and the
baseline (lastSyncedCfi/lastSyncedFraction) is captured right after
view.init() resolves — i.e. the restored position, or the start of
the book on a fresh open.
- debouncedSaveProgress saves only when the position actually moved:
CFI comparison for reflowable books, fraction comparison (1e-4
epsilon) for CFI-less fixed layout and PDF.
- The baseline updates after each successful save, and a
bfcache-resurrected page re-baselines to its frozen position, so the
anti-clobber property survives: a displayed/restored position can
still never overwrite a newer device push.
The twelve userMoved assignments in the wrapper methods are gone —
change detection covers deliberate jumps and internal gestures alike.
Backend untouched; SaveProgress was never the problem.
This commit is contained in:
+38
-26
@@ -482,11 +482,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
tocItems: [] as any[],
|
tocItems: [] as any[],
|
||||||
mediaItemId: "" as string,
|
mediaItemId: "" as string,
|
||||||
saveTimeout: null as ReturnType<typeof setTimeout> | null,
|
saveTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||||
// Set only by deliberate navigation (page turns, jumps, slider). The
|
// Position last known to be stored (the restore at open time, or the
|
||||||
// restore at open time and section-load relocations never set it, so
|
// last successful save). Relocations that don't move from it are never
|
||||||
// progress saves can only ever write a position the user actually
|
// written back, so a restored position can't clobber a newer device
|
||||||
// moved to — never a stale restore clobbering a newer device push.
|
// push — while swipe/scroll paging (handled inside foliate, with no
|
||||||
userMoved: false as boolean,
|
// wrapper method to flag) still saves normally.
|
||||||
|
lastSyncedCfi: "" as string,
|
||||||
|
lastSyncedFraction: -1 as number,
|
||||||
|
lastCfi: "" as string,
|
||||||
contextText: "" as string,
|
contextText: "" as string,
|
||||||
readingTheme: "light" as string,
|
readingTheme: "light" as string,
|
||||||
readingMode: "light" as string,
|
readingMode: "light" as string,
|
||||||
@@ -883,6 +886,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const { fraction, location, pageItem, cfi, tocItem, section } =
|
const { fraction, location, pageItem, cfi, tocItem, section } =
|
||||||
e.detail;
|
e.detail;
|
||||||
this.hideSelectionPopover();
|
this.hideSelectionPopover();
|
||||||
|
this.lastCfi = cfi || "";
|
||||||
this.lastRelocateDetail = {
|
this.lastRelocateDetail = {
|
||||||
fraction,
|
fraction,
|
||||||
location,
|
location,
|
||||||
@@ -944,6 +948,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
} else {
|
} else {
|
||||||
await this.view.init({})
|
await this.view.init({})
|
||||||
}
|
}
|
||||||
|
// The position restored above (or the start of the book on a fresh
|
||||||
|
// open) is the baseline: only relocations that actually move from
|
||||||
|
// it may write progress.
|
||||||
|
this.lastSyncedCfi = this.lastCfi;
|
||||||
|
this.lastSyncedFraction = this.lastRelocateDetail?.fraction ?? -1;
|
||||||
// The renderer only knows it's a PDF once frames exist (they carry
|
// The renderer only knows it's a PDF once frames exist (they carry
|
||||||
// pdf.js onZoom), i.e. after init has rendered the first spread.
|
// pdf.js onZoom), i.e. after init has rendered the first spread.
|
||||||
// Read it now and apply the saved pointer mode — this also makes the
|
// Read it now and apply the saved pointer mode — this also makes the
|
||||||
@@ -953,10 +962,15 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.renderer.setAttribute("interaction-mode", this.interactionMode);
|
this.renderer.setAttribute("interaction-mode", this.interactionMode);
|
||||||
}
|
}
|
||||||
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
|
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
|
||||||
// A bfcache-resurrected page is stale by definition: forbid it from
|
// A bfcache-resurrected page is stale by definition: re-baseline to
|
||||||
// writing its frozen position back until the user navigates again.
|
// its frozen position so it can't write that back until the user
|
||||||
|
// actually navigates again.
|
||||||
window.addEventListener("pageshow", (e: PageTransitionEvent) => {
|
window.addEventListener("pageshow", (e: PageTransitionEvent) => {
|
||||||
if (e.persisted) this.userMoved = false;
|
if (e.persisted) {
|
||||||
|
this.lastSyncedCfi = this.lastCfi;
|
||||||
|
this.lastSyncedFraction =
|
||||||
|
this.lastRelocateDetail?.fraction ?? -1;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.fetchReadingSpeed();
|
this.fetchReadingSpeed();
|
||||||
this.refreshAnnotations();
|
this.refreshAnnotations();
|
||||||
@@ -1557,9 +1571,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Fresh reading position from the database — the single source of
|
// Fresh reading position from the database — the single source of
|
||||||
// truth at open time. Fails soft to a fresh start: the userMoved gate
|
// truth at open time. Fails soft to a fresh start: change detection
|
||||||
// guarantees merely opening (even at the wrong spot) can never
|
// against the restored baseline guarantees merely opening (even at
|
||||||
// overwrite the stored position.
|
// the wrong spot) can never overwrite the stored position.
|
||||||
async fetchSavedLocation(): Promise<{
|
async fetchSavedLocation(): Promise<{
|
||||||
cfi?: string;
|
cfi?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -1591,9 +1605,17 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
||||||
// Only deliberate navigation writes progress: displaying a restored
|
// Only an actual change from the last stored position writes
|
||||||
// position must never overwrite a newer device push.
|
// progress: displaying a restored position must never overwrite a
|
||||||
if (!this.userMoved) return;
|
// newer device push. Swipes and scrolls are handled inside foliate
|
||||||
|
// with no wrapper method to flag, so position — not intent — is the
|
||||||
|
// signal. Books without CFIs (fixed layout, PDF) compare fraction.
|
||||||
|
const currentCfi = cfi || "";
|
||||||
|
const changed =
|
||||||
|
currentCfi || this.lastSyncedCfi
|
||||||
|
? currentCfi !== this.lastSyncedCfi
|
||||||
|
: Math.abs(fraction - this.lastSyncedFraction) > 1e-4;
|
||||||
|
if (!changed) return;
|
||||||
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
||||||
this.saveTimeout = setTimeout(() => {
|
this.saveTimeout = setTimeout(() => {
|
||||||
this.saveProgress(fraction, location, cfi);
|
this.saveProgress(fraction, location, cfi);
|
||||||
@@ -1644,6 +1666,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
this.lastSyncedCfi = cfi || "";
|
||||||
|
this.lastSyncedFraction = fraction;
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// silent fail — progress save is non-critical
|
// silent fail — progress save is non-critical
|
||||||
}
|
}
|
||||||
@@ -1734,23 +1758,18 @@ document.addEventListener("alpine:init", () => {
|
|||||||
saveSettings({ double_page_spread: this.doublePageSpread });
|
saveSettings({ double_page_spread: this.doublePageSpread });
|
||||||
},
|
},
|
||||||
goLeft() {
|
goLeft() {
|
||||||
this.userMoved = true;
|
|
||||||
this.view?.goLeft?.();
|
this.view?.goLeft?.();
|
||||||
},
|
},
|
||||||
goRight() {
|
goRight() {
|
||||||
this.userMoved = true;
|
|
||||||
this.view?.goRight?.();
|
this.view?.goRight?.();
|
||||||
},
|
},
|
||||||
nextPage() {
|
nextPage() {
|
||||||
this.userMoved = true;
|
|
||||||
this.view?.next?.();
|
this.view?.next?.();
|
||||||
},
|
},
|
||||||
previousPage() {
|
previousPage() {
|
||||||
this.userMoved = true;
|
|
||||||
this.view?.prev?.();
|
this.view?.prev?.();
|
||||||
},
|
},
|
||||||
goToFraction(value: string) {
|
goToFraction(value: string) {
|
||||||
this.userMoved = true;
|
|
||||||
this.view?.goToFraction?.(parseFloat(value));
|
this.view?.goToFraction?.(parseFloat(value));
|
||||||
},
|
},
|
||||||
toggleTOC() {
|
toggleTOC() {
|
||||||
@@ -1929,11 +1948,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
goToSearchResult(item: { cfi?: string; page?: number | null }) {
|
goToSearchResult(item: { cfi?: string; page?: number | null }) {
|
||||||
if (item.cfi) {
|
if (item.cfi) {
|
||||||
this.userMoved = true;
|
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
this.view?.goTo?.(item.cfi);
|
this.view?.goTo?.(item.cfi);
|
||||||
} else if (item.page != null) {
|
} else if (item.page != null) {
|
||||||
this.userMoved = true;
|
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
this.view?.goTo?.(item.page);
|
this.view?.goTo?.(item.page);
|
||||||
} else return;
|
} else return;
|
||||||
@@ -1962,7 +1979,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
goBackToLocation() {
|
goBackToLocation() {
|
||||||
const loc = this.backStack.pop();
|
const loc = this.backStack.pop();
|
||||||
if (!loc) return;
|
if (!loc) return;
|
||||||
this.userMoved = true;
|
|
||||||
if (loc.cfi) this.view?.goTo?.(loc.cfi);
|
if (loc.cfi) this.view?.goTo?.(loc.cfi);
|
||||||
else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
|
else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
|
||||||
},
|
},
|
||||||
@@ -1978,7 +1994,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
goToTOCItem(item: any) {
|
goToTOCItem(item: any) {
|
||||||
if (this.view && item.href) {
|
if (this.view && item.href) {
|
||||||
this.userMoved = true;
|
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
this.view.goTo(item.href);
|
this.view.goTo(item.href);
|
||||||
this.tocOpen = false;
|
this.tocOpen = false;
|
||||||
@@ -2106,7 +2121,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
goToPage(index: number) {
|
goToPage(index: number) {
|
||||||
if (!this.view || typeof index !== "number" || index < 0) return;
|
if (!this.view || typeof index !== "number" || index < 0) return;
|
||||||
this.userMoved = true;
|
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
this.view.goTo(index);
|
this.view.goTo(index);
|
||||||
this.tocOpen = false;
|
this.tocOpen = false;
|
||||||
@@ -2114,11 +2128,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
goToBookmark(item: { cfi: string; page: number | null }) {
|
goToBookmark(item: { cfi: string; page: number | null }) {
|
||||||
if (!this.view) return;
|
if (!this.view) return;
|
||||||
if (item.cfi) {
|
if (item.cfi) {
|
||||||
this.userMoved = true;
|
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
this.view.goTo(item.cfi);
|
this.view.goTo(item.cfi);
|
||||||
} else if (item.page != null && item.page > 0) {
|
} else if (item.page != null && item.page > 0) {
|
||||||
this.userMoved = true;
|
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
// Fixed-layout/comic: sections are pages; foliate takes an index.
|
// Fixed-layout/comic: sections are pages; foliate takes an index.
|
||||||
this.view.goTo(item.page - 1);
|
this.view.goTo(item.page - 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user