Comic Viewer Tailwind CSS Demo

Fullscreen reading

The viewer leaves the browser's Fullscreen API to the host application, because which element should fill the screen and what the control looks like are decisions only the surrounding page can make. This demo passes its own reader container to requestFullscreen() instead of the whole document, so the reader and its toggle are the only things left on screen, and the [&:fullscreen] variant drops the rounding the box carries the rest of the time.

The button reads its label back from the fullscreenchange event rather than from the call it made, which keeps it in step when a reader leaves fullscreen through Esc or the browser's own control. Nothing has to be told about the new size: the viewer measures its container with a ResizeObserver, so the page fit and the switch between the single- and double-page layouts follow the container into fullscreen and back out on their own.

Source code

import { useCallback, useRef, useSyncExternalStore } from "react";

import { Reader } from "./reader";

const subscribeToFullscreenChange = (onFullscreenChange) => {
  document.addEventListener("fullscreenchange", onFullscreenChange);

  return () => {
    document.removeEventListener("fullscreenchange", onFullscreenChange);
  };
};

// Whether the API exists never changes, so there is nothing to listen to.
const subscribeToNothing = () => () => undefined;

const isFullscreenEnabled = () => document.fullscreenEnabled;

// Rendering happens on the server, where there is no Fullscreen API to ask.
const getServerSnapshot = () => false;

const useFullscreen = (targetRef) => {
  // Reading the state back from the document, rather than from the return of
  // the request, keeps the control in step however fullscreen was left.
  const isFullscreen = useSyncExternalStore(
    subscribeToFullscreenChange,
    () =>
      targetRef.current !== null &&
      document.fullscreenElement === targetRef.current,
    getServerSnapshot
  );
  const isSupported = useSyncExternalStore(
    subscribeToNothing,
    isFullscreenEnabled,
    getServerSnapshot
  );

  const toggleFullscreen = useCallback(async () => {
    const target = targetRef.current;

    if (target === null) {
      return;
    }

    if (document.fullscreenElement === null) {
      await target.requestFullscreen();
      return;
    }

    await document.exitFullscreen();
  }, [targetRef]);

  return { isFullscreen, isSupported, toggleFullscreen };
};

export const FullscreenReader = ({ pages }) => {
  const containerRef = useRef(null);
  const { isFullscreen, isSupported, toggleFullscreen } =
    useFullscreen(containerRef);

  // The container is the element handed to the screen, so the control goes
  // inside it and stays reachable once the reader fills the display.
  return (
    <div
      className="relative h-full w-full overflow-hidden rounded-xl [&:fullscreen]:rounded-none"
      ref={containerRef}
    >
      <Reader pages={pages} />
      <button
        className="absolute end-3 top-3 z-20 rounded-full bg-black/60 px-3.5 py-1.5"
        disabled={!isSupported}
        onClick={() => void toggleFullscreen()}
        type="button"
      >
        {isFullscreen ? "Exit full screen" : "Enter full screen"}
      </button>
    </div>
  );
};