Decrypted plugin sample
A customFetch hook retrieves the pre-encrypted .enc pages, because the response is ciphertext the browser cannot decode as an image, and an afterFetch hook decrypts what came back before the viewer decodes it. The page list names the type the bytes turn out to be through mimeType.
A plugin changes how a page is fetched and transformed, never how it is styled, so the reader below is composed exactly as it is on the entry point.
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";67/**8 * The key the sample pages were encrypted with. A real application would fetch9 * a key of its own for the document the reader has opened rather than ship one10 * in the bundle.11 */12const encryptionKey = new Uint8Array([13 45, 128, 94, 16, 201, 73, 5, 164, 220, 39, 177, 8, 93, 251, 14, 66, 57, 186,14 109, 34, 240, 12, 154, 80, 28, 198, 71, 115, 9, 166, 42, 203,15]);1617/** Every page carries the initialization vector it was encrypted with first. */18const initializationVectorLength = 12;1920const decryptPage = async (encryptedBuffer: ArrayBuffer) => {21 const encryptedPage = new Uint8Array(encryptedBuffer);22 const initializationVector = encryptedPage.slice(23 0,24 initializationVectorLength25 );26 const key = await crypto.subtle.importKey(27 "raw",28 encryptionKey,29 "AES-GCM",30 false,31 ["decrypt"]32 );3334 return crypto.subtle.decrypt(35 { iv: initializationVector, name: "AES-GCM" },36 key,37 encryptedPage.slice(initializationVectorLength)38 );39};4041/**42 * Fetches the `.enc` page itself, because the response is ciphertext the43 * browser cannot decode as an image, and decrypts what came back before the44 * viewer decodes it.45 */46const encryptedJpegPlugin = ComicViewer.definePlugin({47 afterFetch: ({ buffer }) => decryptPage(buffer),48 customFetch: async ({ signal, url }) => {49 const response = await fetch(url, { signal });5051 if (!response.ok) {52 throw new Error(`Failed to fetch encrypted page: ${response.status}`);53 }5455 return response.arrayBuffer();56 },57 name: "encrypted-jpeg",58});5960const navigationButtonClassName =61 "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";6263/**64 * The page-turn buttons over the page. The logical `start-3` and `end-3` place65 * them on the sides the reading direction gives them, and the icons point the66 * way a page turn goes, which the viewer context reports.67 */68const PageNavigation = () => {69 const { readingDirection } = ComicViewer.useViewerContext();70 const PreviousIcon = readingDirection === "rtl" ? ChevronRight : ChevronLeft;71 const NextIcon = readingDirection === "rtl" ? ChevronLeft : ChevronRight;7273 return (74 <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">75 <ComicViewer.PreviousPageButton76 className={`start-3 ${navigationButtonClassName}`}77 >78 <PreviousIcon aria-hidden="true" className="size-6" />79 </ComicViewer.PreviousPageButton>80 <ComicViewer.NextPageButton81 className={`end-3 ${navigationButtonClassName}`}82 >83 <NextIcon aria-hidden="true" className="size-6" />84 </ComicViewer.NextPageButton>85 </ComicViewer.PageNavigation>86 );87};8889interface DecryptedReaderProps {90 pages: readonly ViewerPage[];91}9293/**94 * A plugin changes how a page is fetched and transformed, never how it is95 * styled, so the tree below is the one the entry point is built from.96 */97export const DecryptedReader = ({ pages }: DecryptedReaderProps) => (98 <ComicViewer.Root99 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"100 pages={pages}101 plugins={[encryptedJpegPlugin]}102 >103 {/* The track is three viewports wide and turns pages by translating itself.104 The page set, the slot, and the page each align the half of the spread they105 hold through the `data-page-side` attribute the rail reports. */}106 <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">107 <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))]">108 {/* The pan and the zoom of a pinch gesture apply to the spread on screen. */}109 <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))]">110 {/* A page that is a whole spread on its own keeps both halves of the set,111 so the double-page basis it would otherwise take is given back to it. */}112 <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">113 <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">114 {/* The page fit mode the viewport reports is what sizes the image. */}115 <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" />116 </ComicViewer.ViewportPage>117 </ComicViewer.ViewportPageSlot>118 </ComicViewer.ViewportPageSet>119 </ComicViewer.ViewportTrack>120 </ComicViewer.Viewport>121 {/* The toolbar slides out of sight with the `aria-hidden` the viewer sets122 on it while the reader controls are at rest. */}123 <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">124 {/* The slider reports the share of the document its thumb rests at as125 `--pcv-page-progress-fill`, which paints the part of the track behind it,126 and the fill runs the way the reader turns pages. */}127 <ComicViewer.PageProgress className="mx-auto min-w-0 shrink basis-3/5">128 <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" />129 <ComicViewer.PageStatus className="mt-1.5 block text-center text-sm text-slate-100" />130 </ComicViewer.PageProgress>131 </ComicViewer.Toolbar>132 <PageNavigation />133 </ComicViewer.Root>134);