Skip to content

Commit 4f92792

Browse files
Address web SDK demo review feedback
1 parent e9f1109 commit 4f92792

8 files changed

Lines changed: 250 additions & 111 deletions

File tree

web/viewer/web-sdk-demo/src/App.tsx

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from 'react'
1+
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { FileExplorer } from './FileExplorer'
33
import { NutrientViewer } from './NutrientViewer'
44
import { Toolbar } from './Toolbar'
@@ -36,6 +36,7 @@ const BUILTIN_FILE: FileEntry = {
3636

3737
export function App() {
3838
const [files, setFiles] = useState<FileEntry[]>([BUILTIN_FILE])
39+
const filesRef = useRef<FileEntry[]>([BUILTIN_FILE])
3940
const [activeId, setActiveId] = useState<string>(BUILTIN_FILE.id)
4041
const [instance, setInstance] = useState<SDKInstance | null>(null)
4142

@@ -81,13 +82,16 @@ export function App() {
8182
[activeId],
8283
)
8384

85+
useEffect(() => {
86+
filesRef.current = files
87+
}, [files])
88+
8489
useEffect(() => {
8590
return () => {
86-
files.forEach((f) => {
91+
filesRef.current.forEach((f) => {
8792
if (!f.isBuiltin) URL.revokeObjectURL(f.url)
8893
})
8994
}
90-
// eslint-disable-next-line react-hooks/exhaustive-deps
9195
}, [])
9296

9397
// ── Place a field at a specific page-space rect (used by drag-drop + click) ──
@@ -160,7 +164,9 @@ export function App() {
160164

161165
if (!formField) {
162166
console.warn('SDK FormField constructor not found for field type:', type)
163-
await instance.create(widget)
167+
window.alert(
168+
'Could not create this form field because the SDK form-field constructor is unavailable.',
169+
)
164170
return
165171
}
166172

@@ -297,15 +303,20 @@ export function App() {
297303
const Rect = sdk.Geometry?.Rect ?? sdk.Rect
298304
if (!ImageAnnotation || !Rect) {
299305
console.warn('SDK ImageAnnotation/Rect not found — signature not inserted.')
306+
window.alert(
307+
'Could not insert the signature because image annotations are unavailable.',
308+
)
300309
return
301310
}
302311

303312
const res = await fetch(dataUrl)
304313
const blob = await res.blob()
305314
const contentType = blob.type || getDataUrlContentType(dataUrl) || 'image/png'
306-
const attachmentId = instance.createAttachment
307-
? await instance.createAttachment(blob)
308-
: null
315+
if (!instance.createAttachment) {
316+
window.alert('Could not insert the signature because attachments are unavailable.')
317+
return
318+
}
319+
const attachmentId = await instance.createAttachment(blob)
309320
const { pageIndex, boundingBox } = await resolveSignatureInsertionTarget(
310321
instance,
311322
signingModal,
@@ -322,8 +333,10 @@ export function App() {
322333
await instance.create(annotation)
323334
} catch (err) {
324335
console.error('Failed to insert signature', err)
336+
window.alert('Could not insert the signature.')
337+
} finally {
338+
setSigningModal(null)
325339
}
326-
setSigningModal(null)
327340
},
328341
[instance, refreshSavedSignatures, signingModal],
329342
)

web/viewer/web-sdk-demo/src/Toolbar.tsx

Lines changed: 145 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -32,25 +32,42 @@ export function Toolbar({
3232
const [zoom, setZoom] = useState(1)
3333
const [pageInput, setPageInput] = useState('1')
3434
const [signMenuOpen, setSignMenuOpen] = useState(false)
35+
const [operationError, setOperationError] = useState<string | null>(null)
36+
37+
const syncPageState = () => {
38+
if (!instance) return
39+
const nextPageCount = instance.totalPageCount
40+
const nextPageIndex = Math.min(
41+
instance.viewState.currentPageIndex,
42+
Math.max(nextPageCount - 1, 0),
43+
)
44+
setPageIndex(nextPageIndex)
45+
setPageCount(nextPageCount)
46+
setZoom(instance.currentZoomLevel ?? 1)
47+
setPageInput(String(nextPageIndex + 1))
48+
}
3549

3650
useEffect(() => {
3751
if (!instance) {
3852
setPageIndex(0)
3953
setPageCount(0)
4054
setZoom(1)
4155
setPageInput('1')
56+
setOperationError(null)
4257
return
4358
}
4459

45-
setPageIndex(instance.viewState.currentPageIndex)
46-
setPageCount(instance.totalPageCount)
47-
setZoom(instance.currentZoomLevel ?? 1)
48-
setPageInput(String(instance.viewState.currentPageIndex + 1))
60+
syncPageState()
4961

5062
const onViewState = (vs: unknown) => {
5163
const state = vs as SDKInstance['viewState']
52-
setPageIndex(state.currentPageIndex)
53-
setPageInput(String(state.currentPageIndex + 1))
64+
const nextPageIndex = Math.min(
65+
state.currentPageIndex,
66+
Math.max(instance.totalPageCount - 1, 0),
67+
)
68+
setPageIndex(nextPageIndex)
69+
setPageCount(instance.totalPageCount)
70+
setPageInput(String(nextPageIndex + 1))
5471
}
5572
const onZoom = (z: unknown) => setZoom(z as number)
5673

@@ -62,6 +79,12 @@ export function Toolbar({
6279
}
6380
}, [instance])
6481

82+
useEffect(() => {
83+
if (!operationError) return
84+
const timeout = window.setTimeout(() => setOperationError(null), 4000)
85+
return () => window.clearTimeout(timeout)
86+
}, [operationError])
87+
6588
const apply = (transform: (vs: SDKInstance['viewState']) => SDKInstance['viewState']) => {
6689
if (!instance) return
6790
instance.setViewState(transform)
@@ -81,33 +104,55 @@ export function Toolbar({
81104
const fitWidth = () =>
82105
apply((vs) => vs.set('zoom', window.NutrientViewer!.ZoomMode.FIT_TO_WIDTH))
83106

84-
const rotatePages = async (rotateBy: 90 | -90) => {
85-
if (!instance) return
86-
await instance.applyOperations([
87-
{ type: 'rotatePages', pageIndexes: [pageIndex], rotateBy },
88-
])
107+
const runOperation = async (
108+
label: string,
109+
operation: (viewer: SDKInstance) => Promise<unknown>,
110+
refreshPages = true,
111+
) => {
112+
if (!instance) return false
113+
setOperationError(null)
114+
try {
115+
await operation(instance)
116+
if (refreshPages) syncPageState()
117+
return true
118+
} catch (err) {
119+
console.error(`${label} failed`, err)
120+
setOperationError(`${label} failed`)
121+
return false
122+
}
89123
}
90-
const deletePage = async () => {
91-
if (!instance || pageCount <= 1) return
92-
await instance.applyOperations([
93-
{ type: 'removePages', pageIndexes: [pageIndex] },
94-
])
124+
125+
const rotatePages = (rotateBy: 90 | -90) => {
126+
return runOperation('Rotate page', (viewer) =>
127+
viewer.applyOperations([
128+
{ type: 'rotatePages', pageIndexes: [pageIndex], rotateBy },
129+
]),
130+
)
95131
}
96-
const addPage = async () => {
97-
if (!instance) return
98-
await instance.applyOperations([
99-
{
100-
type: 'addPage',
101-
afterPageIndex: pageIndex,
102-
backgroundColor: window.NutrientViewer!.Color
103-
? new (window.NutrientViewer!.Color as new (...args: unknown[]) => unknown)({
104-
r: 255,
105-
g: 255,
106-
b: 255,
107-
})
108-
: undefined,
109-
},
110-
])
132+
const deletePage = () => {
133+
if (pageCount <= 1) return
134+
return runOperation('Delete page', (viewer) =>
135+
viewer.applyOperations([
136+
{ type: 'removePages', pageIndexes: [pageIndex] },
137+
]),
138+
)
139+
}
140+
const addPage = () => {
141+
return runOperation('Add page', (viewer) =>
142+
viewer.applyOperations([
143+
{
144+
type: 'addPage',
145+
afterPageIndex: pageIndex,
146+
backgroundColor: window.NutrientViewer!.Color
147+
? new (window.NutrientViewer!.Color as new (...args: unknown[]) => unknown)({
148+
r: 255,
149+
g: 255,
150+
b: 255,
151+
})
152+
: undefined,
153+
},
154+
]),
155+
)
111156
}
112157

113158
const search = () => setMode(window.NutrientViewer!.InteractionMode.SEARCH)
@@ -123,61 +168,94 @@ export function Toolbar({
123168
const fromIndex = fromOneBased - 1
124169
if (fromIndex < 0 || fromIndex >= pageCount) return
125170

126-
// The user's mental model: "After page N" means the moved page should end up
127-
// at slot N+1 in the resulting document. Compute that target final index and
128-
// translate it into the SDK's afterPageIndex/beforePageIndex (which reference
129-
// the *original* page positions).
171+
const afterIndex = afterOneBased - 1
172+
if (afterOneBased > pageCount || afterIndex === fromIndex) {
173+
setMovePopoverOpen(false)
174+
return
175+
}
176+
130177
const targetIndex =
131-
afterOneBased <= 0 ? 0 : Math.min(afterOneBased, pageCount - 1)
178+
afterOneBased <= 0
179+
? 0
180+
: fromIndex < afterIndex
181+
? afterIndex
182+
: afterIndex + 1
132183

133184
if (targetIndex === fromIndex) {
134185
setMovePopoverOpen(false)
135186
return
136187
}
137188

138189
const op =
139-
targetIndex === 0
190+
afterOneBased <= 0
140191
? { type: 'movePages', pageIndexes: [fromIndex], beforePageIndex: 0 }
141192
: {
142193
type: 'movePages',
143194
pageIndexes: [fromIndex],
144-
// Forward move: removing the source shifts later pages left by one,
145-
// so the original-numbering anchor equals the desired final index.
146-
// Backward move: source removal doesn't affect indices before it,
147-
// so the anchor is target − 1 (insert "after that" → at target).
148-
afterPageIndex: targetIndex > fromIndex ? targetIndex : targetIndex - 1,
195+
afterPageIndex: afterIndex,
149196
}
150197

151-
await instance.applyOperations([op])
152-
instance.setViewState((vs) => vs.set('currentPageIndex', targetIndex))
153-
setMovePopoverOpen(false)
198+
const moved = await runOperation('Move page', async (viewer) => {
199+
await viewer.applyOperations([op])
200+
viewer.setViewState((vs) => vs.set('currentPageIndex', targetIndex))
201+
})
202+
if (moved) setMovePopoverOpen(false)
154203
}
155204

156-
const insertImage = async () => {
205+
const insertImage = () => {
157206
if (!instance) return
207+
const sdk = window.NutrientViewer
208+
const ImageAnnotation = sdk?.Annotations?.ImageAnnotation ?? sdk?.ImageAnnotation
209+
const Rect = sdk?.Geometry?.Rect ?? sdk?.Rect
210+
if (!sdk || !ImageAnnotation || !Rect || !instance.createAttachment) {
211+
setOperationError('Insert image unavailable')
212+
return
213+
}
214+
158215
const input = document.createElement('input')
159216
input.type = 'file'
160217
input.accept = 'image/*'
161218
input.onchange = () => {
162-
// Triggering image insertion requires deeper SDK plumbing (attachments +
163-
// image annotations). For this demo, we just enter PAN mode and log.
164-
console.info('TODO: wire insertImage to instance.create()', input.files)
219+
const file = input.files?.[0]
220+
if (!file) return
221+
void runOperation(
222+
'Insert image',
223+
async (viewer) => {
224+
const attachmentId = await viewer.createAttachment(file)
225+
const pageIndex = viewer.viewState.currentPageIndex
226+
const annotation = new ImageAnnotation({
227+
id: sdk.generateInstantId?.(),
228+
pageIndex,
229+
boundingBox: new Rect({ left: 80, top: 80, width: 180, height: 120 }),
230+
description: file.name || 'Inserted image',
231+
imageAttachmentId: attachmentId,
232+
contentType: file.type || 'image/png',
233+
})
234+
await viewer.create(annotation)
235+
},
236+
false,
237+
)
165238
}
166239
input.click()
167240
}
168241

169-
const download = async () => {
170-
if (!instance) return
171-
const buffer = await instance.exportPDF()
172-
const blob = new Blob([buffer], { type: 'application/pdf' })
173-
const url = URL.createObjectURL(blob)
174-
const a = document.createElement('a')
175-
a.href = url
176-
a.download = fileName ?? 'document.pdf'
177-
document.body.appendChild(a)
178-
a.click()
179-
document.body.removeChild(a)
180-
URL.revokeObjectURL(url)
242+
const download = () => {
243+
return runOperation(
244+
'Download',
245+
async (viewer) => {
246+
const buffer = await viewer.exportPDF()
247+
const blob = new Blob([buffer], { type: 'application/pdf' })
248+
const url = URL.createObjectURL(blob)
249+
const a = document.createElement('a')
250+
a.href = url
251+
a.download = fileName ?? 'document.pdf'
252+
document.body.appendChild(a)
253+
a.click()
254+
document.body.removeChild(a)
255+
window.setTimeout(() => URL.revokeObjectURL(url), 0)
256+
},
257+
false,
258+
)
181259
}
182260

183261
const disabled = !instance
@@ -413,6 +491,12 @@ export function Toolbar({
413491
</Popover>
414492
</div>
415493

494+
{operationError && (
495+
<span className="toolbar__status" role="status">
496+
{operationError}
497+
</span>
498+
)}
499+
416500
<div className="toolbar__group toolbar__group--right">
417501
<IconButton
418502
label="Search"

web/viewer/web-sdk-demo/src/form-creator/index.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,15 +225,19 @@ async function getFormFieldForAnnotation(
225225
instance: SDKInstance,
226226
annotation: WidgetLikeAnnotation,
227227
) {
228-
const fields = await instance.getFormFields?.()
229-
const allFields = fields?.toArray?.() ?? []
228+
const fields = await instance.getFormFields()
229+
const allFields = fields.toArray()
230230
return allFields.find((field) => field.name === annotation.formFieldName) ?? null
231231
}
232232

233233
async function deleteField(instance: SDKInstance, annotation: WidgetLikeAnnotation) {
234234
try {
235235
const formField = await getFormFieldForAnnotation(instance, annotation)
236-
const target = formField?.id ?? formField ?? annotation.id
236+
if (!formField) {
237+
console.warn('No backing form field found; skipping widget-only delete.')
238+
return
239+
}
240+
const target = formField.id ?? formField
237241
if (!target) return
238242
await instance.delete(target)
239243
} catch (error) {
@@ -250,8 +254,13 @@ async function renameField(
250254
const updates: unknown[] = []
251255
const formField = await getFormFieldForAnnotation(instance, annotation)
252256

253-
if (formField?.set) updates.push(formField.set('name', nextName))
254-
if (annotation.set) updates.push(annotation.set('formFieldName', nextName))
257+
if (!formField?.set || !annotation.set) {
258+
console.warn('No backing form field found; skipping partial field rename.')
259+
return
260+
}
261+
262+
updates.push(formField.set('name', nextName))
263+
updates.push(annotation.set('formFieldName', nextName))
255264

256265
if (updates.length > 0) await instance.update(updates)
257266
} catch (error) {

0 commit comments

Comments
 (0)