Add Nutrient Web SDK demo: full host-app shell around the viewer#193
Add Nutrient Web SDK demo: full host-app shell around the viewer#193MahmoudElsayad wants to merge 3 commits into
Conversation
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>
|
@sashamilenkovic @sc0 This is ready for you. |
| const Rect = sdk.Geometry?.Rect ?? sdk.Rect | ||
| if (!ImageAnnotation || !Rect) { | ||
| console.warn('SDK ImageAnnotation/Rect not found — signature not inserted.') | ||
| return |
There was a problem hiding this comment.
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.
| const attachmentId = instance.createAttachment | ||
| ? await instance.createAttachment(blob) | ||
| : null |
There was a problem hiding this comment.
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.
| useEffect(() => { | ||
| return () => { | ||
| files.forEach((f) => { | ||
| if (!f.isBuiltin) URL.revokeObjectURL(f.url) | ||
| }) | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []) |
There was a problem hiding this comment.
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.
|
|
||
| if (!formField) { | ||
| console.warn('SDK FormField constructor not found for field type:', type) | ||
| await instance.create(widget) |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| setPageIndex(instance.viewState.currentPageIndex) | ||
| setPageCount(instance.totalPageCount) |
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
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.
| if (formField?.set) updates.push(formField.set('name', nextName)) | ||
| if (annotation.set) updates.push(annotation.set('formFieldName', nextName)) |
There was a problem hiding this comment.
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<{ |
There was a problem hiding this comment.
getFormFields is an always-present core SDK method but is typed optional, so instance.getFormFields?.() short-circuits to null → getFormFieldForAnnotation 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": { | |||
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
applyColor/applyFontSize/applyBold/applyItalic repeat an identical guard preamble (instance → instance.annotations?.text → hasSingleTextAnnotationSelected → safeCall); only the final call differs. Extracting a withTextApi(fn) helper collapses the four bodies with no behavior change.
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
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
Notes
Test plan