SATSSATS

PDF View

Continuous-scrollable PDF View component.

Preview

'use client'

import { PDFView } from '@/components/pdfview/pdfview'
import {
  PDFViewProvider,
  usePDFViewActions,
  usePDFViewState,
} from '@/components/pdfview/pdfview-provider'
import {
  ChevronDownIcon,
  ChevronUpIcon,
  MinusIcon,
  PlusIcon,
} from 'lucide-react'
import { useRef } from 'react'

export default function Preview(props: { src: string }) {
  const containerRef = useRef<HTMLDivElement>(null)
  return (
    <PDFViewProvider>
      <div
        ref={containerRef}
        className="relative h-[450px] w-full overflow-hidden rounded-md border border-slate-100 bg-white">
        <PDFView containerRef={containerRef} src={props.src} />
        <PageIndicator />
        <ZoomControls />
      </div>
    </PDFViewProvider>
  )
}

function PageIndicator() {
  const state = usePDFViewState()
  const actions = usePDFViewActions()

  return (
    <div className="absolute right-2 bottom-2 flex flex-row items-center gap-2">
      <button
        onClick={() => {
          actions.goToPage(state.currentPage - 1)
        }}
        type="button"
        className="grid size-[30px] cursor-pointer place-content-center rounded-full border border-slate-100">
        <ChevronUpIcon className="size-4" />
      </button>
      <p className="w-[80px] rounded-full border border-slate-100 bg-white px-2 py-1 text-center text-sm text-slate-900 shadow-xs">
        {state.currentPage}&nbsp;
        <span className="text-xs text-slate-500">/ {state.totalPages}</span>
      </p>
      <button
        onClick={() => {
          actions.goToPage(state.currentPage + 1)
        }}
        type="button"
        className="grid size-[30px] cursor-pointer place-content-center rounded-full border border-slate-100">
        <ChevronDownIcon className="size-4" />
      </button>
    </div>
  )
}

function ZoomControls() {
  const state = usePDFViewState()
  const actions = usePDFViewActions()

  return (
    <div className="absolute bottom-2 left-2 flex flex-row items-center gap-2">
      <button
        onClick={() => {
          actions.setViewportScale(state.scale - 0.25)
        }}
        type="button"
        className="grid size-[30px] cursor-pointer place-content-center rounded-full border border-slate-100">
        <MinusIcon className="size-4" />
      </button>
      <p className="w-[60px] rounded-full border border-slate-100 bg-white px-2 py-1 text-center text-sm text-slate-900 shadow-xs">
        {(state.scale * 100).toFixed(0)}
        <span className="text-xs">%</span>
      </p>
      <button
        onClick={() => {
          actions.setViewportScale(state.scale + 0.25)
        }}
        type="button"
        className="grid size-[30px] cursor-pointer place-content-center rounded-full border border-slate-100">
        <PlusIcon className="size-4" />
      </button>
    </div>
  )
}

Installation

npx shadcn@latest install https://sats-lab.github.io/components/r/pdfview.json

This will install these components under @/components/pdfview:

  • pdfview
  • pdfview-provider
  • pdfview-utils
  • pdfview-loader
  • pdfview-constants

and these packages if not already installed:

  • nanoid
  • konva
  • react-konva
  • pdfjs-dist

Usage

Basic usage – rendering PDF

In Next.js, make sure to skip SSR when importing the pdfview modules. Here's how to do this in Pages Router and App Router.

import { PDFView } from '@/components/pdfview/pdfview'
import {
  PDFViewProvider,
} from '@/components/pdfview/pdfview-provider'
import { useRef } from 'react'

export default function PDFViewer() {
  const divRef = useRef<HTMLDivElement>(null)

  return (
    <PDFViewProvider>
      <div
        ref={divRef}
        className="relative h-[800px] w-[90vw] border border-slate-100">
        <PDFView
          src="/sample.pdf"
          containerRef={divRef}
        />
      </div>
    </PDFViewProvider>
  )
}

Rendering objects on pages and displaying page indicator.

import { PDFView } from '@/components/pdfview/pdfview'
import {
  Object,
  PDFViewProvider,
  usePDFViewActions,
  usePDFViewState,
} from '@/components/pdfview/pdfview-provider'
import { nanoid } from 'nanoid'
import { useEffect, useRef, useState } from 'react'
import { Image } from 'react-konva'

export default function PDFViewer() {
  const divRef = useRef<HTMLDivElement>(null)

  return (
    <PDFViewProvider>
      <ObjectButtons />
      <div
        ref={divRef}
        className="relative h-[800px] w-[90vw] border border-slate-100">
        <PDFView
          src="/sample.pdf"
          containerRef={divRef}
          RenderObject={RenderObject}
        />
        <PageIndicator />
      </div>
    </PDFViewProvider>
  )
}

function ObjectButtons() {
  const actions = usePDFViewActions()
  return (
    <div>
      {/* eslint-disable-next-line */}
      <img
        onClick={() => {
          actions.setObjectToPlace({
            id: nanoid(),
            dimensions: { width: 38, height: 38 },
            tag: 'image',
            data: {
              src: 'https://picsum.photos/100/100',
            },
          })
        }}
        src="https://picsum.photos/100/100"
        className="size-6"
      />
    </div>
  )
}

function RenderObject(props: { object: Object }) {
  const [image, setImage] = useState<HTMLImageElement | null>(null)

  useEffect(() => {
    const img = new window.Image()
    img.src = props.object.data.src
    img.onload = () => {
      setImage(img)
    }
  }, [props.object])

  if (!image) return null

  return (
    <Image
      image={image}
      width={props.object.dimensions.width}
      height={props.object.dimensions.height}
      listening={props.object.placed}
      alt={''}
    />
  )
}

function PageIndicator() {
  const { currentPage, totalPages } = usePDFViewState()

  return (
    <div className="absolute bottom-2 left-2 rounded-md border border-slate-300 bg-white px-2 py-1 text-slate-900 shadow-xs">
      {currentPage} / {totalPages}
    </div>
  )
}

Accessing PDFView state & actions

PDFView state

The state of PDFView can be accessed via usePDFViewState hook from the child component tree of PDFViewProvider. The return type of usePDFViewState is as follows:

PropTypeDefault
status
'loading' | 'success' | 'error'
loading
scale
number
1
currentPage
number
1
totalPages
number
1

PDFView actions

The actions of PDFView can be accessed via usePDFViewActions hook from the child component tree of PDFViewProvider. The return type of usePDFViewActions is as follows:

PropTypeDefault
setViewportScale
(scale: number) => void
-
goToPage
(pageNumber: number) => void
-
setObjectToPlace
(objectToPlace: Omit<Object, 'position' | 'placed'> | null) => void
-
getObjects
() => { objects: Map<string, Object>; pageObjects: Map<number, string[]> }
-

The Object is from the pdfview-provider.tsx module. Not the global Object.