merge: fix/39-reader-vertical-scroll-anchor dans develop (ancrage de la page courante au passage en mode vertical — correctif complémentaire #39)
This commit is contained in:
@ -1,19 +1,128 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } 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", () => {
|
||||
it("resets the reader page when opening another book so an invalid progression starts at the top", () => {
|
||||
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8");
|
||||
const bookResetEffect = source.slice(source.indexOf("useEffect(() => {\n setReaderControls"), source.indexOf(" useEffect(() => {\n if (book?.format", source.indexOf("useEffect(() => {\n setReaderControls")));
|
||||
|
||||
expect(bookResetEffect).toContain("setPage(1)");
|
||||
beforeEach(() => {
|
||||
runtime.stateIndex = 0;
|
||||
runtime.refIndex = 0;
|
||||
runtime.effects = [];
|
||||
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", () => {
|
||||
const source = readFileSync(new URL("./ReaderPage.tsx", import.meta.url), "utf8");
|
||||
const changeZoomBlock = source.slice(source.indexOf("const changeZoom = useCallback"), source.indexOf(" const zoomControls", source.indexOf("const changeZoom = useCallback")));
|
||||
it("anchors the current CBZ/CBR page when the reader opens directly in vertical mode", () => {
|
||||
CbzReader({
|
||||
bookId: 39,
|
||||
page: 40,
|
||||
zoom: 100,
|
||||
mode: "vertical",
|
||||
onPageCommit: vi.fn(),
|
||||
onControlsChange: vi.fn()
|
||||
});
|
||||
|
||||
expect(changeZoomBlock).toContain("scrollRatioY");
|
||||
expect(changeZoomBlock).not.toMatch(/scrollRatioY[\s\S]*:\s*0\.5/);
|
||||
runEffects();
|
||||
|
||||
expect(runtime.pageElements[39].scrollIntoView).toHaveBeenCalledWith({ block: "start" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -23,7 +23,8 @@ export function CbzReader({
|
||||
onControlsChange: (controls: ReaderControls) => void;
|
||||
}) {
|
||||
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 [documentError, setDocumentError] = useState<string>();
|
||||
const [pageError, setPageError] = useState<string>();
|
||||
@ -124,15 +125,23 @@ export function CbzReader({
|
||||
useEffect(() => {
|
||||
if (mode !== "vertical") {
|
||||
previousModeRef.current = mode;
|
||||
pendingVerticalAnchorRef.current = false;
|
||||
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(() => {
|
||||
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" });
|
||||
target.scrollIntoView({ block: "start" });
|
||||
});
|
||||
}
|
||||
previousModeRef.current = mode;
|
||||
}, [currentPage, mode]);
|
||||
}, [currentPage, mode, pages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "vertical" || !pages) return;
|
||||
|
||||
@ -149,7 +149,8 @@ function PdfPageCanvas({
|
||||
export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onControlsChange }: PdfReaderProps) {
|
||||
const frameRef = useRef<HTMLDivElement>(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 [pages, setPages] = useState(1);
|
||||
const [documentError, setDocumentError] = useState<PdfFailure>();
|
||||
@ -190,15 +191,23 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon
|
||||
useEffect(() => {
|
||||
if (mode !== "vertical") {
|
||||
previousModeRef.current = mode;
|
||||
pendingVerticalAnchorRef.current = false;
|
||||
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(() => {
|
||||
frameRef.current?.querySelector<HTMLElement>(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" });
|
||||
target.scrollIntoView({ block: "start" });
|
||||
});
|
||||
}
|
||||
previousModeRef.current = mode;
|
||||
}, [currentPage, mode]);
|
||||
}, [currentPage, documentProxy, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "vertical") return;
|
||||
|
||||
Reference in New Issue
Block a user