fix(reader): save progress on real position change, not on detected intent
Release / build-and-push (push) Successful in 2m27s
Release / build-and-push (push) Successful in 2m27s
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