fix(web): lecteurs CBZ/CBR et PDF — ancrer la page courante au passage en mode vertical une fois les pages rendues, sans saut au milieu (correctif complémentaire bug #39)

This commit is contained in:
Git Agent
2026-08-26 10:34:56 +02:00
parent da7206440f
commit 53e681565e
3 changed files with 147 additions and 20 deletions

View File

@ -1,19 +1,128 @@
import { readFileSync } from "node:fs"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
type ReaderPageElement = {
dataset: { readerPage: string };
getBoundingClientRect: () => { top: number; bottom: number };
scrollIntoView: ReturnType<typeof vi.fn>;
};
const runtime = vi.hoisted(() => ({
stateIndex: 0,
refIndex: 0,
states: [] as unknown[],
frame: null as { closest: ReturnType<typeof vi.fn>; querySelector: ReturnType<typeof vi.fn> } | null,
previousMode: "vertical",
effects: [] as Array<() => void | (() => void)>,
pageElements: [] as ReaderPageElement[]
}));
vi.mock("react", async () => {
const actual = await vi.importActual<typeof import("react")>("react");
return {
...actual,
useCallback: (callback: unknown) => callback,
useEffect: (effect: () => void | (() => void)) => {
runtime.effects.push(effect);
},
useRef: (initial: unknown) => {
if (runtime.refIndex === 0) {
runtime.refIndex += 1;
return { current: runtime.frame };
}
runtime.refIndex += 1;
return { current: initial };
},
useState: (initial: unknown) => {
const index = runtime.stateIndex;
runtime.stateIndex += 1;
return [runtime.states[index] ?? initial, vi.fn()];
}
};
});
vi.mock("../api/client", () => ({
api: {
cbzPages: vi.fn().mockResolvedValue({
bookId: 39,
pageCount: 80,
pages: Array.from({ length: 80 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` }))
}),
cbzPageUrl: (bookId: number, page: number) => `/books/${bookId}/pages/${page}`
}
}));
import { CbzReader } from "../reader/CbzReader";
function pageElement(page: number, top: number, height = 1000): ReaderPageElement {
return {
dataset: { readerPage: String(page) },
getBoundingClientRect: () => ({ top, bottom: top + height }),
scrollIntoView: vi.fn()
};
}
function runEffects() {
for (const effect of runtime.effects) effect();
}
describe("ReaderPage vertical restore", () => { describe("ReaderPage vertical restore", () => {
it("resets the reader page when opening another book so an invalid progression starts at the top", () => { beforeEach(() => {
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8"); runtime.stateIndex = 0;
const bookResetEffect = source.slice(source.indexOf("useEffect(() => {\n setReaderControls"), source.indexOf(" useEffect(() => {\n if (book?.format", source.indexOf("useEffect(() => {\n setReaderControls"))); runtime.refIndex = 0;
runtime.effects = [];
expect(bookResetEffect).toContain("setPage(1)"); runtime.pageElements = Array.from({ length: 80 }, (_, index) => pageElement(index + 1, index * 1000));
runtime.frame = {
closest: vi.fn(() => ({
getBoundingClientRect: () => ({ top: 0, bottom: 900 }),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
querySelectorAll: vi.fn(() => runtime.pageElements)
})),
querySelector: vi.fn((selector: string) => {
const match = selector.match(/\[data-reader-page="(\d+)"\]/);
return match ? runtime.pageElements[Number(match[1]) - 1] : null;
})
};
runtime.states = [
{
bookId: 39,
pageCount: 80,
pages: Array.from({ length: 80 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` }))
},
undefined,
undefined,
0,
0,
{ width: 900, height: 900 },
null,
{}
];
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
callback(0);
return 1;
});
vi.stubGlobal("cancelAnimationFrame", vi.fn());
vi.stubGlobal(
"ResizeObserver",
vi.fn(() => ({
observe: vi.fn(),
disconnect: vi.fn()
}))
);
}); });
it("does not restore an unknown vertical scroll anchor to the middle of the book", () => { it("anchors the current CBZ/CBR page when the reader opens directly in vertical mode", () => {
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8"); CbzReader({
const changeZoomBlock = source.slice(source.indexOf("const changeZoom = useCallback"), source.indexOf(" const zoomControls", source.indexOf("const changeZoom = useCallback"))); bookId: 39,
page: 40,
zoom: 100,
mode: "vertical",
onPageCommit: vi.fn(),
onControlsChange: vi.fn()
});
expect(changeZoomBlock).toContain("scrollRatioY"); runEffects();
expect(changeZoomBlock).not.toMatch(/scrollRatioY[\s\S]*:\s*0\.5/);
expect(runtime.pageElements[39].scrollIntoView).toHaveBeenCalledWith({ block: "start" });
}); });
}); });

View File

@ -23,7 +23,8 @@ export function CbzReader({
onControlsChange: (controls: ReaderControls) => void; onControlsChange: (controls: ReaderControls) => void;
}) { }) {
const frameRef = useRef<HTMLDivElement>(null); const frameRef = useRef<HTMLDivElement>(null);
const previousModeRef = useRef<ReaderMode>(mode); const previousModeRef = useRef<ReaderMode | null>(null);
const pendingVerticalAnchorRef = useRef(false);
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>();
@ -124,15 +125,23 @@ export function CbzReader({
useEffect(() => { useEffect(() => {
if (mode !== "vertical") { if (mode !== "vertical") {
previousModeRef.current = mode; previousModeRef.current = mode;
pendingVerticalAnchorRef.current = false;
return; return;
} }
if (previousModeRef.current !== "vertical") { if (previousModeRef.current !== "vertical") pendingVerticalAnchorRef.current = true;
if (pendingVerticalAnchorRef.current) {
const target = frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`);
if (!target) {
previousModeRef.current = mode;
return;
}
pendingVerticalAnchorRef.current = false;
requestAnimationFrame(() => { requestAnimationFrame(() => {
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" }); target.scrollIntoView({ block: "start" });
}); });
} }
previousModeRef.current = mode; previousModeRef.current = mode;
}, [currentPage, mode]); }, [currentPage, mode, pages]);
useEffect(() => { useEffect(() => {
if (mode !== "vertical" || !pages) return; if (mode !== "vertical" || !pages) return;

View File

@ -149,7 +149,8 @@ function PdfPageCanvas({
export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onControlsChange }: PdfReaderProps) { export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onControlsChange }: PdfReaderProps) {
const frameRef = useRef<HTMLDivElement>(null); const frameRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const previousModeRef = useRef<ReaderMode>(mode); const previousModeRef = useRef<ReaderMode | null>(null);
const pendingVerticalAnchorRef = useRef(false);
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>();
@ -190,15 +191,23 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
useEffect(() => { useEffect(() => {
if (mode !== "vertical") { if (mode !== "vertical") {
previousModeRef.current = mode; previousModeRef.current = mode;
pendingVerticalAnchorRef.current = false;
return; return;
} }
if (previousModeRef.current !== "vertical") { if (previousModeRef.current !== "vertical") pendingVerticalAnchorRef.current = true;
if (pendingVerticalAnchorRef.current) {
const target = frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`);
if (!target) {
previousModeRef.current = mode;
return;
}
pendingVerticalAnchorRef.current = false;
requestAnimationFrame(() => { requestAnimationFrame(() => {
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" }); target.scrollIntoView({ block: "start" });
}); });
} }
previousModeRef.current = mode; previousModeRef.current = mode;
}, [currentPage, mode]); }, [currentPage, documentProxy, mode]);
useEffect(() => { useEffect(() => {
if (mode !== "vertical") return; if (mode !== "vertical") return;