diff --git a/apps/web/src/pages/ReaderPageVerticalRestore.test.ts b/apps/web/src/pages/ReaderPageVerticalRestore.test.ts index 859fc13..038999b 100644 --- a/apps/web/src/pages/ReaderPageVerticalRestore.test.ts +++ b/apps/web/src/pages/ReaderPageVerticalRestore.test.ts @@ -44,8 +44,8 @@ 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` })) + pageCount: 144, + pages: Array.from({ length: 144 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` })) }), cbzPageUrl: (bookId: number, page: number) => `/books/${bookId}/pages/${page}` } @@ -53,6 +53,23 @@ vi.mock("../api/client", () => ({ import { CbzReader } from "../reader/CbzReader"; +type ElementLike = { + type: unknown; + props?: Record & { 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 { return { dataset: { readerPage: String(page) }, @@ -70,7 +87,7 @@ describe("ReaderPage vertical restore", () => { runtime.stateIndex = 0; runtime.refIndex = 0; 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 = { closest: vi.fn(() => ({ getBoundingClientRect: () => ({ top: 0, bottom: 900 }), @@ -86,8 +103,8 @@ describe("ReaderPage vertical restore", () => { runtime.states = [ { bookId: 39, - pageCount: 80, - pages: Array.from({ length: 80 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` })) + pageCount: 144, + pages: Array.from({ length: 144 }, (_, index) => ({ page: index + 1, name: `page-${index + 1}.jpg` })) }, 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({ bookId: 39, - page: 40, + page: 73, zoom: 100, mode: "vertical", - onPageCommit: vi.fn(), - onControlsChange: vi.fn() + onPageCommit, + onControlsChange: (nextControls) => { + controls = nextControls; + } }); 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"); }); }); diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index dcb639e..dce53c0 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -7,6 +7,10 @@ import type { ReaderControls } from "./ReaderShell"; 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({ bookId, page, @@ -25,6 +29,11 @@ export function CbzReader({ const frameRef = useRef(null); const previousModeRef = useRef(null); const pendingVerticalAnchorRef = useRef(false); + const verticalTrackingReadyRef = useRef(false); + const verticalUserScrollRef = useRef(false); + const verticalAnchorFrameRef = useRef(null); + const verticalAnchorAttemptRef = useRef(0); + const verticalAnchorStableFramesRef = useRef(0); const [pages, setPages] = useState(null); const [documentError, setDocumentError] = useState(); const [pageError, setPageError] = useState(); @@ -69,6 +78,46 @@ export function CbzReader({ }, [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(`[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( (nextPage: number) => { @@ -76,6 +125,7 @@ export function CbzReader({ const target = clampReaderPage(nextPage, pages.pageCount); setPageError(undefined); if (mode === "vertical") { + verticalUserScrollRef.current = false; frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); } onPageCommit(target, pages.pageCount, 1, "immediate"); @@ -126,9 +176,17 @@ export function CbzReader({ if (mode !== "vertical") { previousModeRef.current = mode; pendingVerticalAnchorRef.current = false; + verticalTrackingReadyRef.current = false; + verticalUserScrollRef.current = false; + clearVerticalAnchorFrame(); 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) { const target = frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`); if (!target) { @@ -136,20 +194,26 @@ export function CbzReader({ return; } pendingVerticalAnchorRef.current = false; - requestAnimationFrame(() => { - target.scrollIntoView({ block: "start" }); - }); + stabilizeVerticalAnchor(currentPage); } previousModeRef.current = mode; - }, [currentPage, mode, pages]); + }, [clearVerticalAnchorFrame, currentPage, mode, pages, stabilizeVerticalAnchor]); useEffect(() => { if (mode !== "vertical" || !pages) return; const stage = frameRef.current?.closest(".reader-stage") as HTMLElement | null; if (!stage) return; 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 = () => { frameId = 0; + if (!verticalTrackingReadyRef.current || !verticalUserScrollRef.current) return; const stageRect = stage.getBoundingClientRect(); const visiblePage = majorityVisiblePage( Array.from(stage.querySelectorAll("[data-reader-page]")).map((element) => { @@ -165,14 +229,24 @@ export function CbzReader({ if (frameId) return; 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 }); updateVisiblePage(); return () => { if (frameId) cancelAnimationFrame(frameId); + stage.removeEventListener("wheel", markUserScroll); + stage.removeEventListener("touchmove", markUserScroll); + stage.removeEventListener("pointerdown", markUserScroll); + keyTarget?.removeEventListener("keydown", markUserScrollKey); stage.removeEventListener("scroll", onScroll); }; }, [currentPage, mode, onPageCommit, pages]); + useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]); + if (documentError) { return (
diff --git a/apps/web/src/reader/PdfReader.tsx b/apps/web/src/reader/PdfReader.tsx index 07bea01..0031209 100644 --- a/apps/web/src/reader/PdfReader.tsx +++ b/apps/web/src/reader/PdfReader.tsx @@ -29,6 +29,10 @@ type PdfFailure = { 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) { if (error instanceof Error && error.message.trim()) return `${error.name}: ${error.message}`; 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(null); const previousModeRef = useRef(null); const pendingVerticalAnchorRef = useRef(false); + const verticalTrackingReadyRef = useRef(false); + const verticalUserScrollRef = useRef(false); + const verticalAnchorFrameRef = useRef(null); + const verticalAnchorAttemptRef = useRef(0); + const verticalAnchorStableFramesRef = useRef(0); const [documentProxy, setDocumentProxy] = useState(null); const [pages, setPages] = useState(1); const [documentError, setDocumentError] = useState(); @@ -165,12 +174,53 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon const currentPage = clampReaderPage(page, pages); 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(`[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( (nextPage: number) => { const target = clampReaderPage(nextPage, pages); setPageError(undefined); if (mode === "vertical") { + verticalUserScrollRef.current = false; frameRef.current?.querySelector(`[data-reader-page="${target}"]`)?.scrollIntoView({ block: "start" }); } onPageCommit(target, pages, 1, "immediate"); @@ -192,9 +242,17 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon if (mode !== "vertical") { previousModeRef.current = mode; pendingVerticalAnchorRef.current = false; + verticalTrackingReadyRef.current = false; + verticalUserScrollRef.current = false; + clearVerticalAnchorFrame(); 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) { const target = frameRef.current?.querySelector(`[data-reader-page="${currentPage}"]`); if (!target) { @@ -202,20 +260,26 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon return; } pendingVerticalAnchorRef.current = false; - requestAnimationFrame(() => { - target.scrollIntoView({ block: "start" }); - }); + stabilizeVerticalAnchor(currentPage); } previousModeRef.current = mode; - }, [currentPage, documentProxy, mode]); + }, [clearVerticalAnchorFrame, currentPage, documentProxy, mode, stabilizeVerticalAnchor]); useEffect(() => { if (mode !== "vertical") return; const stage = scrollContainerFor(frameRef.current); if (!stage) return; 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 = () => { frameId = 0; + if (!verticalTrackingReadyRef.current || !verticalUserScrollRef.current) return; const stageRect = stage.getBoundingClientRect(); const visiblePage = majorityVisiblePage( Array.from(stage.querySelectorAll("[data-reader-page]")).map((element) => { @@ -231,14 +295,24 @@ export function PdfReader({ url, page, backHref, zoom, mode, onPageCommit, onCon if (frameId) return; 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 }); updateVisiblePage(); return () => { if (frameId) cancelAnimationFrame(frameId); + stage.removeEventListener("wheel", markUserScroll); + stage.removeEventListener("touchmove", markUserScroll); + stage.removeEventListener("pointerdown", markUserScroll); + keyTarget?.removeEventListener("keydown", markUserScrollKey); stage.removeEventListener("scroll", onScroll); }; }, [currentPage, mode, onPageCommit, pages]); + useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]); + useEffect(() => { const frame = frameRef.current; const stage = scrollContainerFor(frame);