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.