Skip to content

Add Nutrient Web SDK demo: full host-app shell around the viewer#193

Open
MahmoudElsayad wants to merge 3 commits into
masterfrom
mahmoud/demo-example
Open

Add Nutrient Web SDK demo: full host-app shell around the viewer#193
MahmoudElsayad wants to merge 3 commits into
masterfrom
mahmoud/demo-example

Conversation

@MahmoudElsayad

@MahmoudElsayad MahmoudElsayad commented May 16, 2026

Copy link
Copy Markdown

What's special about this demo

Most existing Web SDK examples in this repo show one feature: a single custom button, a single custom tooltip, a single replacement slot. They live inside the SDK's chrome.

This demo does the opposite — it hides the SDK's chrome and rebuilds a complete document workspace around the viewer, in the style of a host application like Dropbox's PDF UI. The viewer is just a content surface; the toolbar, file explorer, signing flow, form-creator panel, and drawing/text tools are all host-app code talking to the SDK through its instance API.

That's the angle this example covers that no other example in the repo does: it stress-tests the app/viewer boundary end-to-end and shows that customer-owned UI around the SDK is a viable path, not just per-component customization.

The six integration patterns it demonstrates

  1. External floating ink toolbar. Activates `NutrientViewer.InteractionMode.INK`, sets app-owned annotation presets through `instance.setAnnotationPresets` / `instance.setCurrentAnnotationPreset`, and drives undo/redo through `instance.history.undo()`. The host owns the toolbar; the SDK just renders the strokes.
  2. Hide and replace the main toolbar. `ui.tools.main` is set to a render-nothing slot and the demo's own React toolbar takes over page navigation, rotate/add/delete/move, zoom, search, and download — all driven through `instance.setViewState` and `instance.applyOperations`.
  3. App-owned signature capture modal. Four-tab dialog (Draw / Type / Upload / Saved) running on a custom canvas. Output goes through `instance.createAttachment(blob)` → `ImageAnnotation({ isSignature: true, imageAttachmentId, contentType: 'image/png' })` → `instance.create()`. `signatures.create` and `signatures.list` slots are bridged so the SDK's own dialog never opens.
  4. Drag-and-drop from an external panel into the viewer. Drop listeners are attached to `instance.contentDocument` (the SDK runs in an iframe), then drop coordinates are converted with `instance.transformContentClientToPageSpace(new NutrientViewer.Geometry.Point(...), pageIndex)` and linked widget + form-field objects are created.
  5. Custom Form Creator property editor. `ui.formCreator.propertyEditor` is replaced with a host-rendered panel that walks the form-field → annotation pair to rename and delete fields through `instance.update` / `instance.delete`.
  6. Custom annotation tooltip actions. `annotationTooltipCallback` injects Delete-field and Edit-field buttons on top of the SDK's built-in tooltip. The Edit button flips into Form Creator mode and a separate listener restores the previous interaction mode when the selection clears.

The API surfaces it exercises across the boundary

Slot replacement: `ui.tools.main`, `ui.tools.contextual`, `ui.signatures.create`, `ui.signatures.list`, `ui.formCreator.propertyEditor`, `annotationTooltipCallback`.

Instance methods: `setUI`, `setViewState`, `applyOperations`, `history.undo`/`redo`, `setAnnotationPresets`, `setCurrentAnnotationPreset`, `contentDocument`, `transformContentClientToPageSpace`, `createAttachment`, `create`, `update`, `delete`, `getSelectedAnnotations`, `setSelectedAnnotations`, `getFormFields`, `getAnnotations`, `exportPDF`, `InteractionMode.*`.

Annotation/form model: `Annotations.WidgetAnnotation`, `Annotations.ImageAnnotation` (with `isSignature: true`), `FormFields.TextFormField`, `FormFields.CheckBoxFormField`, `FormOption`, `Geometry.Rect`, `Geometry.Point`.

This breadth across one app is the point — it shows that all the pieces a customer needs to build a full host-app shell are present and that they compose.

Why this matters

This example was originally built as a validation example for the headless / API-first strategy: prove that the SDK is usable as an embeddable viewer surface inside a customer-owned UI, not only as a closed UI box that can be poked at the edges. As a side benefit, the demo surfaced an API ergonomics gap in slot callbacks (which then drove a follow-up SDK change) — exactly the kind of feedback this category of example is meant to produce.

