Creating A Pdf Saver And Print Previewer in React using useImperativeHandle and jspdf
Dan Hammer
Posted on April 14, 2021
I recently had need to create documents in a React app with a scrolling previewer and the ability to print or save to PDF. Creating a component that could hold and display multi page documents and make them printable and able to be captured as a pdf. This came with some interesting opportunities to make smart components that can handle some work themselves.
I'll go into further detail in the following sections, but for a quick breakdown:
- App creates an array of documents with a title and an array of pages made up of react components and a ref for each document.
- PrintDocumentScroller creates a scrolling view for all documents and renders a PrintDocument for each and passes the ref down.
- PrintDocument creates a PrintArea for each page and exposes a function to generate a PDF of the entire document. This is referenced in App using the ref that was passed down and useImperativeHandle.
- PrintArea renders the content in a page-like view so that the preview, print, and pdf all look the same.
Background
refs and useRef
useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component
You might be familiar with refs primarily as a way to access the DOM. If you pass a ref object to React with
, React will set its .current property to the corresponding DOM node whenever that node changes.
refs are very useful to maintain a stable reference to any value (but especially DOM nodes or components) for the entire life of a component.
For this project, we will use refs to give access to functions on child components in order to render a canvas of each component.
useImperativeHandle
What is useImperativeHandle
?
useImperativeHandle customizes the instance value that is exposed to parent components when using ref. As always, imperative code using refs should be avoided in most cases. useImperativeHandle should be used with forwardRef:
function FancyInput(props, ref) { const inputRef = useRef(); useImperativeHandle(ref, () => ({ focus: () => { inputRef.current.focus(); } })); return <input ref={inputRef} ... />; } FancyInput = forwardRef(FancyInput);```
Make components do some work!
PrintArea
const PrintArea = forwardRef(({ children, pageIndicator }, ref) => {
const useStyles = makeStyles(() => ({
...
}));
const classes = useStyles();
const pageRef = useRef();
useImperativeHandle(ref, () => ({
captureCanvas: () => html2canvas(pageRef.current, { scale: 2 })
}));
return (
<Box className={classes.printArea} ref={pageRef}>
{children}
<Box className={classes.pageIndicator}>{pageIndicator}</Box>
</Box>
);
});
Above, we create a PrintArea component that will hold each individual page. It applies some styles to show an 11" x 8.5" box with a page number indicator in the bottom right. This component is fairly simple, but it provides us with a function, captureCanvas, to get the canvas just for that specific page.
Each PrintArea component is passed a ref. forwardRef allows us to take the assigned ref and use it inside the component.
useImperativeHandle allows us to assign a series of functions to any ref. In this case, the ref passed down through forward ref. We create captureCanvas, a function to digest the page into a canvas directly. This can be called by any parent component with access to the ref with ref.current.captureCanvas()
. This is what we'll take advantage of to gather all of our canvases.
PrintDocument
Each PrintArea is a single page. PrintDocument represents an entire document and all of its pages.
const PrintDocument = forwardRef(({ pages, title }, ref) => {
const numPages = pages.length;
const printAreaRefs = useRef([...Array(numPages)].map(() => createRef()));
useImperativeHandle(ref, () => ({
generatePDF: () =>
...
})
}));
return (
<div>
{pages.map((content, index) => (
<PrintArea
key={`${title}-${index}`}
pageIndicator={`${title} - ${index + 1}/${numPages}`}
ref={printAreaRefs.current[index]}
>
{content}
</PrintArea>
))}
</div>
);
});
PrintDocument creates a ref for each page and then renders the content within PrintAreas that are passed the correct ref.
PrintDocument also employs useImperativeRef to give its parent access to generate a PDF.
useImperativeHandle(ref, () => ({
generatePDF: () =>
Promise.all(
printAreaRefs.current.map((ref) => ref.current.captureCanvas())
).then((canvases) => {
const pdf = new jsPDF(`portrait`, `in`, `letter`, true);
const height = LETTER_PAPER.INCHES.height;
const width = LETTER_PAPER.INCHES.width;
// Loop over the canvases and add them as new numPages
canvases.map((canvas, index) => {
if (index > 0) {
pdf.addPage();
}
const imgData = canvas.toDataURL(`image/png`);
pdf.addImage(imgData, `PNG`, 0, 0, width, height, undefined, `FAST`);
});
return { pdf, title };
})
}));
Because it assigns captureCanvas to each ref passed to a PrintArea, it is able to get the canvas for each page and pass it onto jspdf. Then, it returns the generated pdf and title to a parent component.
savePDFs
const savePDFs = (refs) =>
Promise.all(
refs.current.map((ref) => ref.current.generatePDF())
).then((pdfs) => pdfs.map(({ title, pdf }) => pdf.save(`${title}.pdf`)));
savePDFs is passed the array of document refs and is able to call generatePDF() on each document and then save it.
In my use case, I gather all of the pdfs and upload them each to S3, which I may cover in a future post.
And now, a warning
From the React docs: As always, imperative code using refs should be avoided in most cases.
It is of course possible to approach this without using refs and useImperativeRef.
We can assign an id to every page and programmatically grab it
documents = [
{
title: `Document1`,
pages: [
<div id="page-1-1">stuff</div>
...
]
},
]
...
pages.map((_, index) =>
html2canvas(
document.body.appendChild(
document.getElementById(
`page-${documentIndex}-${pageIndex}`
)
)
)
)
...
We can even make this work with some of the styling. I am not a fan of this approach as it makes it slightly more difficult to generate an arbitrary number of pages and is honestly not very readable, but it is completely valid and will work. I chose not to do this in favor of a more readable and adaptable solution.
Posted on April 14, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 28, 2024
November 27, 2024