diff --git a/packages/app-expo/assets/reader/reader.html b/packages/app-expo/assets/reader/reader.html
index 067a3534..a822a2b9 100644
--- a/packages/app-expo/assets/reader/reader.html
+++ b/packages/app-expo/assets/reader/reader.html
@@ -171,6 +171,46 @@
function postToRN(type, data) {
if (RN) RN.postMessage(JSON.stringify({ type, ...data }));
}
+ const SCROLL_RELOCATE_POST_INTERVAL_MS = 500;
+ let pendingScrollRelocatePayload = null;
+ let scrollRelocatePostTimer = null;
+ let lastScrollRelocatePostAt = 0;
+
+ function flushPendingScrollRelocate() {
+ if (scrollRelocatePostTimer) {
+ clearTimeout(scrollRelocatePostTimer);
+ scrollRelocatePostTimer = null;
+ }
+ if (!pendingScrollRelocatePayload) return;
+ lastScrollRelocatePostAt = Date.now();
+ postToRN('relocate', pendingScrollRelocatePayload);
+ pendingScrollRelocatePayload = null;
+ }
+
+ function postRelocateToRN(payload) {
+ const isContinuousScroll =
+ currentViewMode === 'scroll' &&
+ view &&
+ view.renderer &&
+ view.renderer.scrolled &&
+ !view.isFixedLayout;
+ if (!isContinuousScroll) {
+ flushPendingScrollRelocate();
+ postToRN('relocate', payload);
+ return;
+ }
+
+ pendingScrollRelocatePayload = payload;
+ const now = Date.now();
+ const remaining = SCROLL_RELOCATE_POST_INTERVAL_MS - (now - lastScrollRelocatePostAt);
+ if (remaining <= 0) {
+ flushPendingScrollRelocate();
+ return;
+ }
+ if (!scrollRelocatePostTimer) {
+ scrollRelocatePostTimer = setTimeout(flushPendingScrollRelocate, remaining);
+ }
+ }
window.__READANY_READER_BUILD_ID = 'android-local-server-cors';
postToRN('debug', { message: '[ReaderBuild] ' + window.__READANY_READER_BUILD_ID });
Promise.withResolvers ??= function () {
@@ -313,6 +353,7 @@
if (currentViewMode === 'scroll') {
renderer.removeAttribute('no-continuous-scroll');
renderer.setAttribute('flow', 'scrolled');
+ renderer.setAttribute('max-block-size', '100%');
renderer.setAttribute('max-inline-size', '9999px');
return;
}
@@ -1189,9 +1230,6 @@
if (!hit) return;
armNoteTapGuard(doc, 900);
- // Prevent subsequent click / show-annotation
- e.preventDefault && e.preventDefault();
-
const rect = hit.range.getBoundingClientRect();
const iframe = doc.defaultView && doc.defaultView.frameElement;
const iframeRect = iframe ? iframe.getBoundingClientRect() : { left: 0, top: 0 };
@@ -1206,7 +1244,7 @@
},
});
}, LONG_PRESS_MS);
- }, { passive: false });
+ }, { passive: true });
doc.addEventListener('touchmove', (e) => {
if (!timer) return;
@@ -1551,7 +1589,7 @@
total: Math.max(1, rendererPages - 2),
}
: null;
- postToRN('relocate', {
+ postRelocateToRN({
fraction: detail.fraction,
section: { current: currentSectionIndex, total: relocatedSectionTotal },
location: detail.location,
@@ -4693,8 +4731,8 @@
diff --git a/packages/app-expo/assets/reader/reader.template.html b/packages/app-expo/assets/reader/reader.template.html
index 7a981ea1..e140f264 100644
--- a/packages/app-expo/assets/reader/reader.template.html
+++ b/packages/app-expo/assets/reader/reader.template.html
@@ -171,6 +171,46 @@
function postToRN(type, data) {
if (RN) RN.postMessage(JSON.stringify({ type, ...data }));
}
+ const SCROLL_RELOCATE_POST_INTERVAL_MS = 500;
+ let pendingScrollRelocatePayload = null;
+ let scrollRelocatePostTimer = null;
+ let lastScrollRelocatePostAt = 0;
+
+ function flushPendingScrollRelocate() {
+ if (scrollRelocatePostTimer) {
+ clearTimeout(scrollRelocatePostTimer);
+ scrollRelocatePostTimer = null;
+ }
+ if (!pendingScrollRelocatePayload) return;
+ lastScrollRelocatePostAt = Date.now();
+ postToRN('relocate', pendingScrollRelocatePayload);
+ pendingScrollRelocatePayload = null;
+ }
+
+ function postRelocateToRN(payload) {
+ const isContinuousScroll =
+ currentViewMode === 'scroll' &&
+ view &&
+ view.renderer &&
+ view.renderer.scrolled &&
+ !view.isFixedLayout;
+ if (!isContinuousScroll) {
+ flushPendingScrollRelocate();
+ postToRN('relocate', payload);
+ return;
+ }
+
+ pendingScrollRelocatePayload = payload;
+ const now = Date.now();
+ const remaining = SCROLL_RELOCATE_POST_INTERVAL_MS - (now - lastScrollRelocatePostAt);
+ if (remaining <= 0) {
+ flushPendingScrollRelocate();
+ return;
+ }
+ if (!scrollRelocatePostTimer) {
+ scrollRelocatePostTimer = setTimeout(flushPendingScrollRelocate, remaining);
+ }
+ }
window.__READANY_READER_BUILD_ID = 'android-local-server-cors';
postToRN('debug', { message: '[ReaderBuild] ' + window.__READANY_READER_BUILD_ID });
Promise.withResolvers ??= function () {
@@ -313,6 +353,7 @@
if (currentViewMode === 'scroll') {
renderer.removeAttribute('no-continuous-scroll');
renderer.setAttribute('flow', 'scrolled');
+ renderer.setAttribute('max-block-size', '100%');
renderer.setAttribute('max-inline-size', '9999px');
return;
}
@@ -1189,9 +1230,6 @@
if (!hit) return;
armNoteTapGuard(doc, 900);
- // Prevent subsequent click / show-annotation
- e.preventDefault && e.preventDefault();
-
const rect = hit.range.getBoundingClientRect();
const iframe = doc.defaultView && doc.defaultView.frameElement;
const iframeRect = iframe ? iframe.getBoundingClientRect() : { left: 0, top: 0 };
@@ -1206,7 +1244,7 @@
},
});
}, LONG_PRESS_MS);
- }, { passive: false });
+ }, { passive: true });
doc.addEventListener('touchmove', (e) => {
if (!timer) return;
@@ -1551,7 +1589,7 @@
total: Math.max(1, rendererPages - 2),
}
: null;
- postToRN('relocate', {
+ postRelocateToRN({
fraction: detail.fraction,
section: { current: currentSectionIndex, total: relocatedSectionTotal },
location: detail.location,
diff --git a/packages/app-expo/src/screens/ReaderScreen.tsx b/packages/app-expo/src/screens/ReaderScreen.tsx
index 97f05c13..78d4ce0a 100644
--- a/packages/app-expo/src/screens/ReaderScreen.tsx
+++ b/packages/app-expo/src/screens/ReaderScreen.tsx
@@ -82,6 +82,8 @@ const MAX_TRACKED_PAGE_DELTA = 20;
const MAX_TRACKED_FRACTION_DELTA = 0.08;
const INITIAL_PROGRESS_RESTORE_GUARD_MS = 1800;
const PROGRAMMATIC_NAV_GUARD_MS = 1200;
+const RELOCATE_UI_UPDATE_MS = 250;
+const RELOCATE_CONTEXT_UPDATE_MS = 1000;
const BOOK_MIME_TYPES = [
"application/epub+zip",
"application/pdf",
@@ -149,11 +151,7 @@ const NOTE_TOOLTIP_TOP_THRESHOLD = 180;
import { useRubyStore } from "@readany/core/stores/ruby-store";
import { ReaderSettingsPanel } from "./reader/ReaderSettingsPanel";
import { ReaderTOCPanel } from "./reader/ReaderTOCPanel";
-import {
- CONTROLS_TIMEOUT,
- SCREEN_HEIGHT,
- SCREEN_WIDTH,
-} from "./reader/reader-constants";
+import { CONTROLS_TIMEOUT, SCREEN_HEIGHT, SCREEN_WIDTH } from "./reader/reader-constants";
import { BatteryIcon, ListIcon, SettingsIcon } from "./reader/reader-icons";
import { makeStyles, noteTooltipMdStyles } from "./reader/reader-styles";
import { useReaderBookmark } from "./reader/useReaderBookmark";
@@ -167,6 +165,22 @@ const LOCAL_FONT_SERVER_DIR = "readany-fonts";
type Props = NativeStackScreenProps;
type TTSSegment = VisibleTTSSegment;
+type PendingRelocateState = {
+ bookId: string;
+ fraction?: number;
+ cfi?: string;
+ pageCurrent: number;
+ pageTotal: number;
+ chapter?: string;
+};
+type PendingReadingContextState = {
+ bookId: string;
+ cfi?: string;
+ chapterIndex: number;
+ chapterTitle: string;
+ chapterHref: string;
+ percentage: number;
+};
// ──────────────────────────── helpers ────────────────────────────
@@ -203,6 +217,24 @@ function buildCustomFontFaceCSS(
.join("\n");
}
+function syncReadingContextFromRelocate(pending: PendingReadingContextState | null): void {
+ if (!pending) return;
+ const currentBook = useLibraryStore.getState().books.find((item) => item.id === pending.bookId);
+ readingContextService.updateContext({
+ bookId: pending.bookId,
+ bookTitle: currentBook?.meta?.title || "",
+ currentChapter: {
+ index: pending.chapterIndex,
+ title: pending.chapterTitle,
+ href: pending.chapterHref,
+ },
+ currentPosition: {
+ cfi: pending.cfi || "",
+ percentage: pending.percentage,
+ },
+ });
+}
+
// ──────────────────────────── ReaderScreen ────────────────────────────
export function ReaderScreen({ route, navigation }: Props) {
const colors = useColors();
@@ -349,6 +381,9 @@ export function ReaderScreen({ route, navigation }: Props) {
const locationHistoryRef = useRef([]);
const lastNavigatedCfiRef = useRef(undefined);
const fileServerRef = useRef(null);
+ const pendingRelocateStateRef = useRef(null);
+ const pendingReadingContextRef = useRef(null);
+ const isMountedRef = useRef(true);
const sessionProgressRef = useRef<{
mode: "location" | "page" | "characters";
current: number;
@@ -374,6 +409,31 @@ export function ReaderScreen({ route, navigation }: Props) {
});
}, 5000),
).current;
+ const flushPendingRelocateState = useRef(
+ throttle(() => {
+ if (!isMountedRef.current) return;
+ const pending = pendingRelocateStateRef.current;
+ if (!pending) return;
+ pendingRelocateStateRef.current = null;
+
+ if (pending.fraction != null) {
+ progressRef.current = pending.fraction;
+ setProgress(pending.fraction);
+ }
+ setCurrentPage(pending.pageCurrent);
+ setTotalPages(pending.pageTotal);
+ if (pending.chapter) setCurrentChapter(pending.chapter);
+ if (pending.cfi) setCurrentCfi(pending.cfi);
+ }, RELOCATE_UI_UPDATE_MS),
+ ).current;
+ const flushPendingReadingContext = useRef(
+ throttle(() => {
+ if (!isMountedRef.current) return;
+ const pending = pendingReadingContextRef.current;
+ pendingReadingContextRef.current = null;
+ syncReadingContextFromRelocate(pending);
+ }, RELOCATE_CONTEXT_UPDATE_MS),
+ ).current;
const {
addHighlight,
updateHighlight,
@@ -624,13 +684,6 @@ export function ReaderScreen({ route, navigation }: Props) {
totalBookCharactersRef.current = totalCharacters > 0 ? totalCharacters : null;
},
onRelocate: (detail: RelocateEvent) => {
- console.log("[ReaderScreen] onRelocate", {
- section: detail.section,
- fraction: detail.fraction,
- cfi: detail.cfi,
- routeCfi: cfi,
- lastNavigated: lastNavigatedCfiRef.current,
- });
if (loading) {
setLoading(false);
}
@@ -641,20 +694,17 @@ export function ReaderScreen({ route, navigation }: Props) {
setTranslationReady(false);
chapterTranslation.reset();
}
+ const previousProgress = progressRef.current;
- if (detail.fraction != null) setProgress(detail.fraction);
-
+ let nextPageCurrent = 0;
+ let nextPageTotal = 0;
if (detail.page) {
- setCurrentPage(Math.max(1, detail.page.current));
- setTotalPages(Math.max(1, detail.page.total));
+ nextPageCurrent = Math.max(1, detail.page.current);
+ nextPageTotal = Math.max(1, detail.page.total);
} else if (detail.section?.total && !detail.location?.total) {
// Fixed-layout documents can still expose stable section pages.
- setCurrentPage(Math.max(1, detail.section.current + 1));
- setTotalPages(Math.max(1, detail.section.total));
- } else {
- // Reflowable books without renderer-backed pagination should fall back to percent.
- setCurrentPage(0);
- setTotalPages(0);
+ nextPageCurrent = Math.max(1, detail.section.current + 1);
+ nextPageTotal = Math.max(1, detail.section.total);
}
const trackingSuppressed = Date.now() < progressTrackingGuardUntilRef.current;
@@ -731,10 +781,9 @@ export function ReaderScreen({ route, navigation }: Props) {
}
sessionProgressRef.current = { mode: "page", current: detail.section.current };
}
- if (detail.tocItem?.label) setCurrentChapter(detail.tocItem.label);
if (detail.cfi) {
if (lastCfiRef.current && detail.cfi !== lastCfiRef.current) {
- const fractionDiff = Math.abs((detail.fraction ?? 0) - progress);
+ const fractionDiff = Math.abs((detail.fraction ?? 0) - previousProgress);
if (fractionDiff > 0.02 || locationHistoryRef.current.length === 0) {
locationHistoryRef.current.push(lastCfiRef.current);
if (locationHistoryRef.current.length > 50) {
@@ -743,18 +792,35 @@ export function ReaderScreen({ route, navigation }: Props) {
}
}
lastCfiRef.current = detail.cfi;
- setCurrentCfi(detail.cfi);
// Use throttled save instead of immediate update
throttledSaveProgress(bookId, detail.fraction ?? 0, detail.cfi);
}
+ pendingRelocateStateRef.current = {
+ bookId,
+ fraction: detail.fraction,
+ cfi: detail.cfi,
+ pageCurrent: nextPageCurrent,
+ pageTotal: nextPageTotal,
+ chapter: detail.tocItem?.label,
+ };
+ flushPendingRelocateState();
+ pendingReadingContextRef.current = {
+ bookId,
+ cfi: detail.cfi,
+ chapterIndex: detail.section?.current ?? 0,
+ chapterTitle: detail.tocItem?.label || "",
+ chapterHref: detail.tocItem?.href || "",
+ percentage: (detail.fraction ?? 0) * 100,
+ };
+ flushPendingReadingContext();
+
// Mark translation ready after first successful relocate (CFI navigation done)
if (!translationReady) setTranslationReady(true);
// If TTS is waiting for a page turn to complete, fire the continuation callback now
// that the renderer has fully updated its position (renderer.start reflects new page).
if (ttsPendingContinueRef.current?.pendingTTSContinueCallbackRef.current) {
- console.log("[ReaderScreen][TTS] onRelocate triggered pending TTS continuation");
const cb = ttsPendingContinueRef.current.pendingTTSContinueCallbackRef.current;
ttsPendingContinueRef.current.pendingTTSContinueCallbackRef.current = null;
// Cancel the safety timer since onRelocate fired successfully
@@ -766,20 +832,7 @@ export function ReaderScreen({ route, navigation }: Props) {
void cb();
}
- // Sync reading context for AI tools
- readingContextService.updateContext({
- bookId,
- bookTitle: book?.meta?.title || "",
- currentChapter: {
- index: detail.section?.current ?? 0,
- title: detail.tocItem?.label || "",
- href: detail.tocItem?.href || "",
- },
- currentPosition: {
- cfi: detail.cfi || "",
- percentage: (detail.fraction ?? 0) * 100,
- },
- });
+ // Reading context is synchronized by the throttled relocate flush above.
},
onTocReady: (items: TOCItem[]) => {
setToc(items);
@@ -906,10 +959,25 @@ export function ReaderScreen({ route, navigation }: Props) {
appActive,
// 维护约定:任何新增遮盖正文/输入态/导航跳转,必须在此追加判定。
[
- readSettings.volumeButtonsPageTurn, webViewReady, loading, error, isReimporting,
- showSearch, showTOC, showSettings, showNotebook, showTTS,
- showTranslation, showChapterTranslation, chapterTranslation.state.status,
- selection, noteViewHighlight, noteTooltip, ttsPlayState, isFocused, appActive,
+ readSettings.volumeButtonsPageTurn,
+ webViewReady,
+ loading,
+ error,
+ isReimporting,
+ showSearch,
+ showTOC,
+ showSettings,
+ showNotebook,
+ showTTS,
+ showTranslation,
+ showChapterTranslation,
+ chapterTranslation.state.status,
+ selection,
+ noteViewHighlight,
+ noteTooltip,
+ ttsPlayState,
+ isFocused,
+ appActive,
],
);
@@ -1079,6 +1147,17 @@ export function ReaderScreen({ route, navigation }: Props) {
// Save progress immediately on unmount
useEffect(() => {
return () => {
+ const pendingRelocate = pendingRelocateStateRef.current;
+ if (pendingRelocate?.fraction != null) {
+ progressRef.current = pendingRelocate.fraction;
+ }
+ if (pendingRelocate?.cfi) {
+ lastCfiRef.current = pendingRelocate.cfi;
+ }
+ syncReadingContextFromRelocate(pendingReadingContextRef.current);
+ pendingRelocateStateRef.current = null;
+ pendingReadingContextRef.current = null;
+ isMountedRef.current = false;
if (fileServerRef.current) {
stopFileServer();
fileServerRef.current = null;
diff --git a/packages/foliate-js/paginator.js b/packages/foliate-js/paginator.js
index b2e507be..21250183 100644
--- a/packages/foliate-js/paginator.js
+++ b/packages/foliate-js/paginator.js
@@ -1067,6 +1067,9 @@ export class Paginator extends HTMLElement {
grid-row: 1 / -1;
overflow: auto;
overflow-anchor: auto;
+ overscroll-behavior: contain;
+ touch-action: pan-y;
+ -webkit-overflow-scrolling: touch;
flex-direction: column;
background: var(--_scrollbar-track-bg, transparent);
scrollbar-width: none;
@@ -1107,6 +1110,7 @@ export class Paginator extends HTMLElement {
}
:host([flow="scrolled"]) #container.vertical {
flex-direction: row;
+ touch-action: pan-x;
}
#header {
grid-column: 3 / 4;
@@ -2062,6 +2066,11 @@ export class Paginator extends HTMLElement {
}
#onTouchStart(e) {
if (this.#navigationLocked) return
+ if (this.scrolled) {
+ this.#touchState = null
+ this.#touchScrolled = false
+ return
+ }
const contents = this.getContents?.() ?? []
for (const { doc } of contents) {
const selection = doc?.getSelection?.()
@@ -2084,50 +2093,10 @@ export class Paginator extends HTMLElement {
pv.element.style.willChange = 'transform'
}
}
- #onScrolledTouchMove(e, state) {
- if (e.touches.length > 1) return
- const touch = e.changedTouches[0]
- if (!touch) return
-
- const x = touch.screenX
- const y = touch.screenY
- const totalDx = Math.abs(x - (state.startX ?? x))
- const totalDy = Math.abs(y - (state.startY ?? y))
- const primaryTotal = this.#vertical ? totalDx : totalDy
- const crossTotal = this.#vertical ? totalDy : totalDx
-
- if (primaryTotal < 2 || primaryTotal < crossTotal) {
- state.x = x
- state.y = y
- state.t = e.timeStamp
- return
- }
-
- const dx = state.x - x
- const dy = state.y - y
- state.x = x
- state.y = y
- state.t = e.timeStamp
-
- const delta = this.#vertical ? -dx : dy
- if (Math.abs(delta) < 0.5) return
-
- e.preventDefault()
- this.#touchScrolled = true
-
- const previous = this.containerPosition
- this.containerPosition = previous + delta
- const moved = Math.abs(this.containerPosition - previous) >= 0.5
-
- if (!moved && Math.abs(delta) > 2) {
- const forward = this.#vertical ? delta < 0 : delta > 0
- if (forward && !this.atEnd) void this.next()
- else if (!forward && !this.atStart) void this.prev()
- }
- }
#onTouchMove(e) {
const state = this.#touchState
if (this.#navigationLocked || !state) return
+ if (this.scrolled) return
const contents = this.getContents?.() ?? []
for (const { doc } of contents) {
const selection = doc?.getSelection?.()
@@ -2136,10 +2105,6 @@ export class Paginator extends HTMLElement {
if (state.pinched) return
state.pinched = globalThis.visualViewport.scale > 1
if (state.pinched) return
- if (this.scrolled) {
- this.#onScrolledTouchMove(e, state)
- return
- }
// When the host opts out of swipe-to-paginate, let touch events reach
// native behavior (text selection, etc.) without us tracking or
// pre-empting them.