Migration

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/react is 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

Overview

How the two integrations differ

Nutrient Web SDK is a packaged application:

  • NutrientViewer.load(configuration) mounts the viewer into a container element, which needs an explicit width and height, and resolves with an Instance. NutrientViewer.unload(container) tears it down. In React, the pair lives in a useEffect.
  • 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 toolbarItems and setToolbarItems, the ui slot configuration (preset: 'minimal' hides all of it), theme, and styleSheets.
  • The UI state is an immutable ViewState (current page, zoom, interaction mode, sidebar, read-only mode): read it from instance.viewState, change it with instance.setViewState(), and observe it through events on the instance, such as viewState.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 a licenseKey, or a DWS Viewer API session such 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(), and delete(). Changes are saved as autoSaveMode sets, 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. Viewport is the main view and PageCanvas renders single pages; everything around them is your React code driven by hooks.
  • PdfProviders hosts the state and configuration inside your React tree. The SDK renders unstyled, and your design system supplies the look through the theme configuration.
  • Edits don't rewrite the PDF file. Each change is an operation in a CRDT, which powers the history; useDocumentSaving builds 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.
ConcernNutrient Web SDK@sdk-pdf/react
UIPrebuilt, shaped from outside (toolbarItems, ui slots, theme)Viewport and PageCanvas only; you build toolbars, sidebars, and dialogs
Mountingload() into a sized container in a useEffect, unload() in cleanupPdfProviders and components in your React tree
DeploymentRuntime assets from the CDN or baseUrl, licenseKeyThe package from a private registry
State accessViewState and setViewState(), events on the instanceState hooks that re-render, command hooks, and event hooks
PagesZero-based indexesSession-scoped page ids
FormsForm fields by fully qualified name, widgets as annotationsWidgets with a fieldName, values bound with useWidgetValue
Editing textContent Editor component, in a session you save or discardBuilt in under the PDF_EDITOR content policy, with no session to save
AnnotationsTools and APIs for every common typeExisting annotations display; borders restyle; image stamps and ink from code
Persistencesave() per autoSaveMode, then exportPDF(), Instant JSON, or XFDFprepareDocument() for the PDF
CollaborationInstant, with Document EngineComing soon
File typesPDF, Office documents, and imagesPDF

What this means for your code:

  1. 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.
  2. Drop the mount effect. The load() and unload() effect, the container sizing, the instance reference, and any context that shares it are replaced by PdfProviders and hooks.
  3. Replace the view state. instance.viewState reads become state hooks, and setViewState() calls become command hooks: useScrollToPage, useViewportScale, useSetInteractionMode.
  4. 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. useViewportEvent reports only user interactions in the viewport, such as typing, drags, and form input, and its subscriptions follow the component lifecycle.
  5. Replace diff persistence. Storing Instant JSON or XFDF next to the original file becomes storing the PDF produced by prepareDocument().
  6. Replace indexes and page coordinates with page ids and user-space coordinates; check stored positions after converting them.
  7. Remove deployment pieces: the assets setup (useCDN or baseUrl), the license key, and NutrientViewer.preloadWorker().
API map

Rows marked No equivalent are covered in Features without an equivalent.

