Building a PDF application
PDF applications are assembled from the same recurring parts: the main view showing the pages, a toolbar, sidebars, and a selection toolbar for the current selection. This page maps those parts to the SDK's API; the reference pages linked from each section carry the details.
The main view
Viewport is the SDK's main component: the scrollable and zoomable area that renders the pages and handles direct interaction with them, from selecting and typing to filling form fields. You place it, size it, and drive everything else through hooks. See Getting started for the setup and Viewport for its details.
Styling and customization
The viewport's appearance is not fixed: the theme configuration attaches your class names and inline styles to the elements the SDK renders (the viewport, pages, selection, widget overlays) and replaces some of them with your own components. See Theming and customization.
import { Config, PdfProviders } from '@sdk-pdf/react'
const theme: Config['theme'] = {
// The gap between the pages and the viewport edges, in pixels. Follows the CSS padding
// shorthand syntax.
viewportPadding: [24, 16],
// Appended to the class names of the SDK elements.
classNames: { scrollViewport: 'my-viewport', page: 'my-page' },
// The viewport exposes CSS variables: --selection-color paints the selection outlines
// and handles.
styles: {
scrollViewport: { '--selection-color': 'rebeccapurple' } as React.CSSProperties,
},
}
export function App() {
return (
<PdfProviders theme={theme}>
<DocumentLoader />
</PdfProviders>
)
}Interaction tool and content policy
What users may do in the main view is governed by the interaction mode, which consists of two parts: the content policy and the interaction tool. See useSetInteractionMode and CONTENT_POLICY_PRESET for more details.
The content policy sets a permission level for each content kind: for example, whether text is editable or read-only, or whether links are interactive or editable. CONTENT_POLICY_PRESET covers the common combinations, such as PDF_PRINTER (everything read-only), PDF_VIEWER (text read-only, links and form fields interactive), and PDF_EDITOR (everything editable).
The interaction tool interprets user gestures, that is, what a click, tap, or drag does on a page. For example:
{ type: 'select' }: select, move, and resize blocks;{ type: 'textSelect' }: native text selection for reading and copying;{ type: 'addText' }: create a new paragraph at the click position and focus it for typing;{ type: 'addWidget', widgetProps }: create a form widget at the click position;{ type: 'addImage', image }: place a staged image at the click position.
A permission can also be a function of the block id, which sets it per block: for example, keeping every form field read-only except the ones assigned to the current user.
The interaction mode is dynamic: it can be changed at runtime (for example, toggling between reading and editing), and it is local to each user. useSetInteractionMode returns a setter that changes the tool and, optionally, the policy in one go. For example, a toggle between previewing and editing:
import {
CONTENT_POLICY_PRESET,
InteractionTool,
useSetInteractionMode,
} from '@sdk-pdf/react'
const textSelectTool: InteractionTool = { type: 'textSelect' }
const selectTool: InteractionTool = { type: 'select' }
function ModeSwitch() {
const setInteractionMode = useSetInteractionMode()
return (
<div role="toolbar">
<button
onClick={() =>
setInteractionMode(textSelectTool, CONTENT_POLICY_PRESET.PDF_PRINTER)
}
>
Preview
</button>
<button
onClick={() => setInteractionMode(selectTool, CONTENT_POLICY_PRESET.PDF_EDITOR)}
>
Edit
</button>
</div>
)
}The initial mode is part of the PdfProviders configuration, as shown in Getting started.
History tracking and document export
Edits don't rewrite the PDF file. Each change is recorded as an operation in a CRDT (a conflict-free replicated data type), which is what powers the change history: useUndoRedo provides the undo and redo commands together with their availability (useUndoRedo).
The final PDF is built only when needed: useDocumentSaving (reference) applies the recorded changes to the original document, with downloadDocument saving the result as a file on the user's device and prepareDocument returning the bytes for your own handling, such as uploading to a server.
import { useDocumentSaving, useUndoRedo } from '@sdk-pdf/react'
function DocumentControls() {
const { undo, redo, canUndo, canRedo } = useUndoRedo()
const { downloadDocument } = useDocumentSaving()
return (
<div role="toolbar">
<button disabled={!canUndo} onClick={undo}>
Undo
</button>
<button disabled={!canRedo} onClick={redo}>
Redo
</button>
<button onClick={() => void downloadDocument()}>Download</button>
</div>
)
}Hotkeys
useHotkeys installs window-level keyboard shortcuts. Keys are written as modifier chains ending with the key name, such as Mod-z or Shift-Mod-z, where Mod means Cmd on macOS and Ctrl elsewhere. A handled shortcut consumes the event; return false from a handler to let the event propagate instead. Passing a falsy value instead of a mapping disables the hotkeys:
import { useMemo } from 'react'
import { useHotkeys, useUndoRedo } from '@sdk-pdf/react'
function useDocumentHotkeys() {
const { undo, redo } = useUndoRedo()
// The mapping must be memoized, or the listener re-installs on every render.
useHotkeys(
useMemo(
() => ({
'Mod-z': undo,
'Shift-Mod-z': redo,
}),
[undo, redo]
)
)
}Pages and navigation
The API addresses pages by id rather than by index: an id keeps pointing at the same page while pages are added, removed, and rearranged by the user, where a plain index would silently drift to another page. usePages bridges the two views: it returns the pages in document order (so a position like "page 3 of 12" is an index in that list) and re-renders your component when the order changes. useCurrentPageId returns the page the user is currently looking at.
import { useCurrentPageId, usePages, useScrollToPage } from '@sdk-pdf/react'
function PagePicker() {
const pages = usePages()
const currentPageId = useCurrentPageId()
const scrollToPage = useScrollToPage()
return (
<select
value={currentPageId}
onChange={(event) => scrollToPage(event.currentTarget.value)}
>
{pages.map((page, index) => (
<option key={page.id} value={page.id}>
Page {index + 1} of {pages.length}
</option>
))}
</select>
)
}When a page's position is needed only occasionally, such as inside an event handler, usePageIndexGetter returns a getter that resolves a page id to its index without subscribing your component to page order changes:
import {
useBlockPageIdGetter,
usePageIndexGetter,
useViewportEvent,
} from '@sdk-pdf/react'
function useTrackAddedContent() {
const getBlockPageId = useBlockPageIdGetter()
const getPageIndex = usePageIndexGetter()
useViewportEvent('addToolApplied', ({ blockId, tool }) => {
const pageId = getBlockPageId(blockId)
// The block or its page might be gone already: deleted by an undo or by another user.
const pageIndex = pageId === undefined ? undefined : getPageIndex(pageId)
if (pageIndex === undefined) return
analytics.track('content-added', { type: tool.type, pageNumber: pageIndex + 1 })
})
}Zoom and fit
Zoom controls read and set the viewport scale with useViewportScale (scale 1 means 100%: one user-space unit per CSS pixel). Besides the scale and its setter, it returns animateScale, which zooms smoothly; both setters take an optional viewport point that stays in place while zooming. useViewportFitScale calculates the scales that fit the pages into the viewport width or height:
import { useViewportFitScale, useViewportScale } from '@sdk-pdf/react'
function ScaleControls() {
const [scale, setScale, animateScale] = useViewportScale()
const getFitScale = useViewportFitScale()
return (
<div role="toolbar">
<button onClick={() => void animateScale(scale / 1.25)}>−</button>
<span>{Math.round(scale * 100)}%</span>
<button onClick={() => void animateScale(scale * 1.25)}>+</button>
<button onClick={() => setScale(getFitScale().fitWidthScale)}>Fit width</button>
</div>
)
}To apply a fit automatically when a document loads, instead of wiring it manually, use useViewportAutoFitScale('width') (or 'height'), as the Getting started guide does.
The viewport also zooms on its own gestures: pinch on touch devices and the zoom wheel/trackpad gestures. They go through the same scale, so your controls and the gestures never disagree. The gesture behavior, such as the scale limits and the wheel zoom speed, is tuned through the zoomGestures configuration of PdfProviders. The scale returned by useViewportScale follows every change regardless of its source, so persisting the zoom level only takes reading it. The zoomGestureStart and zoomGestureEnd viewport events mark the gestures themselves.
Thumbnails
Page thumbnails are built with PageCanvas: a component that renders a single page onto a canvas anywhere outside the main view. It picks the render resolution from its own displayed size (or from an explicit scale prop) and renders lazily, skipping the work while it is off-screen. For example, combined with usePages and useScrollToPage, it makes a navigation sidebar:
import { PageCanvas, usePages, useScrollToPage } from '@sdk-pdf/react'
function ThumbnailsSidebar() {
const pages = usePages()
const scrollToPage = useScrollToPage()
return (
<nav>
{pages.map((page, index) => (
<button key={page.id} onClick={() => scrollToPage(page.id)}>
{/* The render resolution follows the canvas CSS size. */}
<PageCanvas
pageId={page.id}
className="thumbnail"
role="img"
aria-label={`Thumbnail ${index + 1}`}
/>
</button>
))}
</nav>
)
}Selection toolbar
A selection toolbar shows and edits the properties of whatever is selected. The useSelection*Props hooks aggregate a property across the whole selection into a { value, set } pair: value holds the common value (or undefined when the selected pieces disagree), and set applies a new value to the entire selection. For example, a text style toolbar with useSelectionTextStyle:
import { useSelectionTextStyle } from '@sdk-pdf/react'
function SelectionToolbar() {
const { isApplicable, fontFamily, isBold, isItalic, textColor } =
useSelectionTextStyle()
// Nothing stylable is selected.
if (!isApplicable) return null
return (
<div role="toolbar">
{/* An empty value means the selection mixes different fonts. */}
<select
value={fontFamily.value ?? ''}
onChange={(event) => fontFamily.set(event.currentTarget.value)}
>
{fontFamily.options.regular.map((option) => (
<option key={option.value} value={option.value}>
{option.name}
</option>
))}
</select>
<button aria-pressed={isBold.value} onClick={() => isBold.set(!isBold.value)}>
Bold
</button>
<button aria-pressed={isItalic.value} onClick={() => isItalic.set(!isItalic.value)}>
Italic
</button>
<input
type="color"
value={textColor.value ?? '#000000'}
onChange={(event) => textColor.set(event.currentTarget.value)}
/>
</div>
)
}Beyond text style, useSelection tells whether the user is editing text or has selected blocks, useSelectedBlocks returns the selected blocks as snapshots, and useSetWidgetProps changes the properties of selected form fields. The Working with forms guide builds on these; the full list is in the Selection hooks reference.