How to Add Zoom Controls to a PDF.js Viewer - PDF.js Express

23 Mar 2020

author
Logan Bittner

In our last blog, we started building a PDF.js viewer using PDF.js, React, and Typescript. In this blog we walk you through an example of adding zoom buttons to your PDF.js viewer. We also explore adding a toolbar to your viewer and how you can improve zooming performance in PDF.js.

We are working off the same code from the last post - available on our Github repo.

You can download the source code beforehand here:here.

And view changes to the existing code here.

Setting up our PDF.js zoom example

Our app will need a form of global state to keep track of zoom and rotation. We can implement this state ourselves via a simple subscriber pattern.

Start by creating a file called ./src/state/state.ts.

Next, we need to set up our initial state as well as a couple of utility types:

// ./src/state/state.ts

const baseState = {
  zoom: 1,
  rotation: 0
};

export type StateKey = keyof typeof baseState;
export type StateValue<K extends StateKey> = typeof baseState[K];
export type SubscriberFunction<K extends StateKey> = (value: StateValue<K>) => void

const handlers = new Map<string, Map<Symbol, any>>();

Here we create baseState which contains the shape of our state and also the default values. (For now, we only store zoom and rotation.) We also create a few types that leverage this state, and a handlers map used to store our subscriber callbacks.

Now we can implement some utility functions to get and set state. Add the following code to ./src/state/state.ts:

// ./src/state/state.ts

export const getState = <K extends StateKey>(key: K): StateValue<K>  => {
  return baseState[key];
}

/*
 * Sets the current state and calls and subscribers that are subscribed to that key 
 */
export const setState = <K extends StateKey>(key: K, value: StateValue<K>) => {
  baseState[key] = value;
  const handlerScope = handlers.get(key);
  if (handlerScope) {
    const funcs = handlerScope.values();
    Array.from(funcs, (func: SubscriberFunction<K>) => func(value))
  }
}

getState accepts any key of baseState (in this case either 'zoom' or 'rotation'), and returns the current value.

setState accepts a key of baseState, and the value you want to set the state to. In this case, value must match the type that we initially set the state to. (For example, since we initialized baseState.zoom to a number, we must pass a number to setState.) This helps us make sure we aren't accidentally changing types.

Now we can implement a subscribe function, which can be used to trigger updates in our components whenever the global state changes. Add the following code to ./src/state/state.ts

// ./src/state/state.ts

/**
 * Binds to an item on state.
 * 'onChange' gets called every time the state is changed.
 * Returns a function that can be used to kill the subscriber.
 */
export const subscribe = <K extends StateKey>(key: K, onChange: SubscriberFunction<K>) => {
  const handlerKey = Symbol();
  let handlerScope = handlers.get(key);
  if (!handlerScope) {
    handlerScope = new Map();
  }
  handlerScope.set(handlerKey, onChange);
  handlers.set(key, handlerScope);
  return () => {
    const scope = handlers.get(key);
    scope.delete(handlerKey);
  }
}

This function accepts a key of baseState and a callback function which will be called with a new value every time key changes. We also return a function which can be called to unsubscribe from the state.

The nice part about this code is that we can easily add new items to our global state without having to write a bunch of boilerplate code!

Creating a state hook

Now that our global state is set up, we can write a utility hook that our components can use to hook into our state and get/set values.

Create a file called ./src/state/useGlobalState.ts and add the following:

// ./src/state/useGlobalState.ts

import { useState, useEffect } from 'react';
import { StateKey, getState, setState, subscribe, StateValue } from './state';

type StateSetter<K extends StateKey> = (value: StateValue<K>) => void;

export default <K extends StateKey>(key: K): [ StateValue<K>,  StateSetter<K>] => {
  const [currentValue, setCurrentValue] = useState(getState(key));

  useEffect(() => subscribe(key, setCurrentValue), [])

  const set: StateSetter<K> = (value) => {
    setState(key, value)
  };

  return [ currentValue, set ]
}

This code wraps React's useState hook and binds it to our global state. We call useEffect to subscribe to whatever key we want and to keep our local value in sync.

Adding zoom in & out controls in PDF.js

Now we're ready to implement zoom controls in our app.

We'll start by adding a reusable Button component. Add the files ./src/components/Button/Button.tsx and ./src/components/Button/Button.scss.

Add the following code to Button.tsx:

import React, { forwardRef, ButtonHTMLAttributes } from 'react';

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  text?: string;
  icon?: string;
} 

export default forwardRef<HTMLButtonElement, ButtonProps>(({
  text,
  icon,
  ...rest
}, ref) => {
  return (
    <button ref={ref} className='Button' {...rest} >
      {
        icon ?
          <i className='material-icons'>{icon}</i> :
          text
      }
    </button>
  )
})

Our button component will accept either a text or icon property. The icon string will be a Google Material Icon, which we need to include in the project in order to render the icon. Add the following to the <head> of src/index.html:

<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">

Next, we create a toolbar in our PDF.js viewer with a header component, which will appear as a slim bar across the top of our app UI. Create the files ./src/components/Header/Header.tsx and ./src/components/Header/Header.scss.

Add the following code to Header.tsx:

import React from 'react';
import Button from '../Button/Button';
import useGlobalState from '../../state/useGlobalState';

import './Header.scss';

