fix(web): lecteurs CBZ/CBR et PDF — stabiliser l'ancrage vertical jusqu'à stabilisation du rendu et ne suivre la page visible qu'après un vrai scroll utilisateur (3e correctif bug #39)
This commit is contained in:
@ -7,6 +7,10 @@ import type { ReaderControls } from "./ReaderShell";
|
||||
|
||||
type PageCommitStrategy = "immediate" | "queued";
|
||||
|
||||
const VERTICAL_ANCHOR_TOLERANCE_PX = 24;
|
||||
const VERTICAL_ANCHOR_STABLE_FRAMES = 18;
|
||||
const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180;
|
||||
|
||||
export function CbzReader({
|
||||
bookId,
|
||||
page,
|
||||
@ -25,6 +29,11 @@ export function CbzReader({
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
const previousModeRef = useRef<ReaderMode | null>(null);
|
||||
const pendingVerticalAnchorRef = useRef(false);
|
||||
const verticalTrackingReadyRef = useRef(false);
|
||||
const verticalUserScrollRef = useRef(false);
|
||||
const verticalAnchorFrameRef = useRef<number | null>(null);
|
||||
const verticalAnchorAttemptRef = useRef(0);
|
||||
const verticalAnchorStableFramesRef = useRef(0);
|
||||
const [pages, setPages] = useState<CbzPagesDto | null>(null);
|
||||
const [documentError, setDocumentError] = useState<string>();
|
||||
const [pageError, setPageError] = useState<string>();
|
||||
@ -69,6 +78,46 @@ export function CbzReader({
|
||||
},
|
||||
[imageSizes, viewportSize, zoom]
|
||||
);
|
||||
const clearVerticalAnchorFrame = useCallback(() => {
|
||||
if (verticalAnchorFrameRef.current !== null) cancelAnimationFrame(verticalAnchorFrameRef.current);
|
||||
verticalAnchorFrameRef.current = null;
|
||||
}, []);
|
||||
const stabilizeVerticalAnchor = useCallback(
|
||||
(targetPage: number) => {
|
||||
clearVerticalAnchorFrame();
|
||||
verticalAnchorAttemptRef.current = 0;
|
||||
verticalAnchorStableFramesRef.current = 0;
|
||||
verticalTrackingReadyRef.current = false;
|
||||
|
||||
const measure = () => {
|
||||
const frame = frameRef.current;
|
||||
const stage = frame?.closest(".reader-stage") as HTMLElement | null;
|
||||
const target = frame?.querySelector<HTMLElement>(`[data-reader-page="${targetPage}"]`);
|
||||
if (!stage || !target) return;
|
||||
|
||||
target.scrollIntoView({ block: "start" });
|
||||
verticalAnchorFrameRef.current = requestAnimationFrame(() => {
|
||||
verticalAnchorFrameRef.current = null;
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const aligned = Math.abs(targetRect.top - stageRect.top) <= VERTICAL_ANCHOR_TOLERANCE_PX;
|
||||
const cannotScrollFurther = stage.scrollTop + stage.clientHeight >= stage.scrollHeight - 2;
|
||||
verticalAnchorStableFramesRef.current = aligned || cannotScrollFurther ? verticalAnchorStableFramesRef.current + 1 : 0;
|
||||
|
||||
if (verticalAnchorStableFramesRef.current >= VERTICAL_ANCHOR_STABLE_FRAMES) {
|
||||
verticalTrackingReadyRef.current = true;
|
||||
return;
|
||||
}
|
||||
verticalAnchorAttemptRef.current += 1;
|
||||
if (verticalAnchorAttemptRef.current >= VERTICAL_ANCHOR_MAX_ATTEMPTS) return;
|
||||
verticalAnchorFrameRef.current = requestAnimationFrame(measure);
|
||||
});
|
||||
};
|
||||
|
||||
verticalAnchorFrameRef.current = requestAnimationFrame(measure);
|
||||
},
|
||||
[clearVerticalAnchorFrame]
|
||||
);
|
||||
|
||||
const go = useCallback(
|
||||
(nextPage: number) => {
|
||||
@ -76,6 +125,7 @@ export function CbzReader({
|
||||
const target = clampReaderPage(nextPage, pages.pageCount);
|
||||
setPageError(undefined);
|
||||
if (mode === "vertical") {
|
||||
verticalUserScrollRef.current = false;
|
||||
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
|
||||
}
|
||||
onPageCommit(target, pages.pageCount, 1, "immediate");
|
||||
@ -126,9 +176,17 @@ export function CbzReader({
|
||||
if (mode !== "vertical") {
|
||||
previousModeRef.current = mode;
|
||||
pendingVerticalAnchorRef.current = false;
|
||||
verticalTrackingReadyRef.current = false;
|
||||
verticalUserScrollRef.current = false;
|
||||
clearVerticalAnchorFrame();
|
||||
return;
|
||||
}
|
||||
if (previousModeRef.current !== "vertical") pendingVerticalAnchorRef.current = true;
|
||||
if (previousModeRef.current !== "vertical") {
|
||||
pendingVerticalAnchorRef.current = true;
|
||||
verticalTrackingReadyRef.current = false;
|
||||
verticalUserScrollRef.current = false;
|
||||
clearVerticalAnchorFrame();
|
||||
}
|
||||
if (pendingVerticalAnchorRef.current) {
|
||||
const target = frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`);
|
||||
if (!target) {
|
||||
@ -136,20 +194,26 @@ export function CbzReader({
|
||||
return;
|
||||
}
|
||||
pendingVerticalAnchorRef.current = false;
|
||||
requestAnimationFrame(() => {
|
||||
target.scrollIntoView({ block: "start" });
|
||||
});
|
||||
stabilizeVerticalAnchor(currentPage);
|
||||
}
|
||||
previousModeRef.current = mode;
|
||||
}, [currentPage, mode, pages]);
|
||||
}, [clearVerticalAnchorFrame, currentPage, mode, pages, stabilizeVerticalAnchor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "vertical" || !pages) return;
|
||||
const stage = frameRef.current?.closest(".reader-stage") as HTMLElement | null;
|
||||
if (!stage) return;
|
||||
let frameId = 0;
|
||||
const markUserScroll = () => {
|
||||
verticalUserScrollRef.current = true;
|
||||
};
|
||||
const markUserScrollKey = (event: KeyboardEvent) => {
|
||||
if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"].includes(event.key)) markUserScroll();
|
||||
};
|
||||
const keyTarget = typeof window === "undefined" ? null : window;
|
||||
const updateVisiblePage = () => {
|
||||
frameId = 0;
|
||||
if (!verticalTrackingReadyRef.current || !verticalUserScrollRef.current) return;
|
||||
const stageRect = stage.getBoundingClientRect();
|
||||
const visiblePage = majorityVisiblePage(
|
||||
Array.from(stage.querySelectorAll<HTMLElement>("[data-reader-page]")).map((element) => {
|
||||
@ -165,14 +229,24 @@ export function CbzReader({
|
||||
if (frameId) return;
|
||||
frameId = requestAnimationFrame(updateVisiblePage);
|
||||
};
|
||||
stage.addEventListener("wheel", markUserScroll, { passive: true });
|
||||
stage.addEventListener("touchmove", markUserScroll, { passive: true });
|
||||
stage.addEventListener("pointerdown", markUserScroll, { passive: true });
|
||||
keyTarget?.addEventListener("keydown", markUserScrollKey);
|
||||
stage.addEventListener("scroll", onScroll, { passive: true });
|
||||
updateVisiblePage();
|
||||
return () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
stage.removeEventListener("wheel", markUserScroll);
|
||||
stage.removeEventListener("touchmove", markUserScroll);
|
||||
stage.removeEventListener("pointerdown", markUserScroll);
|
||||
keyTarget?.removeEventListener("keydown", markUserScrollKey);
|
||||
stage.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [currentPage, mode, onPageCommit, pages]);
|
||||
|
||||
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
|
||||
|
||||
if (documentError) {
|
||||
return (
|
||||
<div className="cbz-reader" ref={frameRef}>
|
||||
|
||||
Reference in New Issue
Block a user