Migrating from Nutrient Web SDK
This guide moves a React application built on Nutrient Web SDK (formerly PSPDFKit for Web) to @sdk-pdf/react. Nutrient Web SDK mounts a complete viewer application into a container element and hands you an Instance with a view state, events, and document APIs; @sdk-pdf/react ships the document engine and the page view and leaves the rest of the UI to you. Most of the migration is rebuilding the parts of the Nutrient UI your users rely on as React components, then moving your instance calls to hooks. The guide covers the standalone setup, where the document is processed in the browser; Collaboration covers setups built on Document Engine and Instant. Work through the checklist below; each step links to the section that covers it.
Beta:
@sdk-pdf/reactis in beta, and its exports and APIs may change between releases. Pin the exact package version and review the changes when you update.
Migration checklist
- Read the overview: how the two integrations differ, and the API map for every
NutrientViewerandinstancecall you use. - List the Nutrient UI your users rely on (toolbar items, sidebars, annotation toolbars, the Document Editor, the content editor, dialogs): each becomes a React component.
- Install the package and replace the
NutrientViewer.load()effect withPdfProviders. - Load and switch documents with
loadPdfandresetPdf, including the password flow. - Lay out
Viewportwith your own toolbar and sidebar. - Move page navigation from indexes to page ids.
- Move
applyOperationspage operations to the page hooks. - Move zoom and the zoom modes.
- Rebuild the thumbnails sidebar with
PageCanvas. - Move form fields and values to widgets.
- Map interaction modes and read-only mode, then content editing and annotations.
- Replace the History API and save state tracking.
- Replace
save(),exportPDF(), and Instant JSON or XFDF storage. - Check collaboration and convert stored positions using coordinates.
- Move error handling and logging and cleanup.
- Plan for features without an equivalent.
- Remove
@nutrient-sdk/viewer, the self-hosted assets folder or theuseCDNsetting, the license key or session, and any Document Engine client settings. Test encrypted, malformed, large, and form-heavy PDFs, page operations, and the save round-trip.
Overview
How the two integrations differ
Nutrient Web SDK is a packaged application:
NutrientViewer.load(configuration)mounts the viewer into acontainerelement, which needs an explicit width and height, and resolves with anInstance.NutrientViewer.unload(container)tears it down. In React, the pair lives in auseEffect.- The viewer brings its own UI: the main toolbar, sidebars, annotation toolbars, the Document Editor, the content editor, and dialogs, rendered inside a shadow root. You shape it from outside with
toolbarItemsandsetToolbarItems, theuislot configuration (preset: 'minimal'hides all of it),theme, andstyleSheets. - The UI state is an immutable
ViewState(current page, zoom, interaction mode, sidebar, read-only mode): read it frominstance.viewState, change it withinstance.setViewState(), and observe it through events on the instance, such asviewState.change. Document changes arrive the same way:annotations.change,formFieldValues.update,document.saveStateChange. - In standalone mode the engine runs as WebAssembly in the browser. Its runtime assets load from the Nutrient CDN (
useCDN: true) or from your server (baseUrl), and alicenseKey, or a DWS Viewer APIsessionsuch as a publishable key, removes the trial watermark. Several features are separate license components, such as Form Creator, Document Editor, Content Editor, and comments. - Annotations, form fields, and form field values are Immutable.js records that you change with
instance.create(),update(), anddelete(). Changes are saved asautoSaveModesets, then exported as a PDF (exportPDF) or as a diff on top of it (Instant JSON or XFDF). - Pages are zero-based indexes, and positions are page coordinates with the origin at the top left of the page.
@sdk-pdf/react is a library: the SDK owns the document, and you own the UI.
- There are no prebuilt toolbars, sidebars, or dialogs.
Viewportis the main view andPageCanvasrenders single pages; everything around them is your React code driven by hooks. PdfProvidershosts the state and configuration inside your React tree. The SDK renders unstyled, and your design system supplies the look through thethemeconfiguration.- Edits don't rewrite the PDF file. Each change is an operation in a CRDT, which powers the history;
useDocumentSavingbuilds the final PDF when you need it. - Pages and blocks are addressed by session-scoped ids, and positions are user-space units with the origin at the bottom-left corner of the page.
| Concern | Nutrient Web SDK | @sdk-pdf/react |
|---|---|---|
| UI | Prebuilt, shaped from outside (toolbarItems, ui slots, theme) | Viewport and PageCanvas only; you build toolbars, sidebars, and dialogs |
| Mounting | load() into a sized container in a useEffect, unload() in cleanup | PdfProviders and components in your React tree |
| Deployment | Runtime assets from the CDN or baseUrl, licenseKey | The package from a private registry |
| State access | ViewState and setViewState(), events on the instance | State hooks that re-render, command hooks, and event hooks |
| Pages | Zero-based indexes | Session-scoped page ids |
| Forms | Form fields by fully qualified name, widgets as annotations | Widgets with a fieldName, values bound with useWidgetValue |
| Editing text | Content Editor component, in a session you save or discard | Built in under the PDF_EDITOR content policy, with no session to save |
| Annotations | Tools and APIs for every common type | Existing annotations display; borders restyle; image stamps and ink from code |
| Persistence | save() per autoSaveMode, then exportPDF(), Instant JSON, or XFDF | prepareDocument() for the PDF |
| Collaboration | Instant, with Document Engine | Coming soon |
| File types | PDF, Office documents, and images |
What this means for your code:
- Build the UI. Every toolbar item, sidebar, and dialog your users rely on becomes a React component built from the hooks in the API reference.
- Drop the mount effect. The
load()andunload()effect, the container sizing, the instance reference, and any context that shares it are replaced byPdfProvidersand hooks. - Replace the view state.
instance.viewStatereads become state hooks, andsetViewState()calls become command hooks:useScrollToPage,useViewportScale,useSetInteractionMode. - Replace instance events with hooks. View state and document changes you listened for come from state hooks (
useCurrentPageId,useViewportScale,useSelection,useDocumentRevision) that re-render on change.useViewportEventreports only user interactions in the viewport, such as typing, drags, and form input, and its subscriptions follow the component lifecycle. - Replace diff persistence. Storing Instant JSON or XFDF next to the original file becomes storing the PDF produced by
prepareDocument(). - Replace indexes and page coordinates with page ids and user-space coordinates; check stored positions after converting them.
- Remove deployment pieces: the assets setup (
useCDNorbaseUrl), the license key, andNutrientViewer.preloadWorker().
API map
Rows marked No equivalent are covered in Features without an equivalent.
| Nutrient Web SDK | @sdk-pdf/react | Notes |
|---|---|---|
NutrientViewer.load({ container, licenseKey, useCDN }), or session with a publishable key | <PdfProviders> | No container, assets setup, license key, or session. |
document as a URL or an ArrayBuffer | loadPdf(Uint8Array) from useLoadPdf | fetch the URL into bytes first. |
password, onPasswordRequired, maxPasswordRetries | loadPdf(data, { password }), catch InvalidPasswordError | No built-in password prompt. |
httpHeaders, signal | No equivalent | Headers and cancellation go on your own fetch. |
The resolved load() promise | loadState.status === 'ready' | |
NutrientViewer.unload(container), then load() with another document | resetPdf() from useResetPdf, then loadPdf() | The rest of your UI stays mounted. |
instance.totalPageCount | usePageCount | |
viewState.currentPageIndex, viewState.currentPageIndex.change | useCurrentPageId | Re-renders with the id of the page in the middle of the viewport. |
setViewState() with currentPageIndex, goToNextPage(), goToPreviousPage() | scrollToPage(pageId) from useScrollToPage | |
instance.pageInfoForIndex(pageIndex) | The entries of usePages | id, width, height, rotation. |
jumpToRect(), jumpAndZoomToRect() | No equivalent | scrollToBlock scrolls to a block, not to a rectangle. |
applyOperations() with addPage | createPage(width, height, insertAfterPageId?) from usePageCreate | Inserts after a page, or appends. |
removePages, keepPages | deletePages(pageIdOrIds) from usePageDelete | For keepPages, delete the other ids. |
movePages | moveBefore or moveAfter from usePageMove | |
rotatePages | setPageRotation(pageIdOrIds, rotation) from useSetPageRotation | Absolute degrees, not an added rotation. |
duplicatePages | duplicatePage(sourcePageId, insertAfterPageId?) from usePageDuplicate | One page per call. |
document.change event | Track the page commands where you call them; usePages re-renders on page changes | Page operations emit no events. |
| Extracting pages to a new file | prepareDocument({ pageIds }) | Saves only the listed pages, in the listed order. |
importDocument, cropPages, addPageMargins, setPageLabel | No equivalent | |
viewState.zoom as a number, zoomIn(), zoomOut(), instance.currentZoomLevel | useViewportScale | |
ZoomMode.FIT_TO_WIDTH | useViewportAutoFitScale, useViewportFitScale | A one-time fit, not a mode that stays applied. |
ZoomMode.FIT_TO_VIEWPORT | Math.min(fitWidthScale, fitHeightScale) | See Zoom and fit modes. |
ZoomMode.AUTO | No equivalent | |
viewState.zoom.change event | scale from useViewportScale in a useEffect | zoomGestureEnd from useViewportEvent reports pinch and wheel zoom. |
layoutMode, scrollMode, pagesRotation | No equivalent | Viewport has one scrollable layout. |
SidebarMode.THUMBNAILS, renderPageAsImageURL() | PageCanvas | |
instance.getFormFields() | useWidgets, useWidgetsGetter | Widgets, not fields. |
getFormFieldValues(), setFormFieldValues() | useWidgetValue | One widget per hook call. |
formFieldValues.update event | widgetInput from useViewportEvent; useWidgetValue re-renders | widgetInput reports values the user changes in the viewport. |
WidgetAnnotation and a FormField passed to instance.create() | createWidgets(props) from useWidgetsCreate | |
InteractionMode.TEXT_WIDGET and the other widget modes, FORM_CREATOR | The { type: 'addWidget', widgetProps } tool | |
setViewState() with interactionMode | useSetInteractionMode | See Editing and interaction tools. |
ViewState#readOnly | PDF_PRINTER from CONTENT_POLICY_PRESET | PDF_VIEWER keeps widgets fillable. |
InteractionMode.CONTENT_EDITOR, beginContentEditingSession() | The PDF_EDITOR content policy, useProgrammaticEditor | See Content editing. |
instance.create() for ink and image annotations | useInkAnnotationCreate, useStampAnnotationCreate | See Annotations. |
instance.create() for other annotation types | No equivalent | See Annotations. |
instance.update() for annotations | useSelectionAnnotationProps | Border color and width of selected annotations only. |
annotations.change and the other annotation events | useDocumentRevision in a useEffect | No annotation-specific events. |
exportInstantJSON(), exportXFDF(), the instantJSON and XFDF options | No equivalent | Save the PDF instead. |
enableHistory, instance.history.undo(), redo(), canUndo(), canRedo() | useUndoRedo | No switch to turn it on. |
instance.history.clear() | No equivalent | |
hasUnsavedChanges(), document.saveStateChange event | useDocumentRevision, useDocumentRevisionGetter | Compare with the revision you last saved. |
autoSaveMode, instance.save() | Nothing to call | prepareDocument() includes every change. |
instance.exportPDF() | prepareDocument() from useDocumentSaving | Resolves with a Uint8Array, not an ArrayBuffer. |
The export-pdf toolbar item | downloadDocument() | No file name option. |
exportPDF({ flatten }), exportPDFWithOperations() | No equivalent | |
NutrientViewer.Error and the rejected load() promise | Typed error classes from the call that failed | See Errors and logging. |
logLevel: NutrientViewer.LogLevel.DEBUG | logMessages | The SDK never prints to the console. |
addEventListener(), removeEventListener() | State hooks, and useViewportEvent for user interactions | Subscriptions follow the component lifecycle. |
toolbarItems, setToolbarItems(), ui slots, theme, styleSheets | Your own components and the theme configuration, see Theming and customization | |
instance.search(), startUISearch() | useDocumentSearch | Highlights the matches and scrolls to the active one; the search UI is yours. |
instance.print() | No equivalent | PageCanvas can render pages for your own print layout, as loaded from the file, without session edits. |
transformClientToPageSpace(), transformPageToClientSpace() | No equivalent | See Coordinates. |
instant: true with Document Engine | Coming soon | See Collaboration. |
Install and set up
Before: Nutrient Web SDK
Nutrient Web SDK installs as @nutrient-sdk/viewer and mounts into a ref from an effect. The runtime assets (WebAssembly, fonts, icons) load from the Nutrient CDN with useCDN: true, or from your server with baseUrl after you extract the assets archive into your public folder:
npm install @nutrient-sdk/viewerimport { useEffect, useRef } from 'react'
import NutrientViewer from '@nutrient-sdk/viewer'
function App() {
const containerRef = useRef(null)
useEffect(() => {
const container = containerRef.current
// Make sure only one viewer is mounted in the container.
NutrientViewer.unload(container)
NutrientViewer.load({
container,
// Or baseUrl, pointing at the self-hosted assets.
useCDN: true,
licenseKey: 'YOUR_LICENSE_KEY',
document: '/files/contract.pdf',
initialViewState: new NutrientViewer.ViewState({ readOnly: true }),
}).then((instance) => {
// instance.viewState, instance.setViewState(), instance.addEventListener(), ...
})
return () => {
NutrientViewer.unload(container)
}
}, [])
// The container needs an explicit width and height before load() runs.
return <div ref={containerRef} style={{ height: '100vh', width: '100vw' }} />
}Under React Strict Mode the effect runs twice in development; unloading the container before each load keeps one viewer mounted. The signal version in Cleanup and lifecycle also cancels a load still in progress.
After: @sdk-pdf/react
Add the @sdk-pdf scope to an .npmrc next to your package.json, keep the token in an environment variable, and install; Getting started has the details:
@sdk-pdf:registry=https://registry.sdk.pdf.net
//registry.sdk.pdf.net/:_authToken=${PDF_NET_REGISTRY_TOKEN}npm install @sdk-pdf/reactSet up the project as Getting started describes: import the stylesheet, render the SDK on the client only, alias canvas in the bundler, and turn off React Strict Mode.
Mount PdfProviders in your tree. Its props must keep their identity between renders, so define static values outside the component and memoize the configuration:
import { useMemo } from 'react'
import { CONTENT_POLICY_PRESET, InteractionTool, PdfProviders } from '@sdk-pdf/react'
import '@sdk-pdf/react/style.css'
// Like interactionMode null in Nutrient: select text and scroll.
const textSelectTool: InteractionTool = { type: 'textSelect' }
export function App({ readOnly }: { readOnly: boolean }) {
// The PDF application re-renders whenever a PdfProviders prop changes identity.
const config = useMemo(
() => ({
// Nutrient's default viewportPadding is 20 pixels on every side.
theme: { viewportPadding: 20 },
initialInteractionTool: textSelectTool,
// Applied when the viewport first mounts; switch later with useSetInteractionMode.
initialContentPolicy: readOnly
? CONTENT_POLICY_PRESET.PDF_PRINTER
: CONTENT_POLICY_PRESET.PDF_VIEWER,
}),
[readOnly]
)
return (
<PdfProviders {...config}>
<DocumentLoader url="/files/contract.pdf" />
</PdfProviders>
)
}What to know
- No assets, no license key in code. The install is the registry scope and the package. There is no CDN setting, no assets archive to serve, no
baseUrlorlicenseKeyto pass, and nopreloadWorker()call to warm up. - No mount effect. The SDK renders as components, so the
useRefanduseEffectmount, theunloadcleanup, and sharing the instance through React context go away: any component underPdfProvidersreaches the document through hooks.Viewportstill fills its parent, so the parent needs an explicit size, as the Nutrient container did. - Pick the content policy deliberately.
PdfProvidersdefaults to theselecttool andPDF_EDITOR, where text, annotations, links, and widgets are all editable. A Nutrient viewer opens with no interaction mode (text selection and scrolling) and, unlessreadOnlyis set, with its annotation tools in the toolbar. Start a reader withtextSelectandPDF_VIEWER, as above, and switch toPDF_EDITORwhen users edit, as in Editing and interaction tools. - readOnly maps to PDF_PRINTER. Nutrient's read-only mode also stops form filling, which matches
PDF_PRINTER(everything read-only, links clickable).PDF_VIEWERkeeps widgets fillable.
Load and reset a document
Before: Nutrient Web SDK
The document option takes a URL or an ArrayBuffer. A wrong or missing password calls onPasswordRequired before the built-in password prompt, and maxPasswordRetries limits the prompt. To switch documents, you unload the viewer and load again into the same container:
const container = document.querySelector('#viewer')
let instance
async function openDocument(arrayBuffer) {
// Remember the page, then unload the current viewer before loading the next document.
const lastPageIndex = instance?.viewState.currentPageIndex ?? 0
if (instance) NutrientViewer.unload(container)
instance = await NutrientViewer.load({
container,
useCDN: true,
licenseKey: 'YOUR_LICENSE_KEY',
// load() takes ownership of the buffer and detaches it: pass a copy to reuse the
// bytes.
document: arrayBuffer.slice(0),
// Resolve with a password to try it, or with null to show the built-in prompt.
onPasswordRequired: async () => lookUpPassword(),
})
// UI state is per instance: restore the page yourself, within the new page count.
const pageIndex = Math.min(lastPageIndex, instance.totalPageCount - 1)
instance.setViewState((viewState) => viewState.set('currentPageIndex', pageIndex))
}After: @sdk-pdf/react
loadPdf from useLoadPdf takes the file bytes, and the load state decides what to render. A URL becomes a fetch:
import { useRef, useState } from 'react'
import { InvalidPasswordError, useLoadPdf, useResetPdf } from '@sdk-pdf/react'
function DocumentLoader({ url }: { url: string }) {
const [loadState, loadPdf] = useLoadPdf()
const bytesRef = useRef<Uint8Array | null>(null)
const [isPasswordNeeded, setIsPasswordNeeded] = useState(false)
const [loadError, setLoadError] = useState<unknown>()
async function open(password?: string) {
setLoadError(undefined)
try {
if (!bytesRef.current) {
// Replaces document: url. Headers, credentials, and an AbortSignal go on this
// fetch.
const response = await fetch(url)
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
bytesRef.current = new Uint8Array(await response.arrayBuffer())
}
// loadPdf reads from the buffer it receives, so keep the original for a retry and
// pass a copy.
await loadPdf(bytesRef.current.slice(), { password })
setIsPasswordNeeded(false)
} catch (error) {
// Replaces onPasswordRequired and the built-in password prompt.
if (error instanceof InvalidPasswordError) setIsPasswordNeeded(true)
else setLoadError(error)
}
}
if (isPasswordNeeded)
return <PasswordPrompt onSubmit={(password) => void open(password)} />
if (loadError) return <p role="alert">The document can't be opened.</p>
if (loadState.status === 'initial')
return <button onClick={() => void open()}>Open</button>
if (loadState.status === 'loading') return <p>Loading the document…</p>
return <PdfApplication />
}
function CloseDocumentButton() {
const resetPdf = useResetPdf()
// Replaces NutrientViewer.unload(container): the load state returns to 'initial'.
return <button onClick={() => resetPdf()}>Close</button>
}What to know
- Bytes, not URLs.
loadPdftakes aUint8Array. The network options ofload()(httpHeaders, linearized loading,tempStorage) move to your ownfetchor have no equivalent, andloadPdftakes nosignal: abort thefetchinstead. - The same buffer habit. Nutrient Web SDK takes ownership of the
ArrayBufferyou pass and detaches it.loadPdfreads from the given buffer for the document's whole lifetime, and anything else touching it can detach it. Either way, pass a copy when you keep the bytes:loadPdf(data.slice()). - Passwords are a rejection, not a callback. Without a password, the empty password is tried, as the PDF specification prescribes; a missing or wrong password rejects with
InvalidPasswordError. There is no built-in prompt and nomaxPasswordRetries: render your own prompt and count attempts yourself. - Switching documents keeps the UI. Instead of unloading and mounting a new viewer, call
resetPdf()and thenloadPdf();PdfProvidersand your components stay mounted. Restoring the page or zoom after a switch is still yours: callscrollToPageandsetScaleonce the new document is ready. - PDF only.
useLoadPdfloads PDF documents; Office documents and images have no equivalent, and there is noinstantJSONorXFDFto apply on load. - Closing invalidates ids. After
resetPdf(), page and block ids, widget snapshots, and document revisions from the old document are no longer valid.
Display the document
Before: Nutrient Web SDK
The viewer renders the document with its toolbar and sidebars, and integrations trim and theme it through the configuration. Custom toolbar buttons are items with type: 'custom' and an onPress callback; the ui slots replace or hide built-in components, down to preset: 'minimal', which leaves only the pages:
NutrientViewer.load({
container,
useCDN: true,
document: '/files/contract.pdf',
toolbarItems: [
...NutrientViewer.defaultToolbarItems.filter((item) => item.type !== 'print'),
{ type: 'custom', id: 'share', title: 'Share', onPress: () => openShareDialog() },
],
toolbarPlacement: NutrientViewer.ToolbarPlacement.TOP,
theme: NutrientViewer.Theme.DARK,
// The viewer renders in a shadow root, so your page CSS has to be passed in.
styleSheets: ['/viewer-overrides.css'],
initialViewState: new NutrientViewer.ViewState({
sidebarMode: NutrientViewer.SidebarMode.THUMBNAILS,
zoom: NutrientViewer.ZoomMode.FIT_TO_WIDTH,
}),
})After: @sdk-pdf/react
Viewport renders the pages, and your components render everything else. Building a PDF application maps the usual parts to hooks:
import { Config, useViewportAutoFitScale, Viewport } from '@sdk-pdf/react'
// A fuller theme than the one in App: pass it as PdfProviders' theme, defined outside
// components.
export const theme: Config['theme'] = {
viewportPadding: [20, 20],
classNames: { scrollViewport: 'viewer', page: 'viewer-page' },
styles: {
scrollViewport: { '--selection-color': 'rebeccapurple' } as React.CSSProperties,
},
}
function PdfApplication() {
// Like zoom: ZoomMode.FIT_TO_WIDTH in initialViewState, applied once per loaded
// document.
useViewportAutoFitScale('width')
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
{/* Your main toolbar: the pager, zoom, tool, history, and save controls from this
guide. */}
<MainToolbar />
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
{/* Like SidebarMode.THUMBNAILS: render it conditionally to toggle it. */}
<ThumbnailsSidebar />
{/* The viewport fills its parent, so the parent needs an explicit size. */}
<div style={{ flex: 1, minWidth: 0 }}>
<Viewport />
</div>
</div>
</div>
)
}What to know
- There is nothing to hide.
toolbarItems,setToolbarItems(),showToolbar,toolbarPlacement,sidebarMode, and theuislots have no counterpart. A custom toolbar item becomes a plainbutton, and a sidebar mode becomes a component you render or don't. Selection, text editing, form filling, links, and zoom gestures work insideViewportand are limited by the content policy; every control around it exists only if you build it. - Theming is CSS you own.
theme.classNamesandtheme.stylestarget the SDK's elements (scrollViewport,page,pageCanvas,resizable,widgetOverlay, and more), and CSS variables such as--selection-colorand--widget-overlay-backgroundtune the selection and widget visuals; Theming and customization lists them. There are no built-in light, dark, or high-contrast themes, and nothing likestyleSheetsto inject. - Render only when ready.
ViewportthrowsDocumentNotLoadedErrorwhen no document is loaded. - One layout.
layoutMode(SINGLE,DOUBLE,AUTO),scrollMode(CONTINUOUS,PER_SPREAD,DISABLED),keepFirstSpreadAsSinglePage, andspreadSpacinghave no equivalent:Viewportshows the pages in one scrollable area.theme.viewportPaddingreplacesViewState#viewportPadding.
Pages and navigation
Before: Nutrient Web SDK
The current page is a zero-based index in the view state. You move it with setViewState(), and changes arrive as viewState.currentPageIndex.change:
pageCountLabel.textContent = instance.totalPageCount
instance.addEventListener('viewState.currentPageIndex.change', (pageIndex) => {
pageInput.value = pageIndex + 1
})
nextButton.onclick = () => instance.setViewState((viewState) => viewState.goToNextPage())
// An index out of range throws a NutrientViewer.Error.
pageInput.onchange = () =>
instance.setViewState((viewState) =>
viewState.set('currentPageIndex', Number(pageInput.value) - 1)
)After: @sdk-pdf/react
usePages returns the pages in document order, so a page number is an index in that list plus one. useScrollToPage scrolls to a page id, and useCurrentPageId re-renders with the page in the middle of the viewport:
import { useCurrentPageId, usePages, useScrollToPage } from '@sdk-pdf/react'
function Pager() {
const pages = usePages()
// Like viewState.currentPageIndex, with a page id instead of an index.
const currentPageId = useCurrentPageId()
const scrollToPage = useScrollToPage()
// There is no current page until the viewport shows one, so start on the first page.
const currentIndex = Math.max(
0,
pages.findIndex((page) => page.id === currentPageId)
)
function goToPageNumber(pageNumber: number) {
// Clamp the number instead of throwing on an index out of range.
const page = pages[Math.min(Math.max(pageNumber, 1), pages.length) - 1]
if (page) scrollToPage(page.id, { behavior: 'smooth' })
}
return (
<div role="toolbar">
<button disabled={currentIndex <= 0} onClick={() => goToPageNumber(currentIndex)}>
Previous
</button>
<label>
Page
<input
type="number"
min={1}
max={pages.length}
value={currentIndex + 1}
onChange={(event) => goToPageNumber(event.currentTarget.valueAsNumber)}
/>
of {pages.length}
</label>
<button
disabled={currentIndex >= pages.length - 1}
onClick={() => goToPageNumber(currentIndex + 2)}
>
Next
</button>
</div>
)
}When you need a page's index inside a handler, such as for analytics, getPageIndex(pageId) from usePageIndexGetter resolves an id to its zero-based index without re-rendering on order changes. For a page count alone, usePageCount replaces instance.totalPageCount.
What to know
- Ids survive reordering; indexes don't. An id keeps pointing at the same page while pages are added, removed, and rearranged by the user, where an index would drift to another page. Ids are session-scoped: persist page numbers, not ids, and resolve them once the document is ready.
- Stale ids throw.
scrollToPagethrowsInvalidInputErrorfor an unknown or deleted id, assetViewState()throws for an index out of range;usePageIndexGetterreturnsundefinedinstead. - Page sizes.
pageInfoForIndex(pageIndex).widthandheightbecome thewidthandheightof theusePages()entries, in user-space units with the rotation applied, next to the page'srotation. - Rotation edits the document.
pagesRotation,rotateLeft(), androtateRight()turn the view only and aren't saved.setPageRotationfromuseSetPageRotationrotates pages in the document as an undoable edit; there is no view-only rotation. - Scrolling to a spot.
jumpToRect()andjumpAndZoomToRect()have no equivalent.scrollToBlock(blockId, options?)fromuseScrollToBlockscrolls to a block, such as a form widget or a paragraph. Page labels (PageInfo#label) have no equivalent either.
Page manipulation
Before: Nutrient Web SDK
Page changes are document operations by index, applied with applyOperations() when the license includes the Document Editor component. Each operation in the array works on the result of the previous one, and the Document Editor UI (InteractionMode.DOCUMENT_EDITOR) offers the same actions with page thumbnails:
await instance.applyOperations([
{
type: 'addPage',
afterPageIndex: 1,
pageWidth: 612,
pageHeight: 792,
rotateBy: 0,
backgroundColor: new NutrientViewer.Color({ r: 255, g: 255, b: 255 }),
},
{ type: 'duplicatePages', pageIndexes: [0] },
{ type: 'movePages', pageIndexes: [5], beforePageIndex: 0 },
// Adds to the page's current rotation.
{ type: 'rotatePages', pageIndexes: [0], rotateBy: 90 },
{ type: 'removePages', pageIndexes: [4] },
])
instance.addEventListener('document.change', (operations) => {
trackPageOperations(operations)
})After: @sdk-pdf/react
Each page operation is a hook that returns a command taking page ids: usePageCreate, usePageDuplicate, usePageMove, useSetPageRotation, and usePageDelete. The commands apply one at a time, and the ids don't shift as the order changes:
import {
usePageCreate,
usePageDelete,
usePageDuplicate,
usePageMove,
usePages,
useSetPageRotation,
} from '@sdk-pdf/react'
function PageActions({ pageId }: { pageId: string }) {
const pages = usePages()
const createPage = usePageCreate()
const duplicatePage = usePageDuplicate()
const { moveAfter } = usePageMove()
const setPageRotation = useSetPageRotation()
const deletePages = usePageDelete()
// Like the document.change listener: page commands emit no events, so track them where
// you call them.
function deletePage() {
deletePages(pageId)
track('pages-deleted', 1)
}
const index = pages.findIndex((page) => page.id === pageId)
const page = pages[index]
if (!page) return null
return (
<div role="toolbar">
{/* Like addPage with afterPageIndex. A4 is roughly 595 × 842 user-space units. */}
<button onClick={() => createPage(595, 842, pageId)}>Insert page after</button>
{/* Like duplicatePages: the copy goes right after the source page. */}
<button onClick={() => void duplicatePage(pageId)}>Duplicate</button>
<button
disabled={index === pages.length - 1}
onClick={() => moveAfter(pageId, pages[index + 1]?.id)}
>
Move down
</button>
{/* rotatePages adds to the current angle; setPageRotation sets an absolute one. */}
<button onClick={() => setPageRotation(pageId, page.rotation + 90)}>
Rotate clockwise
</button>
<button disabled={pages.length <= 1} onClick={deletePage}>
Delete
</button>
</div>
)
}What to know
| Operation | Nutrient Web SDK | @sdk-pdf/react |
|---|---|---|
| Insert | addPage before or after an index, with size, color, and rotation | After insertAfterPageId, or appended; width and height in user-space units (1/72 of an inch), no defaults |
| Duplicate | duplicatePages, each copy after its original | duplicatePage is asynchronous, one page per call, after the source by default |
| Move | movePages before or after an index | moveBefore(ids, beforePageId?) (start when omitted) or moveAfter(ids, afterPageId?) (end when omitted) |
| Rotate | rotatePages adds rotateBy to the current rotation | Sets an absolute angle; -90 and 450 are normalized; angles off a quarter turn throw InvalidInputError |
| Remove | removePages by index, or keepPages for the complement | deletePages by id; keeping a subset means deleting the other ids |
- Undoable. Nutrient's History API tracks annotations, and the Document Editor UI has its own undo and redo for the changes you then save or cancel. Here, deleting, moving, or rotating several pages in one call is a single undo step of the document history, and an undo can restore a deleted page.
- No events.
document.changedelivers the operations array; here, page commands emit no events. Track them where you call them, and read the current pages fromusePages, which re-renders on every page change, undo and redo included. - Extracting pages.
prepareDocument({ pageIds })fromuseDocumentSavingsaves only the listed pages, in the listed order, without changing the loaded document. - No import, cropping, or margins.
importDocument,cropPages,addPageMargins,setPageLabel,flattenAnnotations, andexportPDFWithOperations()have no equivalent: the page hooks change the loaded document.
Zoom and fit modes
Before: Nutrient Web SDK
ViewState#zoom is a number, a ZoomMode, or a ZoomConfiguration (the default, with ZoomMode.AUTO). A zoom mode stays applied whenever the viewport resizes: FIT_TO_WIDTH fits the width of the broadest page, and FIT_TO_VIEWPORT fits the current page completely. The toolbar zoom buttons step by zoomStep (1.25 by default); zoomIn() and zoomOut() step by 25%. instance.currentZoomLevel is always the resulting number:
NutrientViewer.load({
// ...
initialViewState: new NutrientViewer.ViewState({
zoom: NutrientViewer.ZoomMode.FIT_TO_WIDTH,
}),
})
zoomInButton.onclick = () => instance.setViewState((viewState) => viewState.zoomIn())
fitPageButton.onclick = () =>
instance.setViewState((viewState) =>
viewState.set('zoom', NutrientViewer.ZoomMode.FIT_TO_VIEWPORT)
)
// The zoom can be a mode or a configuration: persist the resulting number.
instance.addEventListener('viewState.zoom.change', () =>
persistZoom(instance.currentZoomLevel)
)After: @sdk-pdf/react
useViewportScale reads and sets the scale, useViewportFitScale calculates fit scales on demand, and useViewportAutoFitScale (in PdfApplication above) fits once per loaded document:
import { useEffect } from 'react'
import { useViewportFitScale, useViewportScale } from '@sdk-pdf/react'
// Like zoomIn() and zoomOut(), and the default ViewState#zoomStep.
const ZOOM_STEP = 1.25
function ZoomControls() {
const [scale, setScale] = useViewportScale()
const getFitScale = useViewportFitScale()
// Like viewState.zoom.change: every scale change, from controls, gestures, or an auto
// fit.
useEffect(() => persistZoom(scale), [scale])
function fitPage() {
// Call the getter in the handler: the result reflects the viewport size at that
// moment.
const { fitWidthScale, fitHeightScale } = getFitScale()
setScale(Math.min(fitWidthScale, fitHeightScale))
}
return (
<div role="toolbar">
<button onClick={() => setScale(scale / ZOOM_STEP)}>−</button>
<span>{Math.round(scale * 100)}%</span>
<button onClick={() => setScale(scale * ZOOM_STEP)}>+</button>
<button onClick={() => setScale(getFitScale().fitWidthScale)}>Fit width</button>
<button onClick={fitPage}>Fit page</button>
</div>
)
}Mapping the zoom modes
| Nutrient Web SDK | @sdk-pdf/react |
|---|---|
ZoomMode.FIT_TO_WIDTH | useViewportAutoFitScale('width') on load, or setScale(getFitScale().fitWidthScale); both fit the pages into the viewport width. |
ZoomMode.FIT_TO_VIEWPORT | setScale(Math.min(fitWidthScale, fitHeightScale)). The fit scales measure the widest and tallest pages, not the current page. |
ZoomMode.AUTO | No equivalent. |
A number, zoomIn() | setScale(scale) and setScale(scale * 1.25). Check stored zoom values against a test document before you reuse them. |
What to know
- Fits are one-time calculations. A Nutrient zoom mode is reapplied on every resize.
useViewportAutoFitScaleapplies when a document loads and doesn't re-apply on viewport resizes or manual zooming;useViewportFitScalereflects the viewport size at the moment of the call. - What the fits account for. Both fit scales include
viewportPadding. Scale1is 100%: one user-space unit per CSS pixel.setScale(scale, pivot?)takes a positive finite number and keeps the document still atpivot, a point{ x, y }given as fractions of the viewport area (0the top or left edge,1the bottom or right edge), the center by default. The third item ofuseViewportScale(),animateScale(scale, pivot?), animates the change and resolves when it ends. - The scale updates after a gesture. During a pinch or wheel zoom,
scalekeeps its value until the gesture ends;zoomGestureStartandzoomGestureEndfromuseViewportEventmark the gesture. - Gestures. Pinch and wheel zoom are built in and tuned with
zoomGesturesonPdfProviders(minScale0.5andmaxScale4by default, wheel speed). Marquee zoom has no equivalent.
Thumbnails
Before: Nutrient Web SDK
The built-in thumbnails sidebar is a sidebar mode; custom thumbnails come from renderPageAsImageURL, which renders a page at a width or height up to 5000 pixels and resolves with a Blob URL to revoke later:
instance.setViewState((viewState) =>
viewState.set('sidebarMode', NutrientViewer.SidebarMode.THUMBNAILS)
)
const src = await instance.renderPageAsImageURL(
{ width: 200 * window.devicePixelRatio },
pageIndex
)
thumbnailImage.src = src
// Later, once the image is gone:
URL.revokeObjectURL(src)After: @sdk-pdf/react
PageCanvas renders a page lazily at the canvas's CSS size; the sidebar, its selection, and its page actions are yours. A sidebar that selects a page and shows the PageActions from Page manipulation:
import { useState } from 'react'
import { PageCanvas, usePages, useScrollToPage } from '@sdk-pdf/react'
function ThumbnailsSidebar() {
const pages = usePages()
const scrollToPage = useScrollToPage()
const [selectedPageId, setSelectedPageId] = useState<string>()
return (
<nav style={{ width: 200, overflowY: 'auto' }}>
{pages.map((page, index) => (
<button
key={page.id}
aria-current={page.id === selectedPageId}
onClick={() => {
setSelectedPageId(page.id)
scrollToPage(page.id)
}}
>
{/* The render resolution follows the canvas CSS size: style .thumbnail to the
width you need. */}
<PageCanvas
pageId={page.id}
className="thumbnail"
role="img"
aria-label={`Page ${index + 1}`}
/>
</button>
))}
{selectedPageId && <PageActions pageId={selectedPageId} />}
</nav>
)
}What to know
- No image URLs to manage. There is no width to compute from
devicePixelRatioand no Blob URL to revoke: the render resolution follows the canvas's displayed CSS size, or an explicitscaleprop. - Lazy by default. An off-screen canvas does no work until it becomes visible. In a virtualized list, pass
isVisible. - Annotations included.
PageCanvasdraws annotations, form widgets included, unlesshideAnnotationsis set. - Thumbnails that show session edits are coming soon.
PageCanvasdraws the page as loaded from the file; the main view shows the edits. Rotation is followed live. - Render only when ready.
PageCanvasthrowsDocumentNotLoadedErrorwhen no document is loaded.
Forms
Before: Nutrient Web SDK
Values are a record keyed by the fully qualified field name: strings for text fields and radio groups, arrays of export values for checkboxes, null when empty. getFormFieldValues() covers the fields loaded so far, so integrations call getFormFields() first:
await instance.getFormFields()
const values = instance.getFormFieldValues()
// { 'customer.name': 'Jane Doe', newsletter: ['Yes'], plan: 'pro' }
await instance.setFormFieldValues({ 'customer.name': 'Jane Doe', newsletter: ['Yes'] })
instance.addEventListener('formFieldValues.update', () => {
autosave(instance.getFormFieldValues())
})Creating a field takes a widget annotation and a form field that point at each other, and the Form Creator component in the license:
const widget = new NutrientViewer.Annotations.WidgetAnnotation({
id: NutrientViewer.generateInstantId(),
pageIndex: 0,
formFieldName: 'customer.name',
// Page coordinates: the origin is the top-left corner of the page.
boundingBox: new NutrientViewer.Geometry.Rect({
left: 72,
top: 72,
width: 180,
height: 24,
}),
})
const formField = new NutrientViewer.FormFields.TextFormField({
name: 'customer.name',
annotationIds: NutrientViewer.Immutable.List([widget.id]),
})
await instance.create([widget, formField])After: @sdk-pdf/react
Forms are widget-centric: you work with the widgets on the pages, and each value is shaped like the value of the matching HTML form control, as Working with forms explains. The counterpart of one entry of the values record is a widget looked up by fieldName and bound with useWidgetValue:
import {
CheckboxWidget,
TextWidget,
useWidgets,
useWidgetsCreate,
useWidgetValue,
} from '@sdk-pdf/react'
// Reads and writes one field by name, for a text field or a single checkbox.
function FieldByName({ fieldName }: { fieldName: string }) {
const { widgets } = useWidgets()
// Same-named widgets show and synchronize one value, so binding one of them binds the
// field.
const widget = widgets.find((widget) => widget.fieldName === fieldName)
if (!widget) return null
switch (widget.widgetType) {
case 'text':
return <TextInput widget={widget} />
case 'checkbox':
return <CheckboxInput widget={widget} />
default:
return null
}
}
function TextInput({ widget }: { widget: TextWidget }) {
// Like a useState backed by the document: filling in the main view and undo update it.
const [value, setValue] = useWidgetValue(widget)
return (
<label>
{widget.fieldName}
<input value={value} onChange={(event) => setValue(event.currentTarget.value)} />
</label>
)
}
function CheckboxInput({ widget }: { widget: CheckboxWidget }) {
// A boolean, where getFormFieldValues reports an array of export values.
const [isChecked, setIsChecked] = useWidgetValue(widget)
return (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={(event) => setIsChecked(event.currentTarget.checked)}
/>
{widget.fieldName}
</label>
)
}
function useAddNameField() {
const createWidgets = useWidgetsCreate()
return async (pageId: string, pageHeight: number) => {
// One call creates the widget and its field: there is no separate form field record.
const [widget] = await createWidgets({
pageId,
widgetType: 'text',
// The Rect above, measured from the bottom-left corner of an unrotated page.
x: 72,
y: pageHeight - 72 - 24,
width: 180,
height: 24,
fieldName: 'customer.name',
})
return widget
}
}From form fields to widgets
| Concept | Nutrient Web SDK | @sdk-pdf/react |
|---|---|---|
| Lookup | getFormFields(), a field's annotationIds | useWidgets() (re-renders, isLoaded tells when the list is complete), or await getWidgets() from useWidgetsGetter (loads every page with widgets first) |
| Values | A record by field name: string, string[], or null | useWidgetValue(widget): string for text and choice, boolean for checkbox and radio, option indices for multiChoice |
| Writing | setFormFieldValues({ name: value }), several fields at once | The setter from useWidgetValue, one widget at a time; there is no setter by field name |
| Radio groups | One value for the group: the value of the selected option | One radio widget per option with the shared field name, each with its own checked state |
| Change events | formFieldValues.update | widgetInput from useViewportEvent for values the user changes in the viewport; useWidgetValue re-renders on any change, undo and redo included |
| Creation | WidgetAnnotation and FormField with generateInstantId(), create() | createWidgets(props) from useWidgetsCreate; required: pageId, widgetType, x, y, width, height; a unique fieldName is generated when omitted |
| Interactive creation | TEXT_WIDGET, CHECKBOX_WIDGET, and the other widget modes, formDesignMode | The { type: 'addWidget', widgetProps } interaction tool, used with PDF_EDITOR; the select tool moves and resizes widgets |
| Deletion | instance.delete(formField) also removes its widgets | useBlockDelete with widget ids; same-named widgets act as one field, so delete them together |
| Properties | instance.update() on the widget or the field | setWidgetProps(ids, props, options?) from useSetWidgetProps: isReadOnly, borderColor, fillColor, fontSize, isMultiline, options, and more |
| Form data | Instant JSON formFieldValues, XFDF | No equivalent |
What to know
- Coordinates differ. A Nutrient
boundingBoxis measured from the top-left corner of the page with y pointing down; widget positions here are user-space units from the bottom-left corner. On an unrotated page,yis the page height minustopminusheight. Check placement on a test document before converting stored positions, especially for rotated or cropped pages. - Values are read and written per widget.
useWidgetValuebinds one widget per hook call. To prefill a form from a stored record, render a bound component per field, likeFieldByNameabove; for a radio group, bind each option's widget. - Form scripts. Nutrient runs the JavaScript in a PDF by default in standalone mode, for validation, formatting, and calculations. The SDK recognizes the date formats Acrobat generates without executing scripts and exposes them as a text widget's
format(FormatType.DateTime); seeFormatType. Fields driven by hand-written scripts read as plain text fields. - Read-only fields.
isReadOnlyrestricts filling the field, not authoring the widget; with{ excludeFromHistory: true }, an undo can't unlock a submitted form. - Signatures.
signaturewidgets carry a read-onlyisCryptoSigned; the SDK never signs. - The field being filled.
useFocusedWidgetIdreturns the widget whose control has the focus, where Nutrient integrations track the focused form field themselves.
Editing and interaction tools
Before: Nutrient Web SDK
The interaction mode in the view state decides what a click or drag does, null being text selection and scrolling. Read-only mode hides the editing UI and stops form filling, and isEditableAnnotation narrows editing per annotation:
// The free text annotation tool; null returns to text selection.
instance.setViewState((viewState) =>
viewState.set('interactionMode', NutrientViewer.InteractionMode.TEXT)
)
instance.setViewState((viewState) => viewState.set('interactionMode', null))
instance.setViewState((viewState) => viewState.set('readOnly', true))
NutrientViewer.load({
// ...
isEditableAnnotation: (annotation) =>
!(annotation instanceof NutrientViewer.Annotations.InkAnnotation),
})
instance.addEventListener('viewState.change', (viewState) => {
highlightActiveTool(viewState.interactionMode)
})After: @sdk-pdf/react
The interaction mode combines a content policy (what may change) and an interaction tool (what a gesture does); useSetInteractionMode switches both at once, and useInteractionTool reads the tool back:
import {
CONTENT_POLICY_PRESET,
InteractionTool,
stageImage,
useInteractionTool,
useSetInteractionMode,
} from '@sdk-pdf/react'
const textSelectTool: InteractionTool = { type: 'textSelect' }
const selectTool: InteractionTool = { type: 'select' }
const addTextTool: InteractionTool = { type: 'addText' }
function ToolPicker() {
const interactionTool = useInteractionTool()
const setInteractionMode = useSetInteractionMode()
async function placeImage(file: File) {
const image = await stageImage(file)
// Like the image toolbar item: the next click places the image as a stamp annotation.
setInteractionMode({ type: 'addImage', image }, CONTENT_POLICY_PRESET.PDF_EDITOR)
}
return (
<div role="toolbar">
{/* Like readOnly: true. PDF_VIEWER instead keeps widgets fillable. textSelect is
used only here, so it identifies the mode. */}
<button
aria-pressed={interactionTool.type === 'textSelect'}
onClick={() =>
setInteractionMode(textSelectTool, CONTENT_POLICY_PRESET.PDF_PRINTER)
}
>
Read only
</button>
{/* Select, move, and resize blocks, with editable text. */}
<button
aria-pressed={interactionTool.type === 'select'}
onClick={() => setInteractionMode(selectTool, CONTENT_POLICY_PRESET.PDF_EDITOR)}
>
Edit
</button>
<button
aria-pressed={interactionTool.type === 'addText'}
onClick={() => setInteractionMode(addTextTool, CONTENT_POLICY_PRESET.PDF_EDITOR)}
>
Add text
</button>
<input
type="file"
accept="image/*"
onChange={(event) => {
const file = event.currentTarget.files?.[0]
if (file) placeImage(file).catch(showError)
}}
/>
</div>
)
}Mapping the tools
NutrientViewer.InteractionMode and settings | @sdk-pdf/react |
|---|---|
null | { type: 'textSelect' }: native text selection for reading and copying; needs read-only text. |
PAN | No tool: Viewport scrolls and zooms on its own. |
MULTI_ANNOTATIONS_SELECTION | { type: 'select' }: select, move, and resize blocks, with drag-selection of several. |
TEXT, CALLOUT | The nearest tool is { type: 'addText' }: it creates a text paragraph at the click position and focuses it for typing. |
The image toolbar item, STAMP_PICKER with an image | { type: 'addImage', image, width?, height? } with await stageImage(blob) places a stamp annotation; the size is in user-space units. See stageImage. |
TEXT_WIDGET, CHECKBOX_WIDGET, RADIO_BUTTON_WIDGET, COMBO_BOX_WIDGET, LIST_BOX_WIDGET, BUTTON_WIDGET, SIGNATURE_WIDGET, FORM_CREATOR | { type: 'addWidget', widgetProps } with the matching widgetType. |
DATE_WIDGET | A text widget with a FormatType.DateTime format. |
CONTENT_EDITOR | Text is editable in place when the policy's text level is 'editable' (PDF_EDITOR). See Content editing. |
DOCUMENT_EDITOR | The page hooks. See Page manipulation. |
INK | No drawing tool. useInkAnnotationCreate creates an ink annotation from strokes your code collects. |
TEXT_HIGHLIGHTER, SHAPE_*, NOTE, COMMENT_MARKER, SIGNATURE, LINK, REDACT_*, measurement modes | No equivalent. |
ViewState#readOnly | CONTENT_POLICY_PRESET.PDF_PRINTER, or PDF_VIEWER to keep widgets fillable. |
isEditableAnnotation, editableAnnotationTypes | A policy level as a function of the block id, such as annotations: (blockId) => (lockedIds.has(blockId) ? 'readonly' : 'editable'). |
What to know
- Switch the policy with the tool. Pass
PDF_EDITORwith the editing tools, as above.textSelectneeds read-only text: with editable text, the combination is unsupported and reports a message throughlogMessages. - Per-kind and per-block permissions. A custom content policy is a plain object with
text,annotations,links, andwidgetslevels, finer than one read-only switch. A level can be a function of the block id, which replacesisEditableAnnotation, and'hidden'doesn't render that content at all. There is no hook to read the policy back: keep the mode you set in your own state to show or hide editing UI, where Nutrient integrations readviewState.readOnly. - Local mode. Switching the mode affects this client only.
- Selection toolbars. The annotation toolbar and the content editor's text style controls become your components on top of
useSelectionTextStyle(font, size, color, bold, italic, alignment, spacing), anduseSelectionAnnotationProps(border color and width). Widget settings such as multiline and format read from the widgets inuseSelectedBlocksand change withsetWidgetProps(ids, props)fromuseSetWidgetProps.useSelectionreplaces listening toannotationSelection.change: it returns the selected block ids, or the paragraph in text editing. The markup and comment actions of the inline text selection toolbar (highlight, underline, strikeout, squiggle, comment) have no equivalent. - Keyboard shortcuts are your code. Nutrient ships its own shortcuts; here you wire undo, redo, delete, and arrow keys with
useHotkeys.
Content editing
Before: Nutrient Web SDK
Editing the text of a page needs the Content Editor component. The UI session starts with InteractionMode.CONTENT_EDITOR and ends with Save & Close or Cancel; the programmatic session stages changes to text blocks and commits them:
// The UI session: the content editor mode.
instance.setViewState((viewState) =>
viewState.set('interactionMode', NutrientViewer.InteractionMode.CONTENT_EDITOR)
)
// Later, from your own Done button: saves the changes and exits content editing mode.
await instance.saveContentEditingSession()
// Or, without the UI. Throws while another session is in progress.
const session = await instance.beginContentEditingSession()
const [firstBlock] = await session.getTextBlocks(0)
await session.updateTextBlocks([
{ id: firstBlock.id, text: 'Revised wording', maxWidth: 300 },
])
// Applies the staged changes and closes the session; discard() drops them.
await session.commit()After: @sdk-pdf/react
Under PDF_EDITOR, text is editable in Viewport with nothing to start or save: users click into a paragraph and type. From code, useProgrammaticEditor reads paragraphs and edits them, as Programmatic editing shows:
import { useProgrammaticEditor } from '@sdk-pdf/react'
function useReplaceFirstParagraph() {
const { getParagraphsByPage, getParagraphEditor } = useProgrammaticEditor()
return async (pageId: string, text: string) => {
// Like session.getTextBlocks(pageIndex): the page's paragraphs in content order.
const [firstParagraph] = await getParagraphsByPage(pageId)
// The paragraph may be gone: deleted by an undo since it was read.
const editor = firstParagraph && getParagraphEditor(firstParagraph.id)
if (!editor) return false
// No staging and no commit: the edit applies at once.
editor.selectAll()
editor.deleteAll()
editor.insertText(text)
return true
}
}What to know
- No session. There is no
beginContentEditingSession(),saveContentEditingSession(),discardContentEditingSession(), orhasUnsavedContentEditingChanges(). Text edits are ordinary document changes:useUndoRedoreverts them,useDocumentRevisiontracks them, andprepareDocument()saves them. To drop every edit of a session, load the original bytes again. - Blocks become paragraphs. A text block's
textbecomes a paragraph'scontent, itsanchorbecomesxandy(the start of the first baseline in user space), itsmaxWidthbecomeswidth(the wrap width, or'auto'), and itsboundingBoxbecomesbounds, the box the paragraph covers in user space; check positions on a test document, since the origins differ.useSetParagraphPropssets the position, the wrap width, and the layout (textAlign,lineHeight,textIndent), and the height follows the text. - Styling. The content editor's font, size, and color controls become a toolbar on top of
useSelectionTextStyle;useFocusSelectionreturns the focus to the paragraph after a toolbar click. - New and deleted text. The
addTexttool anduseParagraphCreatecreate paragraphs. Paragraph ids are block ids, souseBlockDeletedeletes them by id, anduseDeleteSelectiondeletes the selection, where Nutrient integrations callcontentEditor.deleteBlock(). - Events.
contentEditor.stateChangebecomes state:useSelectionreturns{ type: 'text', blockId }while a paragraph is in text editing. FromuseViewportEvent,textEditorInputreports typing, andaddToolAppliedreports a paragraph created with theaddTexttool. Paragraphs your code creates or deletes emit no events: track them where you call the hooks.
Annotations
Before: Nutrient Web SDK
Annotations are immutable records created, updated, and deleted through the instance, with events for every change. Integrations often store them apart from the PDF, as Instant JSON or XFDF, and apply them again on the next load:
const annotation = new NutrientViewer.Annotations.RectangleAnnotation({
pageIndex: 0,
boundingBox: new NutrientViewer.Geometry.Rect({
left: 200,
top: 150,
width: 250,
height: 75,
}),
})
const [created] = await instance.create(annotation)
await instance.update(created.set('noPrint', true))
await instance.delete(created.id)
instance.addEventListener('annotations.change', () => refreshAnnotationsPanel())
// A diff on top of the original PDF.
const instantJSON = await instance.exportInstantJSON()
NutrientViewer.load({ ...configuration, instantJSON })After: @sdk-pdf/react
The SDK has no general annotation object API. Existing annotations in the PDF display in Viewport and in PageCanvas, and the selection hooks restyle them: useSelectionAnnotationProps changes the border color and width of selected shapes, lines, ink, and widgets:
import { useSelectionAnnotationProps } from '@sdk-pdf/react'
function AnnotationStyleBar() {
const { isApplicable, borderColor, borderWidth } = useSelectionAnnotationProps()
// Nothing with a border is selected.
if (!isApplicable) return null
return (
<div role="toolbar">
{/* An empty value means the selected annotations disagree. */}
<input
type="color"
value={borderColor.value ?? '#000000'}
onChange={(event) => borderColor.set(event.currentTarget.value)}
/>
<input
type="number"
min={0}
value={borderWidth.value ?? ''}
onChange={(event) => borderWidth.set(Number(event.currentTarget.value))}
/>
</div>
)
}What to know
- Creation. Two types can be created. Image stamps come from the
addImagetool or, from code,createStamp({ pageId, image, x, y, width?, height? })fromuseStampAnnotationCreate. Ink comes fromuseInkAnnotationCreatewith strokes your code collects; there is no drawing tool. Creating highlights, underlines, shapes, notes, free text, callouts, links, and measurements has no equivalent. - No diff formats.
exportInstantJSON(),exportXFDF(),NutrientViewer.Annotations.toSerializableObject(), and theinstantJSONandXFDFload options have no equivalent. Every change lives in the document, andprepareDocument()produces the PDF that carries it: store that file instead of a diff. - Events.
annotations.create,annotations.update, andannotations.deletehave no counterpart.useDocumentRevisionchanges on any edit, without the changed objects, andblocksDragEndandblockResizeDragEndfromuseViewportEventreport the user moving and resizing blocks. - Comments and authors. Comments, replies, mentions, and
setAnnotationCreatorName()have no equivalent. For programmatic edits,setEditorAwarenessPropertiesfromuseProgrammaticEditorgives the editor a name and a color for real-time collaboration, which is coming soon. - Hiding annotations.
PageCanvastakeshideAnnotationsfor thumbnails and previews.
Undo, redo, and change tracking
Before: Nutrient Web SDK
The History API undoes and redoes annotation changes. It's off until enableHistory is set, and the toolbar's undo and redo items are hidden until you add them:
NutrientViewer.load({
// ...
enableHistory: true,
toolbarItems: [
...NutrientViewer.defaultToolbarItems,
{ type: 'undo' },
{ type: 'redo' },
],
})
undoButton.onclick = () => {
if (instance.history.canUndo()) instance.history.undo()
}
redoButton.onclick = () => {
if (instance.history.canRedo()) instance.history.redo()
}
instance.addEventListener('history.undo', ({ before, after }) => trackUndo(before, after))After: @sdk-pdf/react
useUndoRedo returns the commands and their availability, which re-renders the component as the history changes:
import { useMemo } from 'react'
import { useHotkeys, useUndoRedo } from '@sdk-pdf/react'
function HistoryButtons() {
const { undo: undoChange, redo: redoChange, canUndo, canRedo } = useUndoRedo()
// Like history.undo and history.redo listeners: undo and redo emit no events, so track
// them where you call them.
const { undo, redo } = useMemo(
() => ({
undo: () => {
undoChange()
trackHistory('undo')
},
redo: () => {
redoChange()
trackHistory('redo')
},
}),
[undoChange, redoChange]
)
// Nutrient handles Ctrl/Cmd+Z itself; here you map the keys. The mapping must be
// memoized.
useHotkeys(useMemo(() => ({ 'Mod-z': undo, 'Shift-Mod-z': redo }), [undo, redo]))
return (
<div role="toolbar">
<button disabled={!canUndo} onClick={undo}>
Undo
</button>
<button disabled={!canRedo} onClick={redo}>
Redo
</button>
</div>
)
}hasUnsavedChanges() and document.saveStateChange move to document revisions: useDocumentRevision re-renders with a new revision on every edit, and undo and redo bring back the revision of that point in the history. Keep the revision you last saved and compare: a different revision means unsaved changes. A save records the revision it saved, so the indicator lives with the save button in Saving and downloading.
What to know
- One history for the document. Nutrient's History API covers creating, updating, and deleting annotations.
useUndoRedocovers the document: deleting, moving, or rotating pages are undo steps, widget property changes are undo steps unless excluded, and form values follow undo and redo. - Nothing to enable. There is no
enableHistory,history.enable(), orhistory.disable():useUndoRedoworks whenever a document is loaded. - Local and merged. As with Nutrient, undo reverts the local user's changes. Edits within 500 ms merge into one step, even across hook calls; to keep an operation a separate step, call
markUndoBoundary()fromuseUndoBoundarybefore it starts and after its edits finish. - Opting out.
history.willChangewithpreventDefault()keeps an annotation change out of the history; here, only widget property changes made with{ excludeFromHistory: true }fromuseSetWidgetPropsstay out of it. - No history reset.
history.clear()has no equivalent; recording a new saved revision resets only the unsaved-changes baseline.
Saving and downloading
Before: Nutrient Web SDK
Nutrient tells saved changes from unsaved ones. With AutoSaveMode.DISABLED, nothing is saved until instance.save(), hasUnsavedChanges() reports the difference, and exportPDF() leaves out unsaved annotations, so a save comes first:
NutrientViewer.load({
...configuration,
autoSaveMode: NutrientViewer.AutoSaveMode.DISABLED,
})
instance.addEventListener('document.saveStateChange', ({ hasUnsavedChanges }) => {
unsavedBadge.hidden = !hasUnsavedChanges
})
async function upload() {
await instance.save()
const arrayBuffer = await instance.exportPDF()
const body = new FormData()
body.append('file', new Blob([arrayBuffer], { type: 'application/pdf' }))
await fetch('/upload', { method: 'POST', body })
}After: @sdk-pdf/react
useDocumentSaving applies the recorded changes to the original document; every change is included, with no save step before the export. Unsaved changes are relative to the useDocumentRevision value that you record after each upload:
import { useEffect, useRef, useState } from 'react'
import {
useDocumentRevision,
useDocumentRevisionGetter,
useDocumentSaving,
} from '@sdk-pdf/react'
function SaveControls() {
const revision = useDocumentRevision()
const getRevision = useDocumentRevisionGetter()
// The revision on the server: the loaded document's revision until the first save.
const [savedRevision, setSavedRevision] = useState(revision)
// One save at a time, so an overlapping save can't mark a failed upload as saved.
const isSavingRef = useRef(false)
const { prepareDocument, downloadDocument } = useDocumentSaving()
// Replaces document.saveStateChange; undoing back to the saved state clears it too.
const hasUnsavedChanges = revision !== savedRevision
// Warn before the tab closes with changes that aren't uploaded.
useEffect(() => {
if (!hasUnsavedChanges) return
const warn = (event: BeforeUnloadEvent) => event.preventDefault()
window.addEventListener('beforeunload', warn)
return () => window.removeEventListener('beforeunload', warn)
}, [hasUnsavedChanges])
async function upload() {
if (isSavingRef.current) return
isSavingRef.current = true
try {
// Edits made after the save starts may be missing from the saved bytes, so only
// this revision becomes saved.
const startRevision = getRevision()
// The bytes go to fetch, so don't hand out the loaded document's own buffer.
const bytes = await prepareDocument({ returnOriginalIfUnchanged: false })
const body = new FormData()
body.append('file', new Blob([bytes as BlobPart], { type: 'application/pdf' }))
const response = await fetch('/upload', { method: 'POST', body })
if (!response.ok) throw new Error(`Upload failed: ${response.status}`)
// Like a completed save(): later checks report changes relative to this revision.
setSavedRevision(startRevision)
} finally {
isSavingRef.current = false
}
}
return (
<div role="toolbar">
<button onClick={() => upload().catch(showError)}>Save</button>
{/* Replaces the export-pdf toolbar item. */}
<button onClick={() => void downloadDocument()}>Download</button>
{hasUnsavedChanges && <span>Unsaved changes</span>}
</div>
)
}One component owns the saved revision, so the save and the indicator compare against the same value. Render it only once the document is ready, so its first revision is the loaded document's.
What to know
- No save step.
autoSaveMode,instance.save(),ensureChangesSaved(), andSaveErrorhave no counterpart: there is no pending state between an edit and the export. - One artifact.
prepareDocument()resolves with the complete PDF as aUint8Array, whereexportPDF()resolves with anArrayBuffer, anddownloadDocument()saves the same bytes on the user's device. Storing Instant JSON or XFDF next to the original file becomes storing the PDF. - returnOriginalIfUnchanged defaults to
true, returning the buffer passed toloadPdfwhen nothing changed; set it tofalsewhen the bytes will be transferred or mutated. - Save options. Besides
returnOriginalIfUnchanged,pageIdssaves only the listed pages, in the listed order.exportPDF()flags such asflatten,incremental, andpermissions(passwords and document permissions),exportOffice(), and a download file name have no equivalent.
Collaboration
Nutrient Web SDK syncs changes between users through Instant, which needs a server: Document Engine, or DWS Viewer API for the documents it manages. With Document Engine, the viewer loads with documentId, authPayload: { jwt }, and instant: true, instance.connectedClients lists the connected clients, and Collaboration Permissions come from the JWT. In standalone mode, changes stay in the browser until you export them.
Real-time collaboration in @sdk-pdf/react is coming soon. Your Document Engine client settings and Instant JSON sync code don't carry over: edits are CRDT operations, not annotation records.
Coordinates
| Topic | Nutrient Web SDK | @sdk-pdf/react |
|---|---|---|
| Page reference | Zero-based page indexes | Session-scoped page ids; the position is the index in usePages() |
| Origin | Page coordinates: the top-left corner of the visible page area, y pointing down | User space with the origin at the bottom-left corner of the page |
| Units | PDF points | User-space units, one unit is 1/72 of an inch |
| Page size | pageInfoForIndex(pageIndex).width and height | width and height of the usePages() entries, with rotation applied |
| Conversion | transformClientToPageSpace(), transformPageToClientSpace(), transformRawToPageSpace() | No conversion helpers |
Errors and logging
Before: Nutrient Web SDK
A failed load rejects the load() promise, and integrations tell the causes apart by the error message. instance.save() rejects with a SaveError, and logLevel turns on initialization logs in the console:
try {
instance = await NutrientViewer.load({
...configuration,
// Tagged console logs for each initialization stage.
logLevel: NutrientViewer.LogLevel.DEBUG,
})
} catch (error) {
if (error.message.includes('password')) askForPassword()
else showError(error.message)
}
try {
await instance.save()
} catch (error) {
if (error instanceof NutrientViewer.SaveError) reportFailedChanges(error.reason)
}After: @sdk-pdf/react
Failures are typed errors from the call that failed, and problems the SDK works around are log messages you subscribe to with logMessages. Error handling and logging has the full rules:
import { useEffect } from 'react'
import { InvalidPasswordError, logMessages, LogLevel, useLoadPdf } from '@sdk-pdf/react'
function useSdkLogging() {
useEffect(() => {
// The SDK never prints to the console: subscribe, and unsubscribe on unmount.
const unsubscribe = logMessages.on('message', (logMessage) => {
if (logMessage.level >= LogLevel.Warning) {
reportToMonitoring(logMessage.reason, logMessage.message, logMessage.data)
}
})
return () => {
unsubscribe()
}
}, [])
}
function useOpenBytes() {
const [, loadPdf] = useLoadPdf()
return async (data: Uint8Array, password?: string) => {
try {
// Pass a copy and keep `data` for a password retry.
await loadPdf(data.slice(), { password })
} catch (error) {
// instanceof replaces matching on error.message.
if (error instanceof InvalidPasswordError) askForPassword()
else showError(error)
}
}
}What to know
| Situation | Nutrient Web SDK | @sdk-pdf/react |
|---|---|---|
| Missing or wrong password | onPasswordRequired, then the built-in prompt | InvalidPasswordError from loadPdf |
| Malformed file | The load() promise rejects | DocumentFormatError, when the SDK can't work around it |
| Unsupported PDF feature | No equivalent | NotSupportedError |
| Invalid input, such as a bad page | NutrientViewer.Error, such as from setViewState() | InvalidInputError |
| No document | No equivalent | DocumentNotLoadedError |
| Network or HTTP failure | The load() promise rejects | Your own fetch fails before loadPdf runs |
- Error classes. Every SDK error extends
GenericErrorwith a staticmessageand an optionaldataobject; catch and narrow them withinstanceof. - Nothing half-applied. Commands validate first and throw before touching anything; readers return empty values instead of throwing; hooks never throw during render.
- Logs replace console output. Where
logLevelprints initialization stages to the console,@sdk-pdf/reactdeliverslevel,reason, a staticmessage,data, andstackto your subscriber; filter bylevelinstead of setting a verbosity.page.renderErrorhas no counterpart.
Cleanup and lifecycle
Before: Nutrient Web SDK
NutrientViewer.unload() takes the container, a selector, or the instance. Listeners come off with removeEventListener and the same function reference, and the signal option cancels a load that is still in progress:
useEffect(() => {
const container = containerRef.current
const controller = new AbortController()
let instance
const handleZoom = () => persistZoom(instance.currentZoomLevel)
NutrientViewer.load({ ...configuration, container, signal: controller.signal })
.then((loaded) => {
instance = loaded
instance.addEventListener('viewState.zoom.change', handleZoom)
})
.catch((error) => {
// An aborted load rejects with an AbortError.
if (error.name !== 'AbortError') showError(error)
})
return () => {
controller.abort()
instance?.removeEventListener('viewState.zoom.change', handleZoom)
NutrientViewer.unload(container)
}
}, [])After: @sdk-pdf/react
- Subscriptions follow components. State hooks and
useViewportEventsubscribe for the component's lifetime, with callbacks that don't need memoizing or a stable reference.useHotkeysinstalls window-level shortcuts; memoize the mapping, or the listener re-installs on every render, and pass a falsy value instead of the mapping to disable them, such as while a modal is open. - Log subscriptions return an unsubscribe function; call it in the effect cleanup, as in Errors and logging.
- Closing a document is
resetPdf()fromuseResetPdf, which returns the load state to'initial'. There is nounload()for the SDK itself and no container to clear. - Cancelling a load.
loadPdftakes nosignal; cancel thefetchthat produces the bytes.
Features without an equivalent
Plan around these before you migrate:
| Nutrient Web SDK feature | In @sdk-pdf/react |
|---|---|
Built-in toolbar, sidebars, Document Editor UI, themes, and localization (toolbarItems, ui, locale) | The UI is yours |
| Office documents and images | PDF only |
| Creating highlight, shape, note, free text, link, and measurement annotations, and drawing ink | No equivalent; existing annotations display, selected shapes, lines, ink, and widgets take a new border color and width, and code can create ink and image stamps |
| Comments, replies, mentions, and annotation authors | No equivalent |
| Instant JSON and XFDF import and export | No equivalent; save the PDF |
Reading the selected text (getTextSelection) | No equivalent; getParagraphsByPage reads paragraph text, and useDocumentSearch replaces search |
Printing (print) | No print API; PageCanvas can render pages for your own print layout, as loaded from the file, without session edits |
| Redaction, electronic signatures, and digital signatures | No equivalent; the SDK never signs |
| Document comparison, OCR, Office export, and AI Assistant | No equivalent |
| PDF JavaScript (validation, formatting, calculations) | No equivalent; Acrobat date formats are recognized without running scripts |
| Layout and scroll modes, view-only rotation | One scrollable layout; rotation is a document edit |
| Page labels, outline, bookmarks, layers, and attachments | No equivalent |
ZoomMode.AUTO, zoom modes that stay applied on resize, marquee zoom | Fits are one-time calculations; see Zoom and fit modes |
Importing, cropping, and adding margins to pages; exportPDFWithOperations | No equivalent; prepareDocument({ pageIds }) saves a subset of the pages |
| Export options: flattening, incremental saving, passwords, and permissions | No equivalent |
Coordinate conversion (transformClientToPageSpace and the other transforms) | No equivalent |
Scrolling to a rectangle (jumpToRect, jumpAndZoomToRect) | No equivalent; scrollToBlock scrolls to a block |
Clearing the history (history.clear()) | No equivalent; recording a new saved revision resets only the unsaved-changes baseline |
httpHeaders, linearized loading, and signal on load | Use your own fetch; no linearized loading |
| Instant with Document Engine, connected clients, and Collaboration Permissions | Coming soon |