Nutrient Web SDK@sdk-pdf/reactNotes
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 ArrayBufferloadPdf(Uint8Array) from useLoadPdffetch the URL into bytes first.
password, onPasswordRequired, maxPasswordRetriesloadPdf(data, { password }), catch InvalidPasswordErrorNo built-in password prompt.
httpHeaders, signalNo equivalentHeaders and cancellation go on your own fetch.
The resolved load() promiseloadState.status === 'ready'
NutrientViewer.unload(container), then load() with another documentresetPdf() from useResetPdf, then loadPdf()The rest of your UI stays mounted.
instance.totalPageCountusePageCount
viewState.currentPageIndex, viewState.currentPageIndex.changeuseCurrentPageIdRe-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 usePagesid, width, height, rotation.
jumpToRect(), jumpAndZoomToRect()No equivalentscrollToBlock scrolls to a block, not to a rectangle.
applyOperations() with addPagecreatePage(width, height, insertAfterPageId?) from usePageCreateInserts after a page, or appends.
removePages, keepPagesdeletePages(pageIdOrIds) from usePageDeleteFor keepPages, delete the other ids.
movePagesmoveBefore or moveAfter from usePageMove
rotatePagessetPageRotation(pageIdOrIds, rotation) from useSetPageRotationAbsolute degrees, not an added rotation.
duplicatePagesduplicatePage(sourcePageId, insertAfterPageId?) from usePageDuplicateOne page per call.
document.change eventTrack the page commands where you call them; usePages re-renders on page changesPage operations emit no events.
Extracting pages to a new fileprepareDocument({ pageIds })Saves only the listed pages, in the listed order.
importDocument, cropPages, addPageMargins, setPageLabelNo equivalent
viewState.zoom as a number, zoomIn(), zoomOut(), instance.currentZoomLeveluseViewportScale
ZoomMode.FIT_TO_WIDTHuseViewportAutoFitScale, useViewportFitScaleA one-time fit, not a mode that stays applied.
ZoomMode.FIT_TO_VIEWPORTMath.min(fitWidthScale, fitHeightScale)See Zoom and fit modes.
ZoomMode.AUTONo equivalent
viewState.zoom.change eventscale from useViewportScale in a useEffectzoomGestureEnd from useViewportEvent reports pinch and wheel zoom.
layoutMode, scrollMode, pagesRotationNo equivalentViewport has one scrollable layout.
SidebarMode.THUMBNAILS, renderPageAsImageURL()PageCanvas
instance.getFormFields()useWidgets, useWidgetsGetterWidgets, not fields.
getFormFieldValues(), setFormFieldValues()useWidgetValueOne widget per hook call.
formFieldValues.update eventwidgetInput from useViewportEvent; useWidgetValue re-renderswidgetInput 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_CREATORThe { type: 'addWidget', widgetProps } tool
setViewState() with interactionModeuseSetInteractionModeSee Editing and interaction tools.
ViewState#readOnlyPDF_PRINTER from CONTENT_POLICY_PRESETPDF_VIEWER keeps widgets fillable.
InteractionMode.CONTENT_EDITOR, beginContentEditingSession()The PDF_EDITOR content policy, useProgrammaticEditorSee Content editing.
instance.create() for ink and image annotationsuseInkAnnotationCreate, useStampAnnotationCreateSee Annotations.
instance.create() for other annotation typesNo equivalentSee Annotations.
instance.update() for annotationsuseSelectionAnnotationPropsBorder color and width of selected annotations only.
annotations.change and the other annotation eventsuseDocumentRevision in a useEffectNo annotation-specific events.
exportInstantJSON(), exportXFDF(), the instantJSON and XFDF optionsNo equivalentSave the PDF instead.
enableHistory, instance.history.undo(), redo(), canUndo(), canRedo()useUndoRedoNo switch to turn it on.
instance.history.clear()No equivalent
hasUnsavedChanges(), document.saveStateChange eventuseDocumentRevision, useDocumentRevisionGetterCompare with the revision you last saved.
autoSaveMode, instance.save()Nothing to callprepareDocument() includes every change.
instance.exportPDF()prepareDocument() from useDocumentSavingResolves with a Uint8Array, not an ArrayBuffer.
The export-pdf toolbar itemdownloadDocument()No file name option.
exportPDF({ flatten }), exportPDFWithOperations()No equivalent
NutrientViewer.Error and the rejected load() promiseTyped error classes from the call that failedSee Errors and logging.
logLevel: NutrientViewer.LogLevel.DEBUGlogMessagesThe SDK never prints to the console.
addEventListener(), removeEventListener()State hooks, and useViewportEvent for user interactionsSubscriptions follow the component lifecycle.
toolbarItems, setToolbarItems(), ui slots, theme, styleSheetsYour own components and the theme configuration, see Theming and customization
instance.search(), startUISearch()useDocumentSearchHighlights the matches and scrolls to the active one; the search UI is yours.
instance.print()No equivalentPageCanvas can render pages for your own print layout, as loaded from the file, without session edits.
transformClientToPageSpace(), transformPageToClientSpace()No equivalentSee Coordinates.
instant: true with Document EngineComing soonSee 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/viewer
import { 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/react

Set 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 baseUrl or licenseKey to pass, and no preloadWorker() call to warm up.
  • No mount effect. The SDK renders as components, so the useRef and useEffect mount, the unload cleanup, and sharing the instance through React context go away: any component under PdfProviders reaches the document through hooks. Viewport still fills its parent, so the parent needs an explicit size, as the Nutrient container did.
  • Pick the content policy deliberately. PdfProviders defaults to the select tool and PDF_EDITOR, where text, annotations, links, and widgets are all editable. A Nutrient viewer opens with no interaction mode (text selection and scrolling) and, unless readOnly is set, with its annotation tools in the toolbar. Start a reader with textSelect and PDF_VIEWER, as above, and switch to PDF_EDITOR when 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_VIEWER keeps 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. loadPdf takes a Uint8Array. The network options of load() (httpHeaders, linearized loading, tempStorage) move to your own fetch or have no equivalent, and loadPdf takes no signal: abort the fetch instead.
  • The same buffer habit. Nutrient Web SDK takes ownership of the ArrayBuffer you pass and detaches it. loadPdf reads 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 no maxPasswordRetries: render your own prompt and count attempts yourself.
  • Switching documents keeps the UI. Instead of unloading and mounting a new viewer, call resetPdf() and then loadPdf(); PdfProviders and your components stay mounted. Restoring the page or zoom after a switch is still yours: call scrollToPage and setScale once the new document is ready.
  • PDF only. useLoadPdf loads PDF documents; Office documents and images have no equivalent, and there is no instantJSON or XFDF to 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 the ui slots have no counterpart. A custom toolbar item becomes a plain button, and a sidebar mode becomes a component you render or don't. Selection, text editing, form filling, links, and zoom gestures work inside Viewport and are limited by the content policy; every control around it exists only if you build it.
  • Theming is CSS you own. theme.classNames and theme.styles target the SDK's elements (scrollViewport, page, pageCanvas, resizable, widgetOverlay, and more), and CSS variables such as --selection-color and --widget-overlay-background tune the selection and widget visuals; Theming and customization lists them. There are no built-in light, dark, or high-contrast themes, and nothing like styleSheets to inject.
  • Render only when ready. Viewport throws DocumentNotLoadedError when no document is loaded.
  • One layout. layoutMode (SINGLE, DOUBLE, AUTO), scrollMode (CONTINUOUS, PER_SPREAD, DISABLED), keepFirstSpreadAsSinglePage, and spreadSpacing have no equivalent: Viewport shows the pages in one scrollable area. theme.viewportPadding replaces ViewState#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. scrollToPage throws InvalidInputError for an unknown or deleted id, as setViewState() throws for an index out of range; usePageIndexGetter returns undefined instead.
  • Page sizes. pageInfoForIndex(pageIndex).width and height become the width and height of the usePages() entries, in user-space units with the rotation applied, next to the page's rotation.
  • Rotation edits the document. pagesRotation, rotateLeft(), and rotateRight() turn the view only and aren't saved. setPageRotation from useSetPageRotation rotates pages in the document as an undoable edit; there is no view-only rotation.
  • Scrolling to a spot. jumpToRect() and jumpAndZoomToRect() have no equivalent. scrollToBlock(blockId, options?) from useScrollToBlock scrolls 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

OperationNutrient Web SDK@sdk-pdf/react
InsertaddPage before or after an index, with size, color, and rotationAfter insertAfterPageId, or appended; width and height in user-space units (1/72 of an inch), no defaults
DuplicateduplicatePages, each copy after its originalduplicatePage is asynchronous, one page per call, after the source by default
MovemovePages before or after an indexmoveBefore(ids, beforePageId?) (start when omitted) or moveAfter(ids, afterPageId?) (end when omitted)
RotaterotatePages adds rotateBy to the current rotationSets an absolute angle; -90 and 450 are normalized; angles off a quarter turn throw InvalidInputError
RemoveremovePages by index, or keepPages for the complementdeletePages 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.change delivers the operations array; here, page commands emit no events. Track them where you call them, and read the current pages from usePages, which re-renders on every page change, undo and redo included.
  • Extracting pages. prepareDocument({ pageIds }) from useDocumentSaving saves only the listed pages, in the listed order, without changing the loaded document.
  • No import, cropping, or margins. importDocument, cropPages, addPageMargins, setPageLabel, flattenAnnotations, and exportPDFWithOperations() 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_WIDTHuseViewportAutoFitScale('width') on load, or setScale(getFitScale().fitWidthScale); both fit the pages into the viewport width.
ZoomMode.FIT_TO_VIEWPORTsetScale(Math.min(fitWidthScale, fitHeightScale)). The fit scales measure the widest and tallest pages, not the current page.
ZoomMode.AUTONo 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. useViewportAutoFitScale applies when a document loads and doesn't re-apply on viewport resizes or manual zooming; useViewportFitScale reflects the viewport size at the moment of the call.
  • What the fits account for. Both fit scales include viewportPadding. Scale 1 is 100%: one user-space unit per CSS pixel. setScale(scale, pivot?) takes a positive finite number and keeps the document still at pivot, a point { x, y } given as fractions of the viewport area (0 the top or left edge, 1 the bottom or right edge), the center by default. The third item of useViewportScale(), animateScale(scale, pivot?), animates the change and resolves when it ends.
  • The scale updates after a gesture. During a pinch or wheel zoom, scale keeps its value until the gesture ends; zoomGestureStart and zoomGestureEnd from useViewportEvent mark the gesture.
  • Gestures. Pinch and wheel zoom are built in and tuned with zoomGestures on PdfProviders (minScale 0.5 and maxScale 4 by 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 devicePixelRatio and no Blob URL to revoke: the render resolution follows the canvas's displayed CSS size, or an explicit scale prop.
  • Lazy by default. An off-screen canvas does no work until it becomes visible. In a virtualized list, pass isVisible.
  • Annotations included. PageCanvas draws annotations, form widgets included, unless hideAnnotations is set.
  • Thumbnails that show session edits are coming soon. PageCanvas draws the page as loaded from the file; the main view shows the edits. Rotation is followed live.
  • Render only when ready. PageCanvas throws DocumentNotLoadedError when 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

ConceptNutrient Web SDK@sdk-pdf/react
LookupgetFormFields(), a field's annotationIdsuseWidgets() (re-renders, isLoaded tells when the list is complete), or await getWidgets() from useWidgetsGetter (loads every page with widgets first)
ValuesA record by field name: string, string[], or nulluseWidgetValue(widget): string for text and choice, boolean for checkbox and radio, option indices for multiChoice
WritingsetFormFieldValues({ name: value }), several fields at onceThe setter from useWidgetValue, one widget at a time; there is no setter by field name
Radio groupsOne value for the group: the value of the selected optionOne radio widget per option with the shared field name, each with its own checked state
Change eventsformFieldValues.updatewidgetInput from useViewportEvent for values the user changes in the viewport; useWidgetValue re-renders on any change, undo and redo included
CreationWidgetAnnotation and FormField with generateInstantId(), create()createWidgets(props) from useWidgetsCreate; required: pageId, widgetType, x, y, width, height; a unique fieldName is generated when omitted
Interactive creationTEXT_WIDGET, CHECKBOX_WIDGET, and the other widget modes, formDesignModeThe { type: 'addWidget', widgetProps } interaction tool, used with PDF_EDITOR; the select tool moves and resizes widgets
Deletioninstance.delete(formField) also removes its widgetsuseBlockDelete with widget ids; same-named widgets act as one field, so delete them together
Propertiesinstance.update() on the widget or the fieldsetWidgetProps(ids, props, options?) from useSetWidgetProps: isReadOnly, borderColor, fillColor, fontSize, isMultiline, options, and more
Form dataInstant JSON formFieldValues, XFDFNo equivalent

What to know

  • Coordinates differ. A Nutrient boundingBox is 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, y is the page height minus top minus height. Check placement on a test document before converting stored positions, especially for rotated or cropped pages.
  • Values are read and written per widget. useWidgetValue binds one widget per hook call. To prefill a form from a stored record, render a bound component per field, like FieldByName above; 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); see FormatType. Fields driven by hand-written scripts read as plain text fields.
  • Read-only fields. isReadOnly restricts filling the field, not authoring the widget; with { excludeFromHistory: true }, an undo can't unlock a submitted form.
  • Signatures. signature widgets carry a read-only isCryptoSigned; the SDK never signs.
  • The field being filled. useFocusedWidgetId returns 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.
PANNo tool: Viewport scrolls and zooms on its own.
MULTI_ANNOTATIONS_SELECTION{ type: 'select' }: select, move, and resize blocks, with drag-selection of several.
TEXT, CALLOUTThe 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_WIDGETA text widget with a FormatType.DateTime format.
CONTENT_EDITORText is editable in place when the policy's text level is 'editable' (PDF_EDITOR). See Content editing.
DOCUMENT_EDITORThe page hooks. See Page manipulation.
INKNo drawing tool. useInkAnnotationCreate creates an ink annotation from strokes your code collects.
TEXT_HIGHLIGHTER, SHAPE_*, NOTE, COMMENT_MARKER, SIGNATURE, LINK, REDACT_*, measurement modesNo equivalent.
ViewState#readOnlyCONTENT_POLICY_PRESET.PDF_PRINTER, or PDF_VIEWER to keep widgets fillable.
isEditableAnnotation, editableAnnotationTypesA 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_EDITOR with the editing tools, as above. textSelect needs read-only text: with editable text, the combination is unsupported and reports a message through logMessages.
  • Per-kind and per-block permissions. A custom content policy is a plain object with text, annotations, links, and widgets levels, finer than one read-only switch. A level can be a function of the block id, which replaces isEditableAnnotation, 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 read viewState.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), and useSelectionAnnotationProps (border color and width). Widget settings such as multiline and format read from the widgets in useSelectedBlocks and change with setWidgetProps(ids, props) from useSetWidgetProps. useSelection replaces listening to annotationSelection.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(), or hasUnsavedContentEditingChanges(). Text edits are ordinary document changes: useUndoRedo reverts them, useDocumentRevision tracks them, and prepareDocument() saves them. To drop every edit of a session, load the original bytes again.
  • Blocks become paragraphs. A text block's text becomes a paragraph's content, its anchor becomes x and y (the start of the first baseline in user space), its maxWidth becomes width (the wrap width, or 'auto'), and its boundingBox becomes bounds, the box the paragraph covers in user space; check positions on a test document, since the origins differ. useSetParagraphProps sets 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; useFocusSelection returns the focus to the paragraph after a toolbar click.
  • New and deleted text. The addText tool and useParagraphCreate create paragraphs. Paragraph ids are block ids, so useBlockDelete deletes them by id, and useDeleteSelection deletes the selection, where Nutrient integrations call contentEditor.deleteBlock().
  • Events. contentEditor.stateChange becomes state: useSelection returns { type: 'text', blockId } while a paragraph is in text editing. From useViewportEvent, textEditorInput reports typing, and addToolApplied reports a paragraph created with the addText tool. 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 addImage tool or, from code, createStamp({ pageId, image, x, y, width?, height? }) from useStampAnnotationCreate. Ink comes from useInkAnnotationCreate with 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 the instantJSON and XFDF load options have no equivalent. Every change lives in the document, and prepareDocument() produces the PDF that carries it: store that file instead of a diff.
  • Events. annotations.create, annotations.update, and annotations.delete have no counterpart. useDocumentRevision changes on any edit, without the changed objects, and blocksDragEnd and blockResizeDragEnd from useViewportEvent report the user moving and resizing blocks.
  • Comments and authors. Comments, replies, mentions, and setAnnotationCreatorName() have no equivalent. For programmatic edits, setEditorAwarenessProperties from useProgrammaticEditor gives the editor a name and a color for real-time collaboration, which is coming soon.
  • Hiding annotations. PageCanvas takes hideAnnotations for 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. useUndoRedo covers 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(), or history.disable(): useUndoRedo works 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() from useUndoBoundary before it starts and after its edits finish.
  • Opting out. history.willChange with preventDefault() keeps an annotation change out of the history; here, only widget property changes made with { excludeFromHistory: true } from useSetWidgetProps stay 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(), and SaveError have no counterpart: there is no pending state between an edit and the export.
  • One artifact. prepareDocument() resolves with the complete PDF as a Uint8Array, where exportPDF() resolves with an ArrayBuffer, and downloadDocument() 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 to loadPdf when nothing changed; set it to false when the bytes will be transferred or mutated.
  • Save options. Besides returnOriginalIfUnchanged, pageIds saves only the listed pages, in the listed order. exportPDF() flags such as flatten, incremental, and permissions (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

TopicNutrient Web SDK@sdk-pdf/react
Page referenceZero-based page indexesSession-scoped page ids; the position is the index in usePages()
OriginPage coordinates: the top-left corner of the visible page area, y pointing downUser space with the origin at the bottom-left corner of the page
UnitsPDF pointsUser-space units, one unit is 1/72 of an inch
Page sizepageInfoForIndex(pageIndex).width and heightwidth and height of the usePages() entries, with rotation applied
ConversiontransformClientToPageSpace(), 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

SituationNutrient Web SDK@sdk-pdf/react
Missing or wrong passwordonPasswordRequired, then the built-in promptInvalidPasswordError from loadPdf
Malformed fileThe load() promise rejectsDocumentFormatError, when the SDK can't work around it
Unsupported PDF featureNo equivalentNotSupportedError
Invalid input, such as a bad pageNutrientViewer.Error, such as from setViewState()InvalidInputError
No documentNo equivalentDocumentNotLoadedError
Network or HTTP failureThe load() promise rejectsYour own fetch fails before loadPdf runs
  • Error classes. Every SDK error extends GenericError with a static message and an optional data object; catch and narrow them with instanceof.
  • 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 logLevel prints initialization stages to the console, @sdk-pdf/react delivers level, reason, a static message, data, and stack to your subscriber; filter by level instead of setting a verbosity. page.renderError has 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 useViewportEvent subscribe for the component's lifetime, with callbacks that don't need memoizing or a stable reference. useHotkeys installs 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() from useResetPdf, which returns the load state to 'initial'. There is no unload() for the SDK itself and no container to clear.
  • Cancelling a load. loadPdf takes no signal; cancel the fetch that produces the bytes.

Features without an equivalent

Plan around these before you migrate:

Nutrient Web SDK featureIn @sdk-pdf/react
Built-in toolbar, sidebars, Document Editor UI, themes, and localization (toolbarItems, ui, locale)The UI is yours
Office documents and imagesPDF only
Creating highlight, shape, note, free text, link, and measurement annotations, and drawing inkNo 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 authorsNo equivalent
Instant JSON and XFDF import and exportNo 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 signaturesNo equivalent; the SDK never signs
Document comparison, OCR, Office export, and AI AssistantNo equivalent
PDF JavaScript (validation, formatting, calculations)No equivalent; Acrobat date formats are recognized without running scripts
Layout and scroll modes, view-only rotationOne scrollable layout; rotation is a document edit
Page labels, outline, bookmarks, layers, and attachmentsNo equivalent
ZoomMode.AUTO, zoom modes that stay applied on resize, marquee zoomFits are one-time calculations; see Zoom and fit modes
Importing, cropping, and adding margins to pages; exportPDFWithOperationsNo equivalent; prepareDocument({ pageIds }) saves a subset of the pages
Export options: flattening, incremental saving, passwords, and permissionsNo 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 loadUse your own fetch; no linearized loading
Instant with Document Engine, connected clients, and Collaboration PermissionsComing soon

On this page