export default () => {

  const [currentZoom, setZoom] = useGlobalState('zoom');

  return (
    <div className='Header'>
      <Button
        icon='remove_circle_outline'
        onClick={() => setZoom(currentZoom / 1.5)}
      /> 

      <Button
        icon='add_circle_outline'
        onClick={() => setZoom(currentZoom * 1.5)}
      /> 
    </div>  
  )
}

The above code adds two buttons to our header -- a plus and a minus. Each time one of these buttons are clicked, we update our current zoom state.

Our toolbar also needs a bit of styling, so let’s add the following to Header.scss:

.Header {
  position: fixed;
  top: 0;
  left: 0;
  background-color: white;
  width: 100%;
  padding: 5px;
  border-bottom: 1px solid #e7e7e7;
}

Now we hook up our rendering API to use our global zoom value. In our useDocument hook (src/hooks/useDocument.ts), we can subscribe to our state and pass it into our rendering API that we created in the first blog in this series.

Change src/hooks/useDocument.ts to have the following code:

// src/hooks/useDocument.ts

import { useState, useEffect } from 'react';
import getDocumentPages from '../api/getDocumentPages';
import useGlobalState from '../state/useGlobalState';

export default ({
  url
}) => {
  const [pages, setPages] = useState<string[]>([]);
+ const [zoom] = useGlobalState('zoom');

  useEffect(() => {
    const getPages = async () => {
      const canvases = await getDocumentPages({
        url,
+       scale: zoom
      });
      setPages(canvases);
    }
    getPages();
+ }, [url, zoom])
  return {
    pages
  }
}

Here you can see we pass the scale parameter to our rendering API that we set up earlier. We also added zoom to our dependency array so our component will render whenever zoom changes!

The last step is rendering the toolbar inside our app. In app.tsx, let's render our Header component:

import React from 'react';
import ReactDOM from 'react-dom';
import Viewer from './components/Viewer/Viewer';
+ import Header from './components/Header/Header';

const App = () => {
  return (
    <div>
+     <Header />
      <Viewer />
    </div>
  )
}

ReactDOM.render(<App />, document.getElementById('app'));

When you check your app at http://localhost:1234/, you should see a toolbar with plus and minus buttons. Clicking the buttons will zoom the document in and out!

Optimizing PDF.js zoom performance

You'll notice that clicking the zoom buttons takes a moment to actually zoom the document. This is because our getDocumentPages function waits to render all the pages before returning them. We can optimize this function by switching to a callback pattern, and returning each page as soon as they are finished.

Change src/api/getDocumentPages.ts to look like the following:

interface GetDocumentPagesOptions {
  scale?: number;
  url: string;
+ onPageComplete: (pageIndex: number, url: string) => void;
+ onFinished: () => void
}

export default async ({
  scale = 1,
  url,
+ onPageComplete,
+ onFinished,
}: GetDocumentPagesOptions) => {
  const PDFJS = window.pdfjsLib;

  // First, we need to load the document using the getDocument utility
  const loadingTask = PDFJS.getDocument(url);
  const pdf = await loadingTask.promise;

  const { numPages } = pdf;

+ const canvasPromises = [];

  // Now for every page in the document, we're going to add a page to the array
  for (let i = 0; i < numPages; i++) {
    const pageIndex = i;
    const page = await pdf.getPage(i + 1);

    const viewport = page.getViewport({ scale });
    const { width, height } = viewport;
    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;
    canvas.className = 'page'
+   canvasPromises.push(
+     page.render({
+       canvasContext: canvas.getContext('2d'),
+       viewport
+     }).then(() => {
+       onPageComplete(pageIndex, canvas.toDataURL());
+     })
    )
  }

+ await Promise.all(canvasPromises);
+ onFinished();
}

This changes our function to use a callback pattern, where onPageComplete is called every time a page renders. This allows us to update our app incrementally as pages finish rendering. We also add an onFinished used to display loading spinners and other user feedback in the future.

Now we can update our useDocument hook to use this new pattern. Change src/hooks/useDocument.ts to the following:

import { useState, useEffect } from 'react';
import getDocumentPages from '../api/getDocumentPages';
import useGlobalState from '../state/useGlobalState';

export default ({
  url
}) => {
  const [pages, setPages] = useState<string[]>([]);
  const [zoom] = useGlobalState('zoom');

  useEffect(() => {
    getDocumentPages({
      url,
      scale: zoom,
      onPageComplete: (index, url) => {
        setPages(old => {
          const clone = [...old];
          clone[index] = url;
          return clone;
        })
      },
      onFinished: () => { /*TODO*/ }
    });
  }, [url, zoom])

  return {
    pages
  }
}

With this code in place, we’ve made the viewer more responsive by updating ‘pages’ array each time a new page is rendered, instead of only once when every page is rendered.

Wrapping up

In our first blog post, we were able to build a PDF.js viewer and in this blog we’ve added zoom controls so users can zoom in and out at the click of a button. We’ve also developed some basic infrastructure in our app that will allow us to quickly add state and buttons in our application. In our next blog article, we’ll dive into this a bit deeper by covering some customizations you can do within your PDF.js viewer -- stay tuned!

In addition to the changes in the blog, we also made a few minor CSS tweaks to make our app feel a bit nicer. Those changes can be found in this commit.

The source code for this blog can be downloaded in this release.

You can also view all the changes we made to our code base here.

About PDF.js Express

PDF.js Express is a commercial viewer that wraps around the PDF.js rendering engine. It lets you easily add features to your viewer like annotations, form filling, e-signatures and more. You can get started with a free trial or contact us if you have any questions.