merge: fix/39-reader-vertical-scroll-regression dans develop (sync initiale de la page visible en vertical avec garde anti-boucle — 5e correctif #39)

This commit is contained in:
Git Agent
2026-08-26 11:48:39 +02:00
2 changed files with 92 additions and 10 deletions

View File

@ -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<number | null>(null);
const verticalAnchorFrameRef = useRef<number | null>(null);
const verticalInitialSyncFrameRef = useRef<number | null>(null);
const verticalInitialSyncAttemptRef = useRef(0);
const verticalInitialSyncDoneRef = useRef(false);
const verticalAnchorAttemptRef = useRef(0);
const verticalAnchorStableFramesRef = useRef(0);
const [pages, setPages] = useState<CbzPagesDto | null>(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<HTMLElement>("[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<HTMLElement>(`[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 (

View File

@ -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<number | null>(null);
const verticalAnchorFrameRef = useRef<number | null>(null);
const verticalInitialSyncFrameRef = useRef<number | null>(null);
const verticalInitialSyncAttemptRef = useRef(0);
const verticalInitialSyncDoneRef = useRef(false);
const verticalAnchorAttemptRef = useRef(0);
const verticalAnchorStableFramesRef = useRef(0);
const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(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<HTMLElement>("[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<HTMLElement>(`[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;