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:
Git Agent
2026-08-26 11:23:28 +02:00
parent 8a4451ef52
commit 4e40a7a236
3 changed files with 217 additions and 20 deletions

View File

@ -44,8 +44,8 @@ vi.mock("../api/client", () => ({
api: { api: {
cbzPages: vi.fn().mockResolvedValue({ cbzPages: vi.fn().mockResolvedValue({
bookId: 39, bookId: 39,
pageCount: 80, pageCount: 144,
pages: Array.from({ length: 80 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` })) pages: Array.from({ length: 144 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` }))
}), }),
cbzPageUrl: (bookId: number, page: number) => `/books/${bookId}/pages/${page}` cbzPageUrl: (bookId: number, page: number) => `/books/${bookId}/pages/${page}`
} }
@ -53,6 +53,23 @@ vi.mock("../api/client", () => ({
import { CbzReader } from "../reader/CbzReader"; import { CbzReader } from "../reader/CbzReader";
type ElementLike = {
type: unknown;
props?: Record<string, unknown> & { children?: unknown };
};
function isElementLike(value: unknown): value is ElementLike {
return Boolean(value && typeof value === "object" && "type" in value);
}
function findElementsByType(node: unknown, type: string): ElementLike[] {
if (Array.isArray(node)) return node.flatMap((child) => findElementsByType(child, type));
if (!isElementLike(node)) return [];
const matches = node.type === type ? [node] : [];
return [...matches, ...findElementsByType(node.props?.children, type)];
}
function pageElement(page: number, top: number, height = 1000): ReaderPageElement { function pageElement(page: number, top: number, height = 1000): ReaderPageElement {
return { return {
dataset: { readerPage: String(page) }, dataset: { readerPage: String(page) },
@ -70,7 +87,7 @@ describe("ReaderPage vertical restore", () => {
runtime.stateIndex = 0; runtime.stateIndex = 0;
runtime.refIndex = 0; runtime.refIndex = 0;
runtime.effects = []; runtime.effects = [];
runtime.pageElements = Array.from({ length: 80 }, (_, index) => pageElement(index + 1, index * 1000)); runtime.pageElements = Array.from({ length: 144 }, (_, index) => pageElement(index + 1, index * 1000));
runtime.frame = { runtime.frame = {
closest: vi.fn(() => ({ closest: vi.fn(() => ({
getBoundingClientRect: () => ({ top: 0, bottom: 900 }), getBoundingClientRect: () => ({ top: 0, bottom: 900 }),
@ -86,8 +103,8 @@ describe("ReaderPage vertical restore", () => {
runtime.states = [ runtime.states = [
{ {
bookId: 39, bookId: 39,
pageCount: 80, pageCount: 144,
pages: Array.from({ length: 80 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` })) pages: Array.from({ length: 144 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` }))
}, },
undefined, undefined,
undefined, undefined,
@ -111,18 +128,50 @@ describe("ReaderPage vertical restore", () => {
); );
}); });
it("anchors the current CBZ/CBR page when the reader opens directly in vertical mode", () => { it("anchors page 73 of a CBZ/CBR and keeps previous pages reachable in vertical mode", () => {
let controls: { onPrevious: () => void } | undefined;
const onPageCommit = vi.fn();
CbzReader({ CbzReader({
bookId: 39, bookId: 39,
page: 40, page: 73,
zoom: 100, zoom: 100,
mode: "vertical", mode: "vertical",
onPageCommit: vi.fn(), onPageCommit,
onControlsChange: vi.fn() onControlsChange: (nextControls) => {
controls = nextControls;
}
}); });
runEffects(); runEffects();
expect(runtime.pageElements[39].scrollIntoView).toHaveBeenCalledWith({ block: "start" }); expect(runtime.pageElements[72].scrollIntoView).toHaveBeenCalledWith({ block: "start" });
runtime.stateIndex = 0;
runtime.refIndex = 0;
runtime.effects = [];
const tree = CbzReader({
bookId: 39,
page: 73,
zoom: 100,
mode: "vertical",
onPageCommit,
onControlsChange: vi.fn()
});
const figures = findElementsByType(tree, "figure");
const images = findElementsByType(tree, "img");
expect(figures).toHaveLength(144);
expect(figures[0].props?.["data-reader-page"]).toBe(1);
expect(figures[71].props?.["data-reader-page"]).toBe(72);
expect(figures[72].props?.["data-reader-page"]).toBe(73);
expect(images[0].props?.src).toBe("/books/39/pages/1");
expect(images[71].props?.src).toBe("/books/39/pages/72");
expect(images[71].props?.loading).toBe("lazy");
expect(images[71].props).not.toHaveProperty("hidden");
controls?.onPrevious();
expect(runtime.pageElements[71].scrollIntoView).toHaveBeenCalledWith({ block: "start" });
expect(onPageCommit).toHaveBeenCalledWith(72, 144, 1, "immediate");
}); });
}); });

View File

@ -7,6 +7,10 @@ import type { ReaderControls } from "./ReaderShell";
type PageCommitStrategy = "immediate" | "queued"; 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({ export function CbzReader({
bookId, bookId,
page, page,
@ -25,6 +29,11 @@ export function CbzReader({
const frameRef = useRef<HTMLDivElement>(null); const frameRef = useRef<HTMLDivElement>(null);
const previousModeRef = useRef<ReaderMode | null>(null); const previousModeRef = useRef<ReaderMode | null>(null);
const pendingVerticalAnchorRef = useRef(false); 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 [pages, setPages] = useState<CbzPagesDto | null>(null);
const [documentError, setDocumentError] = useState<string>(); const [documentError, setDocumentError] = useState<string>();
const [pageError, setPageError] = useState<string>(); const [pageError, setPageError] = useState<string>();
@ -69,6 +78,46 @@ export function CbzReader({
}, },
[imageSizes, viewportSize, zoom] [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( const go = useCallback(
(nextPage: number) => { (nextPage: number) => {
@ -76,6 +125,7 @@ export function CbzReader({
const target = clampReaderPage(nextPage, pages.pageCount); const target = clampReaderPage(nextPage, pages.pageCount);
setPageError(undefined); setPageError(undefined);
if (mode === "vertical") { if (mode === "vertical") {
verticalUserScrollRef.current = false;
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
} }
onPageCommit(target, pages.pageCount, 1, "immediate"); onPageCommit(target, pages.pageCount, 1, "immediate");
@ -126,9 +176,17 @@ export function CbzReader({
if (mode !== "vertical") { if (mode !== "vertical") {
previousModeRef.current = mode; previousModeRef.current = mode;
pendingVerticalAnchorRef.current = false; pendingVerticalAnchorRef.current = false;
verticalTrackingReadyRef.current = false;
verticalUserScrollRef.current = false;
clearVerticalAnchorFrame();
return; 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) { if (pendingVerticalAnchorRef.current) {
const target = frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`); const target = frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`);
if (!target) { if (!target) {
@ -136,20 +194,26 @@ export function CbzReader({
return; return;
} }
pendingVerticalAnchorRef.current = false; pendingVerticalAnchorRef.current = false;
requestAnimationFrame(() => { stabilizeVerticalAnchor(currentPage);
target.scrollIntoView({ block: "start" });
});
} }
previousModeRef.current = mode; previousModeRef.current = mode;
}, [currentPage, mode, pages]); }, [clearVerticalAnchorFrame, currentPage, mode, pages, stabilizeVerticalAnchor]);
useEffect(() => { useEffect(() => {
if (mode !== "vertical" || !pages) return; if (mode !== "vertical" || !pages) return;
const stage = frameRef.current?.closest(".reader-stage") as HTMLElement | null; const stage = frameRef.current?.closest(".reader-stage") as HTMLElement | null;
if (!stage) return; if (!stage) return;
let frameId = 0; 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 = () => { const updateVisiblePage = () => {
frameId = 0; frameId = 0;
if (!verticalTrackingReadyRef.current || !verticalUserScrollRef.current) return;
const stageRect = stage.getBoundingClientRect(); const stageRect = stage.getBoundingClientRect();
const visiblePage = majorityVisiblePage( const visiblePage = majorityVisiblePage(
Array.from(stage.querySelectorAll<HTMLElement>("[data-reader-page]")).map((element) => { Array.from(stage.querySelectorAll<HTMLElement>("[data-reader-page]")).map((element) => {
@ -165,14 +229,24 @@ export function CbzReader({
if (frameId) return; if (frameId) return;
frameId = requestAnimationFrame(updateVisiblePage); 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 }); stage.addEventListener("scroll", onScroll, { passive: true });
updateVisiblePage(); updateVisiblePage();
return () => { return () => {
if (frameId) cancelAnimationFrame(frameId); if (frameId) cancelAnimationFrame(frameId);
stage.removeEventListener("wheel", markUserScroll);
stage.removeEventListener("touchmove", markUserScroll);
stage.removeEventListener("pointerdown", markUserScroll);
keyTarget?.removeEventListener("keydown", markUserScrollKey);
stage.removeEventListener("scroll", onScroll); stage.removeEventListener("scroll", onScroll);
}; };
}, [currentPage, mode, onPageCommit, pages]); }, [currentPage, mode, onPageCommit, pages]);
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
if (documentError) { if (documentError) {
return ( return (
<div className="cbz-reader" ref={frameRef}> <div className="cbz-reader" ref={frameRef}>

View File

@ -29,6 +29,10 @@ type PdfFailure = {
type PdfPageStatus = "idle" | "loading" | PdfRenderResult | "render-failed"; 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;
function pdfTechnicalMessage(error: unknown) { function pdfTechnicalMessage(error: unknown) {
if (error instanceof Error && error.message.trim()) return `${error.name}: ${error.message}`; if (error instanceof Error && error.message.trim()) return `${error.name}: ${error.message}`;
if (typeof error === "string" && error.trim()) return error; if (typeof error === "string" && error.trim()) return error;
@ -151,6 +155,11 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const previousModeRef = useRef<ReaderMode | null>(null); const previousModeRef = useRef<ReaderMode | null>(null);
const pendingVerticalAnchorRef = useRef(false); 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 [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null); const [documentProxy, setDocumentProxy] = useState<pdfjs.PDFDocumentProxy | null>(null);
const [pages, setPages] = useState(1); const [pages, setPages] = useState(1);
const [documentError, setDocumentError] = useState<PdfFailure>(); const [documentError, setDocumentError] = useState<PdfFailure>();
@ -165,12 +174,53 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
const currentPage = clampReaderPage(page, pages); const currentPage = clampReaderPage(page, pages);
const showCanvas = pdfCanvasVisible(pageRendered, Boolean(pageError)); const showCanvas = pdfCanvasVisible(pageRendered, Boolean(pageError));
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 = scrollContainerFor(frame);
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( const go = useCallback(
(nextPage: number) => { (nextPage: number) => {
const target = clampReaderPage(nextPage, pages); const target = clampReaderPage(nextPage, pages);
setPageError(undefined); setPageError(undefined);
if (mode === "vertical") { if (mode === "vertical") {
verticalUserScrollRef.current = false;
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" });
} }
onPageCommit(target, pages, 1, "immediate"); onPageCommit(target, pages, 1, "immediate");
@ -192,9 +242,17 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
if (mode !== "vertical") { if (mode !== "vertical") {
previousModeRef.current = mode; previousModeRef.current = mode;
pendingVerticalAnchorRef.current = false; pendingVerticalAnchorRef.current = false;
verticalTrackingReadyRef.current = false;
verticalUserScrollRef.current = false;
clearVerticalAnchorFrame();
return; 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) { if (pendingVerticalAnchorRef.current) {
const target = frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`); const target = frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`);
if (!target) { if (!target) {
@ -202,20 +260,26 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
return; return;
} }
pendingVerticalAnchorRef.current = false; pendingVerticalAnchorRef.current = false;
requestAnimationFrame(() => { stabilizeVerticalAnchor(currentPage);
target.scrollIntoView({ block: "start" });
});
} }
previousModeRef.current = mode; previousModeRef.current = mode;
}, [currentPage, documentProxy, mode]); }, [clearVerticalAnchorFrame, currentPage, documentProxy, mode, stabilizeVerticalAnchor]);
useEffect(() => { useEffect(() => {
if (mode !== "vertical") return; if (mode !== "vertical") return;
const stage = scrollContainerFor(frameRef.current); const stage = scrollContainerFor(frameRef.current);
if (!stage) return; if (!stage) return;
let frameId = 0; 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 = () => { const updateVisiblePage = () => {
frameId = 0; frameId = 0;
if (!verticalTrackingReadyRef.current || !verticalUserScrollRef.current) return;
const stageRect = stage.getBoundingClientRect(); const stageRect = stage.getBoundingClientRect();
const visiblePage = majorityVisiblePage( const visiblePage = majorityVisiblePage(
Array.from(stage.querySelectorAll<HTMLElement>("[data-reader-page]")).map((element) => { Array.from(stage.querySelectorAll<HTMLElement>("[data-reader-page]")).map((element) => {
@ -231,14 +295,24 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
if (frameId) return; if (frameId) return;
frameId = requestAnimationFrame(updateVisiblePage); 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 }); stage.addEventListener("scroll", onScroll, { passive: true });
updateVisiblePage(); updateVisiblePage();
return () => { return () => {
if (frameId) cancelAnimationFrame(frameId); if (frameId) cancelAnimationFrame(frameId);
stage.removeEventListener("wheel", markUserScroll);
stage.removeEventListener("touchmove", markUserScroll);
stage.removeEventListener("pointerdown", markUserScroll);
keyTarget?.removeEventListener("keydown", markUserScrollKey);
stage.removeEventListener("scroll", onScroll); stage.removeEventListener("scroll", onScroll);
}; };
}, [currentPage, mode, onPageCommit, pages]); }, [currentPage, mode, onPageCommit, pages]);
useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]);
useEffect(() => { useEffect(() => {
const frame = frameRef.current; const frame = frameRef.current;
const stage = scrollContainerFor(frame); const stage = scrollContainerFor(frame);