diff --git a/apps/web/src/pages/ReaderPageVerticalRestore.test.ts b/apps/web/src/pages/ReaderPageVerticalRestore.test.ts index d533831..859fc13 100644 --- a/apps/web/src/pages/ReaderPageVerticalRestore.test.ts +++ b/apps/web/src/pages/ReaderPageVerticalRestore.test.ts @@ -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; +}; + +const runtime = vi.hoisted(() => ({ + stateIndex: 0, + refIndex: 0, + states: [] as unknown[], + frame: null as { closest: ReturnType; querySelector: ReturnType } | null, + previousMode: "vertical", + effects: [] as Array<() => void | (() => void)>, + pageElements: [] as ReaderPageElement[] +})); + +vi.mock("react", async () => { + const actual = await vi.importActual("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" }); }); }); diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index c1c33a7..dcb639e 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -23,7 +23,8 @@ export function CbzReader({ onControlsChange: (controls: ReaderControls) => void; }) { const frameRef = useRef(null); - const previousModeRef = useRef(mode); + const previousModeRef = useRef(null); + const pendingVerticalAnchorRef = useRef(false); const [pages, setPages] = useState(null); const [documentError, setDocumentError] = useState(); const [pageError, setPageError] = useState(); @@ -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(`[data-reader-page="${currentPage}"]`); + if (!target) { + previousModeRef.current = mode; + return; + } + pendingVerticalAnchorRef.current = false; requestAnimationFrame(() => { - frameRef.current?.querySelector(`[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; diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx index ec62213..07bea01 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -149,7 +149,8 @@ function PdfPageCanvas({ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onControlsChange }: PdfReaderProps) { const frameRef = useRef(null); const canvasRef = useRef(null); - const previousModeRef = useRef(mode); + const previousModeRef = useRef(null); + const pendingVerticalAnchorRef = useRef(false); const [documentProxy, setDocumentProxy] = useState(null); const [pages, setPages] = useState(1); const [documentError, setDocumentError] = useState(); @@ -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(`[data-reader-page="${currentPage}"]`); + if (!target) { + previousModeRef.current = mode; + return; + } + pendingVerticalAnchorRef.current = false; requestAnimationFrame(() => { - frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`)?.scrollIntoView({ block: "start" }); + target.scrollIntoView({ block: "start" }); }); } previousModeRef.current = mode; - }, [currentPage, mode]); + }, [currentPage, documentProxy, mode]); useEffect(() => { if (mode !== "vertical") return;