What's rebuilt vs. consumed

  • Rebuilt from scratch: main toolbar, signature dialog, Form Creator property editor, floating ink/text toolbars, file explorer, signers panel.
  • Augmented: annotation tooltip (custom Delete/Edit items on top of the SDK's tooltip).
  • Consumed as-is: page rendering, thumbnails sidebar, search UI, rich-text editor, annotation/form-field data model, history stack, attachment storage.

Notes

  • The SDK is loaded from the CDN via a `<script>` tag (`pspdfkit-web@1.15.0`), so there's no `pspdfkit-lib` copy step and no `pspdfkit` npm dependency. `baseUrl` is omitted — the SDK auto-detects it from the script's origin.
  • The grouped UI slots used here (`tools.main`, `signatures.create`, `formCreator.propertyEditor`) require Web SDK 1.14+. Older CDN versions reject the config with `'tools' is not a valid UI element`.
  • A shared Baseline UI dark theme is passed straight into the SDK iframe via `NutrientViewer.load({ theme })`, so the demo shell and the viewer chrome render with one set of color tokens — no CSS overrides, no runtime sync.
  • `biome.json` is updated to opt the new example out of repo-wide formatting, matching the convention every other example under `web/` follows (`!web/viewer/multi-tab`, `!web/signing/signing-demo-complete`, etc.).

Test plan

  • `cd web/viewer/web-sdk-demo && npm install && npm run dev`
  • Open http://localhost:5173 — bundled `example.pdf` renders with the custom dark theme
  • Drag a PDF into the sidebar — uploads, switches active doc
  • Toolbar: page nav, rotate, add/delete/move page, zoom, download
  • Sign popover → Get signatures → drag a Signature pill onto the page → signing modal opens at the drop point
  • Drawing mode → floating ink toolbar appears; colour + width work; Undo + Delete drawings work
  • Text mode → floating text toolbar; colour, size, bold/italic, alignment apply to a selected text annotation
  • Edit field (annotation tooltip) → switches to Form Creator, custom property editor renders; renaming via the input updates both the form-field and the widget; clicking off restores the previous interaction mode
  • `npm run build` succeeds

A complete, standalone Vite + React + TypeScript app that showcases a fully
custom UI around the Web SDK: custom top toolbar, draggable ink and text
toolbars, side file explorer with drag-and-drop PDF uploads, a four-tab
signature dialog, drag-and-drop form field placement, and a custom Form
Creator property editor mounted into the SDK's slot.

The SDK is loaded from the CDN via a script tag (pspdfkit-web@1.15.0), so
no pspdfkit-lib copy step is required.

biome.json is updated to opt the new example out of repo-wide formatting,
matching the convention of every other example under web/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@MahmoudElsayad MahmoudElsayad self-assigned this May 16, 2026
@MahmoudElsayad MahmoudElsayad changed the title Add Nutrient Web SDK demo app (React + Vite) Add Nutrient Web SDK demo: full host-app shell around the viewer May 16, 2026
@MahmoudElsayad MahmoudElsayad marked this pull request as ready for review June 2, 2026 01:14
@MahmoudElsayad MahmoudElsayad requested review from a team as code owners June 2, 2026 01:14
@MahmoudElsayad MahmoudElsayad requested a review from ritz078 June 2, 2026 01:14
@MahmoudElsayad

Copy link
Copy Markdown
Author

@sashamilenkovic @sc0 This is ready for you.

@ritz078 ritz078 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review pass — inline suggestions below, none blocking.

const Rect = sdk.Geometry?.Rect ?? sdk.Rect
if (!ImageAnnotation || !Rect) {
console.warn('SDK ImageAnnotation/Rect not found — signature not inserted.')
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This return exits insertSignatureIntoSelected without setSigningModal(null), so when the SDK's ImageAnnotation/Rect constructors aren't found the signing modal stays open with no feedback — and re-clicking Insert keeps re-failing. Every other exit (!instance at 291, success/catch at 326) clears it. Move the close into a finally.

Comment thread web/viewer/web-sdk-demo/src/App.tsx Outdated
Comment on lines +306 to +308
const attachmentId = instance.createAttachment
? await instance.createAttachment(blob)
: null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When createAttachment is absent, attachmentId is null but the code still builds new ImageAnnotation({ imageAttachmentId: null, … }) and creates it — producing a blank/throwing signature rather than failing fast. Bail with a visible message when attachmentId is null instead of flowing it downstream.

Comment on lines +84 to +91
useEffect(() => {
return () => {
files.forEach((f) => {
if (!f.isBuiltin) URL.revokeObjectURL(f.url)
})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty dep array makes this cleanup close over the first-render files ([BUILTIN_FILE]), so blob URLs created for later-uploaded files are never revoked on unmount — the exhaustive-deps disable is hiding exactly that stale closure. Track files in a ref and revoke ref.current here, or drop the effect since it currently does nothing useful.

Comment thread web/viewer/web-sdk-demo/src/App.tsx Outdated

if (!formField) {
console.warn('SDK FormField constructor not found for field type:', type)
await instance.create(widget)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback creates a widget carrying a formFieldName but no backing form field. That orphan is what later breaks renameField (it renames the widget to a name no field has) and leaves a non-functioning field on the page. Consider not creating the widget at all when the FormField constructor is missing, or surfacing the failure.

Comment thread web/viewer/web-sdk-demo/src/Toolbar.tsx Outdated
}

setPageIndex(instance.viewState.currentPageIndex)
setPageCount(instance.totalPageCount)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pageCount is set once here and only viewState.change/zoom.change are subscribed — nothing re-reads instance.totalPageCount after applyOperations add/remove/move. So the "of N" readout, the move-form validation, and the pageCount <= 1 delete-guard all go stale until reload (add pages → count stays old; delete toward 1 → guard checks a stale count). Re-read totalPageCount after each operation.

const list = await instance.getAnnotations(pageIndex)
const inkIds = list
.toArray()
.filter((a) => a.constructor?.name === 'InkAnnotation')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filtering by a.constructor?.name === 'InkAnnotation' relies on class names surviving the minified CDN bundle — if they're mangled, "delete drawings on page" silently matches nothing. instanceof window.NutrientViewer.Annotations.InkAnnotation is robust regardless. Same pattern in text/TextToolbar.tsx:206. Worth confirming against the 1.15.0 CDN build.

Comment on lines +253 to +254
if (formField?.set) updates.push(formField.set('name', nextName))
if (annotation.set) updates.push(annotation.set('formFieldName', nextName))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rename isn't atomic across the widget↔form-field pair: if the form field can't be resolved (e.g. a widget created via the placeFieldAt fallback with no backing field), only annotation.set('formFieldName', …) runs and the widget desyncs from any field, with no rollback. deleteField (line 236) has the sibling issue — it falls back to annotation.id, deleting the widget and orphaning the FormField.

delete: (idOrList: unknown) => Promise<unknown>
update: (objectOrList: unknown) => Promise<unknown>
createAttachment: (blob: Blob) => Promise<string>
getFormFields?: () => Promise<{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getFormFields is an always-present core SDK method but is typed optional, so instance.getFormFields?.() short-circuits to nullgetFormFieldForAnnotation returns []deleteField/renameField silently take the orphan/desync paths. Making it required turns a genuinely-missing API into a caught throw instead of document corruption.

@@ -0,0 +1,21 @@
{
"compilerOptions": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The build script is tsc -b && vite build, and the root tsconfig uses project references, but neither leaf config sets "composite": true. TS build mode errors TS6306 ("Referenced project must have setting composite: true") when references lack it. I couldn't run tsc here — please confirm npm run build passes; if it errors TS6306, add "composite": true to both leaf compilerOptions (allowed alongside noEmit on TS 5.7). Same for tsconfig.node.json.

// selection (Slate transforms) when in EDITING mode, or the annotation
// model when only SELECTED. Each handler is the user's explicit intent —
// we only push the property they actually changed.
const applyColor = useCallback(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyColor/applyFontSize/applyBold/applyItalic repeat an identical guard preamble (instanceinstance.annotations?.texthasSingleTextAnnotationSelectedsafeCall); only the final call differs. Extracting a withTextApi(fn) helper collapses the four bodies with no behavior change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants