From 4363f8e31a25e78e7160a61845c8b84b34acccf0 Mon Sep 17 00:00:00 2001 From: Git Agent Date: Wed, 26 Aug 2026 11:48:37 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20lecteurs=20CBZ/CBR=20et=20PDF=20?= =?UTF-8?q?=E2=80=94=20synchroniser=20la=20page=20visible=20au=20chargemen?= =?UTF-8?q?t=20en=20mode=20vertical=20avec=20garde=20anti-boucle,=20align?= =?UTF-8?q?=C3=A9e=20sur=20le=20scroll=20initial=20r=C3=A9el=20(5e=20corre?= =?UTF-8?q?ctif=20bug=20#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/reader/CbzReader.tsx | 51 ++++++++++++++++++++++++++++--- apps/web/src/reader/PdfReader.tsx | 51 ++++++++++++++++++++++++++++--- 2 files changed, 92 insertions(+), 10 deletions(-) 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;