diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index d75c56c..7e82fc3 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -10,6 +10,7 @@ type PageCommitStrategy = "immediate" | "queued"; const VERTICAL_ANCHOR_TOLERANCE_PX = 24; const VERTICAL_ANCHOR_STABLE_FRAMES = 18; const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180; +const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180; export function CbzReader({ bookId, @@ -33,6 +34,9 @@ export function CbzReader({ const verticalUserScrollRef = useRef(false); const verticalAnchorTargetPageRef = useRef(null); const verticalAnchorFrameRef = useRef(null); + const verticalInitialSyncFrameRef = useRef(null); + const verticalInitialSyncAttemptRef = useRef(0); + const verticalInitialSyncDoneRef = useRef(false); const verticalAnchorAttemptRef = useRef(0); const verticalAnchorStableFramesRef = useRef(0); const [pages, setPages] = useState(null); @@ -83,10 +87,15 @@ export function CbzReader({ if (verticalAnchorFrameRef.current !== null) cancelAnimationFrame(verticalAnchorFrameRef.current); verticalAnchorFrameRef.current = null; }, []); + const clearVerticalInitialSync = useCallback(() => { + if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current); + verticalInitialSyncFrameRef.current = null; + }, []); const commitVisiblePage = useCallback( (stage: HTMLElement, allowInitialCommit = false) => { if (!pages || !verticalTrackingReadyRef.current) return; if (!allowInitialCommit && !verticalUserScrollRef.current) return; + if (allowInitialCommit && verticalInitialSyncDoneRef.current) return; const stageRect = stage.getBoundingClientRect(); const visiblePage = majorityVisiblePage( Array.from(stage.querySelectorAll("[data-reader-page]")).map((element) => { @@ -96,10 +105,34 @@ export function CbzReader({ stageRect.top, stageRect.bottom ); - if (visiblePage && visiblePage !== currentPage) onPageCommit(clampReaderPage(visiblePage, pages.pageCount), pages.pageCount, 1, "queued"); + if (visiblePage && visiblePage !== currentPage) { + if (allowInitialCommit) verticalInitialSyncDoneRef.current = true; + onPageCommit(clampReaderPage(visiblePage, pages.pageCount), pages.pageCount, 1, "queued"); + } }, [currentPage, onPageCommit, pages] ); + const scheduleInitialVisibleSync = useCallback( + (stage: HTMLElement) => { + clearVerticalInitialSync(); + if (!(stage.scrollHeight > stage.clientHeight)) return; + verticalInitialSyncAttemptRef.current = 0; + + const sync = () => { + verticalInitialSyncFrameRef.current = null; + if (verticalUserScrollRef.current || verticalInitialSyncDoneRef.current) return; + if (verticalTrackingReadyRef.current && stage.scrollTop > Math.max(32, stage.clientHeight * 0.5)) { + commitVisiblePage(stage, true); + } + verticalInitialSyncAttemptRef.current += 1; + if (verticalInitialSyncAttemptRef.current >= VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS) return; + verticalInitialSyncFrameRef.current = requestAnimationFrame(sync); + }; + + verticalInitialSyncFrameRef.current = requestAnimationFrame(sync); + }, + [clearVerticalInitialSync, commitVisiblePage] + ); const stabilizeVerticalAnchor = useCallback( (targetPage: number) => { clearVerticalAnchorFrame(); @@ -125,6 +158,7 @@ export function CbzReader({ if (verticalAnchorStableFramesRef.current >= VERTICAL_ANCHOR_STABLE_FRAMES) { verticalTrackingReadyRef.current = true; commitVisiblePage(stage, true); + scheduleInitialVisibleSync(stage); return; } verticalAnchorAttemptRef.current += 1; @@ -135,7 +169,7 @@ export function CbzReader({ verticalAnchorFrameRef.current = requestAnimationFrame(measure); }, - [clearVerticalAnchorFrame, commitVisiblePage] + [clearVerticalAnchorFrame, commitVisiblePage, scheduleInitialVisibleSync] ); const go = useCallback( @@ -198,14 +232,19 @@ export function CbzReader({ verticalTrackingReadyRef.current = false; verticalUserScrollRef.current = false; verticalAnchorTargetPageRef.current = null; + verticalInitialSyncDoneRef.current = false; clearVerticalAnchorFrame(); + clearVerticalInitialSync(); return; } - if (previousModeRef.current !== "vertical" || (!verticalUserScrollRef.current && verticalAnchorTargetPageRef.current !== currentPage)) { + const enteringVertical = previousModeRef.current !== "vertical"; + if (enteringVertical || (!verticalUserScrollRef.current && verticalAnchorTargetPageRef.current !== currentPage)) { pendingVerticalAnchorRef.current = true; verticalTrackingReadyRef.current = false; verticalUserScrollRef.current = false; + if (enteringVertical) verticalInitialSyncDoneRef.current = false; clearVerticalAnchorFrame(); + clearVerticalInitialSync(); } if (pendingVerticalAnchorRef.current) { const target = frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`); @@ -218,7 +257,7 @@ export function CbzReader({ stabilizeVerticalAnchor(currentPage); } previousModeRef.current = mode; - }, [clearVerticalAnchorFrame, currentPage, mode, pages, stabilizeVerticalAnchor]); + }, [clearVerticalAnchorFrame, clearVerticalInitialSync, currentPage, mode, pages, stabilizeVerticalAnchor]); useEffect(() => { if (mode !== "vertical" || !pages) return; @@ -245,6 +284,7 @@ export function CbzReader({ stage.addEventListener("pointerdown", markUserScroll, { passive: true }); keyTarget?.addEventListener("keydown", markUserScrollKey); stage.addEventListener("scroll", onScroll, { passive: true }); + scheduleInitialVisibleSync(stage); updateVisiblePage(); return () => { if (frameId) cancelAnimationFrame(frameId); @@ -254,9 +294,10 @@ export function CbzReader({ keyTarget?.removeEventListener("keydown", markUserScrollKey); stage.removeEventListener("scroll", onScroll); }; - }, [commitVisiblePage, mode, pages]); + }, [commitVisiblePage, mode, pages, scheduleInitialVisibleSync]); useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]); + useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]); if (documentError) { return ( diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx index 1afcc9f..d4b53ec 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -32,6 +32,7 @@ type PdfPageStatus = "idle" | "loading" | PdfRenderResult | "render-failed"; const VERTICAL_ANCHOR_TOLERANCE_PX = 24; const VERTICAL_ANCHOR_STABLE_FRAMES = 18; const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180; +const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180; function pdfTechnicalMessage(error: unknown) { if (error instanceof Error && error.message.trim()) return `${error.name}: ${error.message}`; @@ -159,6 +160,9 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon const verticalUserScrollRef = useRef(false); const verticalAnchorTargetPageRef = useRef(null); const verticalAnchorFrameRef = useRef(null); + const verticalInitialSyncFrameRef = useRef(null); + const verticalInitialSyncAttemptRef = useRef(0); + const verticalInitialSyncDoneRef = useRef(false); const verticalAnchorAttemptRef = useRef(0); const verticalAnchorStableFramesRef = useRef(0); const [documentProxy, setDocumentProxy] = useState(null); @@ -179,10 +183,15 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon if (verticalAnchorFrameRef.current !== null) cancelAnimationFrame(verticalAnchorFrameRef.current); verticalAnchorFrameRef.current = null; }, []); + const clearVerticalInitialSync = useCallback(() => { + if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current); + verticalInitialSyncFrameRef.current = null; + }, []); const commitVisiblePage = useCallback( (stage: HTMLElement, allowInitialCommit = false) => { if (!verticalTrackingReadyRef.current) return; if (!allowInitialCommit && !verticalUserScrollRef.current) return; + if (allowInitialCommit && verticalInitialSyncDoneRef.current) return; const stageRect = stage.getBoundingClientRect(); const visiblePage = majorityVisiblePage( Array.from(stage.querySelectorAll("[data-reader-page]")).map((element) => { @@ -192,10 +201,34 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon stageRect.top, stageRect.bottom ); - if (visiblePage && visiblePage !== currentPage) onPageCommit(clampReaderPage(visiblePage, pages), pages, 1, "queued"); + if (visiblePage && visiblePage !== currentPage) { + if (allowInitialCommit) verticalInitialSyncDoneRef.current = true; + onPageCommit(clampReaderPage(visiblePage, pages), pages, 1, "queued"); + } }, [currentPage, onPageCommit, pages] ); + const scheduleInitialVisibleSync = useCallback( + (stage: HTMLElement) => { + clearVerticalInitialSync(); + if (!(stage.scrollHeight > stage.clientHeight)) return; + verticalInitialSyncAttemptRef.current = 0; + + const sync = () => { + verticalInitialSyncFrameRef.current = null; + if (verticalUserScrollRef.current || verticalInitialSyncDoneRef.current) return; + if (verticalTrackingReadyRef.current && stage.scrollTop > Math.max(32, stage.clientHeight * 0.5)) { + commitVisiblePage(stage, true); + } + verticalInitialSyncAttemptRef.current += 1; + if (verticalInitialSyncAttemptRef.current >= VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS) return; + verticalInitialSyncFrameRef.current = requestAnimationFrame(sync); + }; + + verticalInitialSyncFrameRef.current = requestAnimationFrame(sync); + }, + [clearVerticalInitialSync, commitVisiblePage] + ); const stabilizeVerticalAnchor = useCallback( (targetPage: number) => { clearVerticalAnchorFrame(); @@ -221,6 +254,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon if (verticalAnchorStableFramesRef.current >= VERTICAL_ANCHOR_STABLE_FRAMES) { verticalTrackingReadyRef.current = true; commitVisiblePage(stage, true); + scheduleInitialVisibleSync(stage); return; } verticalAnchorAttemptRef.current += 1; @@ -231,7 +265,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon verticalAnchorFrameRef.current = requestAnimationFrame(measure); }, - [clearVerticalAnchorFrame, commitVisiblePage] + [clearVerticalAnchorFrame, commitVisiblePage, scheduleInitialVisibleSync] ); const go = useCallback( @@ -264,14 +298,19 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon verticalTrackingReadyRef.current = false; verticalUserScrollRef.current = false; verticalAnchorTargetPageRef.current = null; + verticalInitialSyncDoneRef.current = false; clearVerticalAnchorFrame(); + clearVerticalInitialSync(); return; } - if (previousModeRef.current !== "vertical" || (!verticalUserScrollRef.current && verticalAnchorTargetPageRef.current !== currentPage)) { + const enteringVertical = previousModeRef.current !== "vertical"; + if (enteringVertical || (!verticalUserScrollRef.current && verticalAnchorTargetPageRef.current !== currentPage)) { pendingVerticalAnchorRef.current = true; verticalTrackingReadyRef.current = false; verticalUserScrollRef.current = false; + if (enteringVertical) verticalInitialSyncDoneRef.current = false; clearVerticalAnchorFrame(); + clearVerticalInitialSync(); } if (pendingVerticalAnchorRef.current) { const target = frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`); @@ -284,7 +323,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon stabilizeVerticalAnchor(currentPage); } previousModeRef.current = mode; - }, [clearVerticalAnchorFrame, currentPage, documentProxy, mode, stabilizeVerticalAnchor]); + }, [clearVerticalAnchorFrame, clearVerticalInitialSync, currentPage, documentProxy, mode, stabilizeVerticalAnchor]); useEffect(() => { if (mode !== "vertical") return; @@ -311,6 +350,7 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon stage.addEventListener("pointerdown", markUserScroll, { passive: true }); keyTarget?.addEventListener("keydown", markUserScrollKey); stage.addEventListener("scroll", onScroll, { passive: true }); + scheduleInitialVisibleSync(stage); updateVisiblePage(); return () => { if (frameId) cancelAnimationFrame(frameId); @@ -320,9 +360,10 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon keyTarget?.removeEventListener("keydown", markUserScrollKey); stage.removeEventListener("scroll", onScroll); }; - }, [commitVisiblePage, mode, pages]); + }, [commitVisiblePage, mode, pages, scheduleInitialVisibleSync]); useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]); + useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]); useEffect(() => { const frame = frameRef.current;