Lazy page metadata
This reader is never handed a page list. It is given the number of pages it holds and a resolvePage function, and it asks for the metadata of a page only as the reader comes near it. The imaginary endpoint behind this demo waits more than a second before answering, so the placeholder a page shows while its metadata is on its way stays on screen long enough to see. It is ViewportPendingPage carrying the utilities this demo gives it through renderPendingPage, in the place the page will take in the spread.
Only the pages within pageResolveOverscan of the current one are asked for, four on either side by default, which is why the request count starts at five rather than at the length of the document. A request for a page the reader leaves far behind is aborted through the AbortSignal it was given, and its metadata is forgotten once the page is further away than both that window and the pages the viewport can still render, so a page returned to much later is resolved again and a signed URL that has expired in the meantime is reissued.
Page images follow a window of their own. imagePreloadSpreads is 1 here, so the viewer fetches and decodes one spread beyond the ones it can render on either side of the reader. Those loads are queued behind the spread on screen and dropped when the reader moves away from them, so the count of decoded pages runs ahead of the three spreads the viewport holds without the current one waiting for them.
The document also grows as it is read. It starts as the first chapter of seven pages, and onEndReached appends the next chapter once the reader comes within two pages of the end, which the page count and the reading progress follow immediately.
Source code
1"use client";23import * as ComicViewer from "@publira/comic-viewer";4import type { PageResolveContext, ViewerPage } from "@publira/comic-viewer";5import { ChevronLeft, ChevronRight } from "lucide-react";6import { useCallback, useMemo, useState } from "react";78/** How many pages one chapter of the sample document holds. */9const CHAPTER_LENGTH = 7;10/** How long the imaginary page-metadata endpoint takes to answer. */11const PAGE_RESOLVE_DELAY_MS = 1200;12/** How long the imaginary chapter index takes to answer. */13const CHAPTER_LOAD_DELAY_MS = 400;1415/** Resolves after the delay, or rejects as soon as the request is aborted. */16const wait = (delayMs: number, signal?: AbortSignal): Promise<void> =>17 // eslint-disable-next-line promise/avoid-new -- A delay is only reachable through the timer callback API.18 new Promise((resolve, reject) => {19 const timeout = setTimeout(resolve, delayMs);2021 signal?.addEventListener(22 "abort",23 () => {24 clearTimeout(timeout);25 reject(signal.reason);26 },27 { once: true }28 );29 });3031export interface LazyChapters {32 /** How many chapters the whole document holds. */33 chapterCount: number;34 isLoadingChapter: boolean;35 /** How many chapters have been appended to the viewer so far. */36 loadedChapterCount: number;37 /** Appends the next chapter. Passed to the viewer as `onEndReached`. */38 loadNextChapter: () => void;39 /** The number of pages the viewer knows about, resolved or not. */40 pageCount: number;41 /** How many page-metadata requests have been answered. */42 requestCount: number;43 resolvePage: (44 index: number,45 context: PageResolveContext46 ) => Promise<ViewerPage | undefined>;47}4849/**50 * Stands in for a paginated backend: it hands the viewer one chapter of page51 * count at a time, and answers for the metadata of a single page only once the52 * viewer asks for it.53 */54export const useLazyChapters = (pages: readonly ViewerPage[]): LazyChapters => {55 const [pageCount, setPageCount] = useState(() =>56 Math.min(CHAPTER_LENGTH, pages.length)57 );58 const [isLoadingChapter, setIsLoadingChapter] = useState(false);59 const [requestCount, setRequestCount] = useState(0);6061 const resolvePage = useCallback(62 async (63 index: number,64 { signal }: PageResolveContext65 ): Promise<ViewerPage | undefined> => {66 await wait(PAGE_RESOLVE_DELAY_MS, signal);67 setRequestCount((count) => count + 1);6869 // An endpoint that signs its URLs would sign this one here, which is why70 // the viewer asks again for a page the reader comes back to much later.71 return pages[index];72 },73 [pages]74 );7576 const loadNextChapter = useCallback(() => {77 if (pageCount >= pages.length) {78 return;79 }8081 setIsLoadingChapter(true);82 void (async () => {83 await wait(CHAPTER_LOAD_DELAY_MS);84 setPageCount((count) => Math.min(pages.length, count + CHAPTER_LENGTH));85 setIsLoadingChapter(false);86 })();87 }, [pageCount, pages.length]);8889 return {90 chapterCount: Math.ceil(pages.length / CHAPTER_LENGTH),91 isLoadingChapter,92 loadNextChapter,93 loadedChapterCount: Math.ceil(pageCount / CHAPTER_LENGTH),94 pageCount,95 requestCount,96 resolvePage,97 };98};99100const statClassName =101 "flex flex-1 basis-48 flex-col gap-1 rounded-xl border border-slate-300 bg-white px-4 py-3 shadow-sm dark:border-slate-700 dark:bg-slate-900";102103interface LazyStatProps {104 label: string;105 value: string;106}107108const LazyStat = ({ label, value }: LazyStatProps) => (109 <div className={statClassName}>110 <span className="text-xs text-slate-600 dark:text-slate-400">{label}</span>111 <output aria-label={label} className="text-lg font-semibold tabular-nums">112 {value}113 </output>114 </div>115);116117const navigationButtonClassName =118 "pointer-events-auto absolute top-1/2 grid size-10 -translate-y-1/2 place-items-center rounded-full bg-black/60 p-0 text-slate-100 shadow-lg outline-offset-2 outline-slate-100 transition hover:bg-black/80 focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50";119120/**121 * The page-turn buttons over the page. The logical `start-3` and `end-3` place122 * them on the sides the reading direction gives them, and the icons point the123 * way a page turn goes, which the viewer context reports.124 */125const PageNavigation = () => {126 const { readingDirection } = ComicViewer.useViewerContext();127 const PreviousIcon = readingDirection === "rtl" ? ChevronRight : ChevronLeft;128 const NextIcon = readingDirection === "rtl" ? ChevronLeft : ChevronRight;129130 return (131 <ComicViewer.PageNavigation className="pointer-events-none absolute inset-0 z-10 transition duration-150 ease-out aria-hidden:translate-y-2 aria-hidden:opacity-0">132 <ComicViewer.PreviousPageButton133 className={`start-3 ${navigationButtonClassName}`}134 >135 <PreviousIcon aria-hidden="true" className="size-6" />136 </ComicViewer.PreviousPageButton>137 <ComicViewer.NextPageButton138 className={`end-3 ${navigationButtonClassName}`}139 >140 <NextIcon aria-hidden="true" className="size-6" />141 </ComicViewer.NextPageButton>142 </ComicViewer.PageNavigation>143 );144};145146// Lazily resolved metadata is what this demo is about, so the page standing in147// for one still on its way is styled here like every other part of the reader.148const renderPendingPage = () => (149 <ComicViewer.ViewportPendingPage className="h-full w-full animate-pulse bg-slate-900" />150);151152/** How many spreads beyond the viewport this demo loads ahead of the reader. */153const IMAGE_PRELOAD_SPREADS = 1;154155interface LazyReaderProps {156 pages: readonly ViewerPage[];157}158159/** Renders a reader whose page metadata arrives as the reader reaches it. */160export const LazyReader = ({ pages }: LazyReaderProps) => {161 const [decodedImageCount, setDecodedImageCount] = useState(0);162 // The hook reads each decoded page without returning one of its own, so the163 // viewer keeps drawing the image it decoded.164 const plugins = useMemo(165 () => [166 ComicViewer.definePlugin({167 afterDecode: () => {168 setDecodedImageCount((count) => count + 1);169 },170 name: "count-decoded-pages",171 }),172 ],173 []174 );175 const {176 chapterCount,177 isLoadingChapter,178 loadNextChapter,179 loadedChapterCount,180 pageCount,181 requestCount,182 resolvePage,183 } = useLazyChapters(pages);184185 return (186 <>187 <section188 aria-label="Comic reader"189 className="aspect-[4/5] min-h-96 w-full md:aspect-[8/5]"190 >191 <ComicViewer.Root192 className="relative flex h-full min-h-0 w-full min-w-0 overflow-hidden rounded-xl bg-slate-950 text-slate-100 shadow-2xl shadow-slate-950/30"193 pageCount={pageCount}194 imagePreloadSpreads={IMAGE_PRELOAD_SPREADS}195 onEndReached={loadNextChapter}196 plugins={plugins}197 resolvePage={resolvePage}198 >199 {/* The track is three viewports wide and turns pages by translating itself.200 The page set, the slot, and the page each align the half of the spread they201 hold through the `data-page-side` attribute the rail reports. */}202 <ComicViewer.Viewport203 renderPendingPage={renderPendingPage}204 className="group/viewport relative flex min-h-0 min-w-0 flex-1 touch-pan-y overflow-hidden data-[pannable]:cursor-grab data-[pannable]:touch-none data-[panning]:cursor-grabbing"205 >206 <ComicViewer.ViewportTrack className="flex h-full w-[300%] shrink-0 basis-[300%] [transform:translateX(calc(-33.3333%_+_var(--pcv-drag-offset)))] data-[dragging]:transition-none data-[transition-state=active]:transition-transform data-[transition-state=active]:duration-[260ms] data-[transition-state=active]:ease-out data-[transition-state=active]:data-[slide-direction=left]:[transform:translateX(calc(-66.6667%_+_var(--pcv-drag-offset)))] data-[transition-state=active]:data-[slide-direction=right]:[transform:translateX(var(--pcv-drag-offset))]">207 {/* The pan and the zoom of a pinch gesture apply to the spread on screen. */}208 <ComicViewer.ViewportPageSet className="flex h-full min-w-0 shrink-0 basis-1/3 data-[page-side=left]:justify-start data-[page-side=right]:justify-end data-[rail-slot=current]:[transform:translate(var(--pcv-pan-x,0)_var(--pcv-pan-y,0))_scale(var(--pcv-zoom-scale,1))]">209 {/* A page that is a whole spread on its own keeps both halves of the set,210 so the double-page basis it would otherwise take is given back to it. */}211 <ComicViewer.ViewportPageSlot className="flex min-w-0 flex-1 items-center justify-center data-[page-side=left]:justify-end data-[page-side=right]:justify-start data-[view-mode=double]:max-w-1/2 data-[view-mode=double]:basis-1/2 data-[view-mode=double]:data-[page-layout=spread]:max-w-full data-[view-mode=double]:data-[page-layout=spread]:basis-full">212 <ComicViewer.ViewportPage className="flex h-full w-full min-w-0 items-center justify-center data-[page-side=left]:justify-end data-[page-side=right]:justify-start">213 {/* The page fit mode the viewport reports is what sizes the image. */}214 <ComicViewer.PageCanvas className="h-full max-w-full bg-slate-900 object-contain transition-[filter] duration-150 group-data-[page-fit-mode=actual]/viewport:h-auto group-data-[page-fit-mode=actual]/viewport:w-auto group-data-[page-fit-mode=actual]/viewport:max-w-none group-data-[page-fit-mode=width]/viewport:h-auto group-data-[page-fit-mode=width]/viewport:w-full group-data-[page-fit-mode=width]/viewport:max-w-none data-[placeholder]:brightness-75 data-[placeholder]:saturate-75" />215 </ComicViewer.ViewportPage>216 </ComicViewer.ViewportPageSlot>217 </ComicViewer.ViewportPageSet>218 </ComicViewer.ViewportTrack>219 </ComicViewer.Viewport>220 {/* The toolbar slides out of sight with the `aria-hidden` the viewer sets221 on it while the reader controls are at rest. */}222 <ComicViewer.Toolbar className="absolute inset-x-0 bottom-0 z-10 flex items-center gap-2 bg-linear-to-t from-black/80 via-black/55 to-transparent px-3 pt-8 pb-3 transition duration-150 ease-out aria-hidden:translate-y-2 aria-hidden:opacity-0">223 {/* The slider reports the share of the document its thumb rests at as224 `--pcv-page-progress-fill`, which paints the part of the track behind it,225 and the fill runs the way the reader turns pages. */}226 <ComicViewer.PageProgress className="mx-auto min-w-0 shrink basis-3/5">227 <ComicViewer.PageProgressSlider className="block h-3.5 w-full cursor-pointer appearance-none bg-transparent p-0 outline-offset-4 outline-slate-100 [--pcv-page-progress-fill-direction:to_right] focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50 rtl:[--pcv-page-progress-fill-direction:to_left] [&::-moz-range-thumb]:size-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-slate-100 [&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-black/65 [&::-moz-range-track]:[background-image:linear-gradient(var(--pcv-page-progress-fill-direction),var(--color-slate-100)_var(--pcv-page-progress-fill),transparent_var(--pcv-page-progress-fill))] [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-black/65 [&::-webkit-slider-runnable-track]:[background-image:linear-gradient(var(--pcv-page-progress-fill-direction),var(--color-slate-100)_var(--pcv-page-progress-fill),transparent_var(--pcv-page-progress-fill))] [&::-webkit-slider-thumb]:-mt-[0.3125rem] [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-0 [&::-webkit-slider-thumb]:bg-slate-100" />228 <ComicViewer.PageStatus className="mt-1.5 block text-center text-sm text-slate-100" />229 </ComicViewer.PageProgress>230 </ComicViewer.Toolbar>231 <PageNavigation />232 </ComicViewer.Root>233 </section>234 <div className="flex flex-wrap gap-3">235 <LazyStat236 label="Pages available"237 value={`${pageCount} of ${pages.length}`}238 />239 <LazyStat label="Metadata requests" value={String(requestCount)} />240 <LazyStat241 label="Page images decoded"242 value={String(decodedImageCount)}243 />244 <LazyStat245 label="Chapters loaded"246 value={247 isLoadingChapter248 ? "Loading…"249 : `${loadedChapterCount} of ${chapterCount}`250 }251 />252 </div>253 </>254 );255};