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:
@ -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" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user