diff --git a/apps/web/src/reader/CbzReader.tsx b/apps/web/src/reader/CbzReader.tsx index d1de4f4..1f9c329 100644 --- a/apps/web/src/reader/CbzReader.tsx +++ b/apps/web/src/reader/CbzReader.tsx @@ -11,6 +11,7 @@ const VERTICAL_ANCHOR_TOLERANCE_PX = 24; const VERTICAL_ANCHOR_STABLE_FRAMES = 18; const VERTICAL_ANCHOR_MAX_ATTEMPTS = 180; const VERTICAL_INITIAL_SYNC_MAX_ATTEMPTS = 180; +const VERTICAL_IMAGE_PRELOAD_CONCURRENCY = 24; export function CbzReader({ bookId, @@ -39,6 +40,9 @@ export function CbzReader({ const verticalInitialSyncDoneRef = useRef(false); const verticalAnchorAttemptRef = useRef(0); const verticalAnchorStableFramesRef = useRef(0); + const preloadedImagesRef = useRef(new Map()); + const pendingImageSizesRef = useRef>({}); + const imageSizeFlushFrameRef = useRef(null); const [pages, setPages] = useState(null); const [documentError, setDocumentError] = useState(); const [pageError, setPageError] = useState(); @@ -91,6 +95,27 @@ export function CbzReader({ if (verticalInitialSyncFrameRef.current !== null) cancelAnimationFrame(verticalInitialSyncFrameRef.current); verticalInitialSyncFrameRef.current = null; }, []); + const queueImageSize = useCallback((pageNumber: number, size: ReaderSize) => { + pendingImageSizesRef.current[pageNumber] = size; + if (imageSizeFlushFrameRef.current !== null) return; + imageSizeFlushFrameRef.current = requestAnimationFrame(() => { + imageSizeFlushFrameRef.current = null; + const pending = pendingImageSizesRef.current; + pendingImageSizesRef.current = {}; + setImageSizes((current) => { + let changed = false; + const next = { ...current }; + for (const [pageKey, nextSize] of Object.entries(pending)) { + const pageNumber = Number(pageKey); + const currentSize = current[pageNumber]; + if (currentSize?.width === nextSize.width && currentSize.height === nextSize.height) continue; + next[pageNumber] = nextSize; + changed = true; + } + return changed ? next : current; + }); + }); + }, []); const commitVisiblePage = useCallback( (stage: HTMLElement, allowInitialCommit = false) => { if (!pages || !verticalTrackingReadyRef.current) return; @@ -192,8 +217,49 @@ export function CbzReader({ useEffect(() => { setImageSizes({}); + pendingImageSizesRef.current = {}; + preloadedImagesRef.current.clear(); }, [bookId, retryAttempt]); + useEffect(() => { + if (mode !== "vertical" || !pages || typeof Image === "undefined") return; + let cancelled = false; + let nextIndex = 0; + let active = 0; + const pageItems = pages.pages; + + const preloadNext = () => { + if (cancelled) return; + while (active < VERTICAL_IMAGE_PRELOAD_CONCURRENCY && nextIndex < pageItems.length) { + const item = pageItems[nextIndex]; + nextIndex += 1; + if (preloadedImagesRef.current.has(item.page)) continue; + const image = new Image(); + preloadedImagesRef.current.set(item.page, image); + active += 1; + image.decoding = "async"; + image.loading = "eager"; + image.onload = () => { + active -= 1; + if (!cancelled && image.naturalWidth > 0 && image.naturalHeight > 0) { + queueImageSize(item.page, { width: image.naturalWidth, height: image.naturalHeight }); + } + preloadNext(); + }; + image.onerror = () => { + active -= 1; + preloadNext(); + }; + image.src = api.cbzPageUrl(bookId, item.page); + } + }; + + preloadNext(); + return () => { + cancelled = true; + }; + }, [bookId, mode, pages, queueImageSize, retryAttempt]); + useEffect(() => { const frame = frameRef.current; const stage = frame?.closest(".reader-stage") as HTMLElement | null; @@ -275,6 +341,7 @@ export function CbzReader({ commitVisiblePage(stage); }; const onScroll = () => { + if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true; if (frameId) return; frameId = requestAnimationFrame(updateVisiblePage); }; @@ -297,6 +364,12 @@ export function CbzReader({ useEffect(() => () => clearVerticalAnchorFrame(), [clearVerticalAnchorFrame]); useEffect(() => () => clearVerticalInitialSync(), [clearVerticalInitialSync]); + useEffect( + () => () => { + if (imageSizeFlushFrameRef.current !== null) cancelAnimationFrame(imageSizeFlushFrameRef.current); + }, + [] + ); if (documentError) { return ( @@ -352,10 +425,7 @@ export function CbzReader({ style={verticalImageStyle(item.page)} onLoad={(event) => { const { naturalWidth, naturalHeight } = event.currentTarget; - setImageSizes((current) => ({ - ...current, - [item.page]: { width: naturalWidth, height: naturalHeight } - })); + queueImageSize(item.page, { width: naturalWidth, height: naturalHeight }); }} onError={() => setPageError(`Page ${item.page} indisponible.`)} /> diff --git a/apps/web/src/reader/readerRuntime.test.ts b/apps/web/src/reader/readerRuntime.test.ts index d331867..fd99b2a 100644 --- a/apps/web/src/reader/readerRuntime.test.ts +++ b/apps/web/src/reader/readerRuntime.test.ts @@ -222,6 +222,16 @@ describe("reader runtime helpers", () => { expect(verticalBranch).not.toContain('hidden={!imageSizes[item.page]}'); }); + it("preloads deep CBZ vertical pages without relying only on browser lazy loading", () => { + const source = readFileSync(new URL("./CbzReader.tsx", import.meta.url), "utf8"); + + expect(source).toContain("VERTICAL_IMAGE_PRELOAD_CONCURRENCY"); + expect(source).toContain("new Image()"); + expect(source).toContain('image.loading = "eager"'); + expect(source).toContain("queueImageSize"); + expect(source).toContain("if (verticalTrackingReadyRef.current) verticalUserScrollRef.current = true"); + }); + it("keeps unloaded PDF and CBZ vertical pages visually covered by a page-sized fallback", () => { const styles = readFileSync(new URL("../styles/app.css", import.meta.url), "utf8");