Comic Viewer Tailwind CSS Demo

Pages available7 of 21
Metadata requests0
Chapters loaded1 of 3

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.

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

import * as ComicViewer from "@publira/comic-viewer";
import { useCallback, useState } from "react";

const CHAPTER_LENGTH = 7;

// A page whose metadata is still being resolved keeps its place in the
// spread, styled with utilities like every other part of this reader.
const renderPendingPage = () => (
  <ComicViewer.ViewportPendingPage className="h-full w-full animate-pulse bg-slate-900" />
);

export const Reader = () => {
  const [pageCount, setPageCount] = useState(CHAPTER_LENGTH);

  const resolvePage = useCallback(async (index, { signal }) => {
    // The endpoint signs the URL it hands back, and the viewer fetches the
    // page right after asking, while the signature is still valid.
    const response = await fetch(`/api/pages/${index}`, { signal });

    return response.json();
  }, []);

  const loadNextChapter = useCallback(() => {
    setPageCount((count) => count + CHAPTER_LENGTH);
  }, []);

  return (
    <ComicViewer.Root
      onEndReached={loadNextChapter}
      pageCount={pageCount}
      resolvePage={resolvePage}
      className="relative flex h-full w-full overflow-hidden rounded-xl bg-slate-950"
    >
      <ComicViewer.Viewport renderPendingPage={renderPendingPage} />
      <ComicViewer.Toolbar />
      <ComicViewer.PageNavigation />
    </ComicViewer.Root>
  );
};