@@ -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"
0 commit comments