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
1"use client";23import * as ComicViewer from "@publira/comic-viewer";4import type { ViewerPage } from "@publira/comic-viewer";5import { ChevronLeft, ChevronRight } from "lucide-react";6import { useCallback, useRef, useSyncExternalStore } from "react";7import type { RefObject } from "react";89/**10 * The document reports every change, including the ones the page did not ask11 * for, such as leaving fullscreen through Escape or the browser's own control.12 */13const subscribeToFullscreenChange = (onFullscreenChange: () => void) => {14 document.addEventListener("fullscreenchange", onFullscreenChange);1516 return () => {17 document.removeEventListener("fullscreenchange", onFullscreenChange);18 };19};2021/** Whether the API exists never changes, so there is nothing to listen to. */22const subscribeToNothing = () => () => {23 // The value is fixed for the lifetime of the document.24};2526const isFullscreenEnabled = () => document.fullscreenEnabled;2728/** Rendering happens on the server, where there is no Fullscreen API to ask. */29const getServerSnapshot = () => false;3031interface Fullscreen {32 /** Whether the element behind the ref is the one currently filling the screen. */33 isFullscreen: boolean;34 /** Whether this document is allowed to enter fullscreen at all. */35 isSupported: boolean;36 /** Enters fullscreen, or leaves it when an element already fills the screen. */37 toggleFullscreen: () => Promise<void>;38}3940/**41 * Drives the native Fullscreen API for a single element. A browser only grants42 * a request that comes out of a user gesture, so call `toggleFullscreen`43 * straight from an event handler.44 *45 * The state is read back from the document rather than from the return of the46 * request, which keeps a control in step with what is on screen however47 * fullscreen was left.48 *49 * Only the standard names are used. The prefixed WebKit ones would matter for50 * Safari before 16.4, which is older than the CSS these demos are already51 * written in, such as `light-dark()`, so a fallback could never be reached.52 */53export const useFullscreen = (54 targetRef: RefObject<HTMLElement | null>55): Fullscreen => {56 const isFullscreen = useSyncExternalStore(57 subscribeToFullscreenChange,58 () =>59 targetRef.current !== null &&60 document.fullscreenElement === targetRef.current,61 getServerSnapshot62 );63 // Until the browser has hydrated the markup there is no API to ask, so a64 // control renders unavailable and turns usable where fullscreen is offered.65 const isSupported = useSyncExternalStore(66 subscribeToNothing,67 isFullscreenEnabled,68 getServerSnapshot69 );7071 const toggleFullscreen = useCallback(async () => {72 const target = targetRef.current;7374 if (target === null) {75 return;76 }7778 if (document.fullscreenElement === null) {79 await target.requestFullscreen();80 return;81 }8283 await document.exitFullscreen();84 }, [targetRef]);8586 return { isFullscreen, isSupported, toggleFullscreen };87};8889const navigationButtonClassName =90 "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";9192/**93 * The page-turn buttons over the page. The logical `start-3` and `end-3` place94 * them on the sides the reading direction gives them, and the icons point the95 * way a page turn goes, which the viewer context reports.96 */97const PageNavigation = () => {98 const { readingDirection } = ComicViewer.useViewerContext();99 const PreviousIcon = readingDirection === "rtl" ? ChevronRight : ChevronLeft;100 const NextIcon = readingDirection === "rtl" ? ChevronLeft : ChevronRight;101102 return (103 <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">104 <ComicViewer.PreviousPageButton105 className={`start-3 ${navigationButtonClassName}`}106 >107 <PreviousIcon aria-hidden="true" className="size-6" />108 </ComicViewer.PreviousPageButton>109 <ComicViewer.NextPageButton110 className={`end-3 ${navigationButtonClassName}`}111 >112 <NextIcon aria-hidden="true" className="size-6" />113 </ComicViewer.NextPageButton>114 </ComicViewer.PageNavigation>115 );116};117118interface FullscreenReaderProps {119 pages: readonly ViewerPage[];120}121122/** Renders the reader with a control that hands its container to the screen. */123export const FullscreenReader = ({ pages }: FullscreenReaderProps) => {124 const containerRef = useRef<HTMLDivElement>(null);125 const { isFullscreen, isSupported, toggleFullscreen } =126 useFullscreen(containerRef);127128 const handleToggle = async () => {129 try {130 await toggleFullscreen();131 } catch {132 // A browser that turns the request down leaves the reader as it is, so133 // there is nothing to recover from here.134 }135 };136137 return (138 // The container is the element handed to the screen, so the control stays139 // reachable in fullscreen along with the reader it belongs to. The rounded140 // page-shaped box it sits in is dropped once the screen sets its size.141 <div142 className="relative h-full w-full overflow-hidden rounded-xl [&:fullscreen]:rounded-none"143 ref={containerRef}144 >145 <ComicViewer.Root146 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"147 pages={pages}148 >149 {/* The track is three viewports wide and turns pages by translating itself.150 The page set, the slot, and the page each align the half of the spread they151 hold through the `data-page-side` attribute the rail reports. */}152 <ComicViewer.Viewport 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">153 <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))]">154 {/* The pan and the zoom of a pinch gesture apply to the spread on screen. */}155 <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))]">156 {/* A page that is a whole spread on its own keeps both halves of the set,157 so the double-page basis it would otherwise take is given back to it. */}158 <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">159 <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">160 {/* The page fit mode the viewport reports is what sizes the image. */}161 <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" />162 </ComicViewer.ViewportPage>163 </ComicViewer.ViewportPageSlot>164 </ComicViewer.ViewportPageSet>165 </ComicViewer.ViewportTrack>166 </ComicViewer.Viewport>167 {/* The toolbar slides out of sight with the `aria-hidden` the viewer sets168 on it while the reader controls are at rest. */}169 <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">170 {/* The slider reports the share of the document its thumb rests at as171 `--pcv-page-progress-fill`, which paints the part of the track behind it,172 and the fill runs the way the reader turns pages. */}173 <ComicViewer.PageProgress className="mx-auto min-w-0 shrink basis-3/5">174 <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" />175 <ComicViewer.PageStatus className="mt-1.5 block text-center text-sm text-slate-100" />176 </ComicViewer.PageProgress>177 </ComicViewer.Toolbar>178 <PageNavigation />179 </ComicViewer.Root>180 <button181 aria-pressed={isFullscreen}182 className="absolute end-3 top-3 z-20 rounded-full bg-black/60 px-3.5 py-1.5 text-sm font-semibold 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"183 disabled={!isSupported}184 onClick={handleToggle}185 type="button"186 >187 {isFullscreen ? "Exit full screen" : "Enter full screen"}188 </button>189 </div>190 );191};