adalo-pdf-viewer 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,521 @@
1
+ /**
2
+ * PDFViewer — Native component (iOS & Android)
3
+ *
4
+ * Design language:
5
+ * iOS → HIG / iOS 18: floating frosted-glass pill, SF-style circular
6
+ * chevron buttons, hairline border, spring opacity feedback
7
+ * Android → Material Design 3: bottom surface bar with rounded top
8
+ * corners, ripple touch feedback, M3 elevation + typography
9
+ */
10
+
11
+ import React, { Component } from 'react'
12
+ import {
13
+ View,
14
+ Text,
15
+ Pressable,
16
+ ActivityIndicator,
17
+ StyleSheet,
18
+ Platform,
19
+ Dimensions,
20
+ Share,
21
+ } from 'react-native'
22
+ import Pdf from 'react-native-pdf'
23
+
24
+ const IS_IOS = Platform.OS === 'ios'
25
+
26
+ // ─── Main Component ─────────────────────────────────────────────────────────
27
+
28
+ class PDFViewer extends Component {
29
+ constructor(props) {
30
+ super(props)
31
+ const { width, height } = Dimensions.get('window')
32
+ this.state = {
33
+ currentPage: 1,
34
+ totalPages: 0,
35
+ loading: true,
36
+ error: false,
37
+ containerWidth: width,
38
+ containerHeight: height,
39
+ }
40
+ this.pdfRef = null
41
+ }
42
+
43
+ // Capture the actual pixel bounds Adalo assigns to this component.
44
+ // react-native-pdf needs explicit numbers — '100%' strings are unreliable.
45
+ handleRootLayout = ({ nativeEvent: { layout: { width, height } } }) => {
46
+ if (width > 0 && height > 0) {
47
+ this.setState({ containerWidth: width, containerHeight: height })
48
+ }
49
+ }
50
+
51
+ handleLoadComplete = (numberOfPages) => {
52
+ const { onLoadComplete } = this.props
53
+ this.setState({ totalPages: numberOfPages, loading: false, error: false })
54
+ if (onLoadComplete) onLoadComplete(numberOfPages)
55
+ }
56
+
57
+ handlePageChanged = (page, numberOfPages) => {
58
+ const { onPageChange } = this.props
59
+ this.setState({ currentPage: page, totalPages: numberOfPages })
60
+ if (onPageChange) onPageChange(page, numberOfPages)
61
+ }
62
+
63
+ handleError = (err) => {
64
+ const { onError } = this.props
65
+ console.warn('[PDFViewer] Error:', err)
66
+ this.setState({ loading: false, error: true })
67
+ if (onError) onError()
68
+ }
69
+
70
+ goToPrevPage = () => {
71
+ const { currentPage } = this.state
72
+ if (currentPage > 1 && this.pdfRef) this.pdfRef.setPage(currentPage - 1)
73
+ }
74
+
75
+ goToNextPage = () => {
76
+ const { currentPage, totalPages } = this.state
77
+ if (currentPage < totalPages && this.pdfRef) this.pdfRef.setPage(currentPage + 1)
78
+ }
79
+
80
+ handleShare = async () => {
81
+ const { pdfUrl } = this.props
82
+ if (!pdfUrl) return
83
+ try {
84
+ await Share.share(
85
+ // iOS Share sheet accepts a url; Android uses message
86
+ IS_IOS
87
+ ? { url: pdfUrl }
88
+ : { message: pdfUrl, title: 'PDF Document' }
89
+ )
90
+ } catch (e) {
91
+ // User cancelled or share not supported — silently ignore
92
+ }
93
+ }
94
+
95
+ render() {
96
+ const {
97
+ pdfUrl,
98
+ fitWidth,
99
+ showControls,
100
+ backgroundColor,
101
+ controlColor,
102
+ controlTextColor,
103
+ loadingText,
104
+ loadingTextColor,
105
+ errorText,
106
+ errorTextColor,
107
+ } = this.props
108
+
109
+ const { currentPage, totalPages, loading, error, containerWidth, containerHeight } = this.state
110
+ const primaryColor = controlColor || (IS_IOS ? '#007AFF' : '#6750A4')
111
+ const bgColor = backgroundColor || '#FFFFFF'
112
+
113
+ // ── Editor / no-URL placeholder ──────────────────────────────────────────
114
+ if (!pdfUrl) {
115
+ return (
116
+ <View style={[shared.placeholder, { backgroundColor: bgColor }]} onLayout={this.handleRootLayout}>
117
+ <Text style={shared.placeholderIcon}>{'📄'}</Text>
118
+ <Text style={shared.placeholderTitle}>PDF Viewer</Text>
119
+ <Text style={[shared.placeholderSub, { color: loadingTextColor || '#8E8E93' }]}>
120
+ Set a PDF URL to preview
121
+ </Text>
122
+ </View>
123
+ )
124
+ }
125
+
126
+ return (
127
+ <View
128
+ style={[shared.root, { backgroundColor: bgColor }]}
129
+ onLayout={this.handleRootLayout}
130
+ >
131
+
132
+ {/* ── PDF renderer ───────────────────────────────────────────────── */}
133
+ <Pdf
134
+ ref={(r) => { this.pdfRef = r }}
135
+ source={{ uri: pdfUrl, cache: true }}
136
+ fitWidth={fitWidth !== false}
137
+ onLoadComplete={this.handleLoadComplete}
138
+ onPageChanged={this.handlePageChanged}
139
+ onError={this.handleError}
140
+ style={[shared.pdf, { width: containerWidth, height: containerHeight }]}
141
+ renderActivityIndicator={() => (
142
+ <View style={shared.loadingContainer}>
143
+ <ActivityIndicator size="large" color={primaryColor} />
144
+ <Text style={[shared.loadingText, { color: loadingTextColor || '#8E8E93' }]}>
145
+ {loadingText || 'Loading PDF…'}
146
+ </Text>
147
+ </View>
148
+ )}
149
+ />
150
+
151
+ {/* ── Error overlay ───────────────────────────────────────────────── */}
152
+ {error && (
153
+ <View style={shared.errorOverlay}>
154
+ <Text style={shared.errorIcon}>{'⚠️'}</Text>
155
+ <Text style={[shared.errorText, { color: errorTextColor || (IS_IOS ? '#FF3B30' : '#B3261E') }]}>
156
+ {errorText || 'Could not load PDF.\nPlease check the URL.'}
157
+ </Text>
158
+ </View>
159
+ )}
160
+
161
+ {/* ── Share button ────────────────────────────────────────────────── */}
162
+ {/* Always visible once loaded. iOS: opens system share sheet */}
163
+ {/* (Save to Files, AirDrop, Mail…). Android: standard share intent. */}
164
+ {!loading && !error && (
165
+ <Pressable
166
+ onPress={this.handleShare}
167
+ style={({ pressed }) => [share.btn, { opacity: pressed ? 0.55 : 1 }]}
168
+ accessibilityLabel="Share PDF"
169
+ accessibilityRole="button"
170
+ >
171
+ {/* iOS-style share icon: box with upward arrow */}
172
+ <Text style={share.icon}>{IS_IOS ? '⬆' : '⤴'}</Text>
173
+ </Pressable>
174
+ )}
175
+
176
+ {/* ── Page controls ───────────────────────────────────────────────── */}
177
+ {showControls && !loading && !error && totalPages > 1 && (
178
+ IS_IOS
179
+ ? (
180
+ <IOSControls
181
+ currentPage={currentPage}
182
+ totalPages={totalPages}
183
+ primaryColor={primaryColor}
184
+ onPrev={this.goToPrevPage}
185
+ onNext={this.goToNextPage}
186
+ />
187
+ )
188
+ : (
189
+ <AndroidControls
190
+ currentPage={currentPage}
191
+ totalPages={totalPages}
192
+ primaryColor={primaryColor}
193
+ textColor={controlTextColor || '#FFFFFF'}
194
+ onPrev={this.goToPrevPage}
195
+ onNext={this.goToNextPage}
196
+ />
197
+ )
198
+ )}
199
+ </View>
200
+ )
201
+ }
202
+ }
203
+
204
+ // ─── iOS Controls ────────────────────────────────────────────────────────────
205
+ // HIG / iOS 18: floating pill, frosted-glass feel, SF-style thin chevrons
206
+
207
+ function IOSControls({ currentPage, totalPages, primaryColor, onPrev, onNext }) {
208
+ const canPrev = currentPage > 1
209
+ const canNext = currentPage < totalPages
210
+
211
+ return (
212
+ <View style={ios.wrapper} pointerEvents="box-none">
213
+ <View style={ios.pill}>
214
+
215
+ {/* Prev */}
216
+ <Pressable
217
+ onPress={onPrev}
218
+ disabled={!canPrev}
219
+ style={({ pressed }) => [
220
+ ios.btn,
221
+ { opacity: !canPrev ? 0.25 : pressed ? 0.5 : 1 },
222
+ ]}
223
+ hitSlop={10}
224
+ accessibilityLabel="Previous page"
225
+ accessibilityRole="button"
226
+ >
227
+ <Text style={[ios.chevron, { color: primaryColor }]}>{'‹'}</Text>
228
+ </Pressable>
229
+
230
+ {/* Hairline divider */}
231
+ <View style={ios.divider} />
232
+
233
+ {/* Page indicator */}
234
+ <View style={ios.indicatorWrap}>
235
+ <Text style={ios.pageNum}>{currentPage}</Text>
236
+ <Text style={ios.pageSep}>{' of '}</Text>
237
+ <Text style={ios.pageTotal}>{totalPages}</Text>
238
+ </View>
239
+
240
+ {/* Hairline divider */}
241
+ <View style={ios.divider} />
242
+
243
+ {/* Next */}
244
+ <Pressable
245
+ onPress={onNext}
246
+ disabled={!canNext}
247
+ style={({ pressed }) => [
248
+ ios.btn,
249
+ { opacity: !canNext ? 0.25 : pressed ? 0.5 : 1 },
250
+ ]}
251
+ hitSlop={10}
252
+ accessibilityLabel="Next page"
253
+ accessibilityRole="button"
254
+ >
255
+ <Text style={[ios.chevron, { color: primaryColor }]}>{'›'}</Text>
256
+ </Pressable>
257
+
258
+ </View>
259
+ </View>
260
+ )
261
+ }
262
+
263
+ const ios = StyleSheet.create({
264
+ wrapper: {
265
+ position: 'absolute',
266
+ bottom: 24,
267
+ left: 0,
268
+ right: 0,
269
+ alignItems: 'center',
270
+ },
271
+ // Frosted-glass pill: high-opacity white, multi-layer shadow, hairline border
272
+ pill: {
273
+ flexDirection: 'row',
274
+ alignItems: 'center',
275
+ backgroundColor: 'rgba(255,255,255,0.90)',
276
+ borderRadius: 32,
277
+ shadowColor: '#000000',
278
+ shadowOffset: { width: 0, height: 2 },
279
+ shadowOpacity: 0.10,
280
+ shadowRadius: 12,
281
+ borderWidth: StyleSheet.hairlineWidth,
282
+ borderColor: 'rgba(0,0,0,0.10)',
283
+ minWidth: 172,
284
+ paddingVertical: 4,
285
+ paddingHorizontal: 4,
286
+ },
287
+ btn: {
288
+ width: 44,
289
+ height: 44,
290
+ borderRadius: 22,
291
+ alignItems: 'center',
292
+ justifyContent: 'center',
293
+ },
294
+ // SF-style thin single-chevron — system font renders this beautifully on iOS
295
+ chevron: {
296
+ fontSize: 28,
297
+ fontWeight: '300',
298
+ lineHeight: 32,
299
+ includeFontPadding: false,
300
+ textAlignVertical: 'center',
301
+ },
302
+ divider: {
303
+ width: StyleSheet.hairlineWidth,
304
+ height: 22,
305
+ backgroundColor: 'rgba(0,0,0,0.12)',
306
+ marginHorizontal: 2,
307
+ },
308
+ indicatorWrap: {
309
+ flex: 1,
310
+ flexDirection: 'row',
311
+ alignItems: 'center',
312
+ justifyContent: 'center',
313
+ paddingHorizontal: 8,
314
+ },
315
+ pageNum: {
316
+ fontSize: 15,
317
+ fontWeight: '600',
318
+ color: '#1C1C1E',
319
+ letterSpacing: -0.3,
320
+ includeFontPadding: false,
321
+ },
322
+ pageSep: {
323
+ fontSize: 13,
324
+ fontWeight: '400',
325
+ color: '#8E8E93',
326
+ marginHorizontal: 1,
327
+ includeFontPadding: false,
328
+ },
329
+ pageTotal: {
330
+ fontSize: 15,
331
+ fontWeight: '400',
332
+ color: '#8E8E93',
333
+ includeFontPadding: false,
334
+ },
335
+ })
336
+
337
+ // ─── Android Controls ─────────────────────────────────────────────────────────
338
+ // Material Design 3: bottom bar, rounded top (16dp), elevation 4, ripple
339
+
340
+ function AndroidControls({ currentPage, totalPages, primaryColor, textColor, onPrev, onNext }) {
341
+ const canPrev = currentPage > 1
342
+ const canNext = currentPage < totalPages
343
+
344
+ return (
345
+ <View style={[android.bar, { backgroundColor: primaryColor }]}>
346
+
347
+ {/* Prev — M3 icon button with ripple */}
348
+ <Pressable
349
+ onPress={onPrev}
350
+ disabled={!canPrev}
351
+ android_ripple={{ color: 'rgba(255,255,255,0.28)', radius: 28, borderless: true }}
352
+ style={[android.iconBtn, { opacity: canPrev ? 1 : 0.38 }]}
353
+ accessibilityLabel="Previous page"
354
+ accessibilityRole="button"
355
+ >
356
+ <Text style={[android.arrowText, { color: textColor }]}>{'◀'}</Text>
357
+ </Pressable>
358
+
359
+ {/* Page indicator — M3 Label Large: 14sp / 500 / tracking +0.1 */}
360
+ <Text style={[android.indicator, { color: textColor }]}>
361
+ {currentPage}{' · '}{totalPages}
362
+ </Text>
363
+
364
+ {/* Next */}
365
+ <Pressable
366
+ onPress={onNext}
367
+ disabled={!canNext}
368
+ android_ripple={{ color: 'rgba(255,255,255,0.28)', radius: 28, borderless: true }}
369
+ style={[android.iconBtn, { opacity: canNext ? 1 : 0.38 }]}
370
+ accessibilityLabel="Next page"
371
+ accessibilityRole="button"
372
+ >
373
+ <Text style={[android.arrowText, { color: textColor }]}>{'▶'}</Text>
374
+ </Pressable>
375
+
376
+ </View>
377
+ )
378
+ }
379
+
380
+ const android = StyleSheet.create({
381
+ // M3 Bottom App Bar: elevation 4, shape.cornerLarge top = 16dp
382
+ bar: {
383
+ position: 'absolute',
384
+ bottom: 0,
385
+ left: 0,
386
+ right: 0,
387
+ height: 56,
388
+ flexDirection: 'row',
389
+ alignItems: 'center',
390
+ justifyContent: 'space-between',
391
+ paddingHorizontal: 4,
392
+ elevation: 4,
393
+ borderTopLeftRadius: 16,
394
+ borderTopRightRadius: 16,
395
+ },
396
+ iconBtn: {
397
+ width: 56,
398
+ height: 56,
399
+ alignItems: 'center',
400
+ justifyContent: 'center',
401
+ borderRadius: 28,
402
+ },
403
+ arrowText: {
404
+ fontSize: 16,
405
+ includeFontPadding: false,
406
+ },
407
+ indicator: {
408
+ flex: 1,
409
+ textAlign: 'center',
410
+ fontSize: 14,
411
+ fontWeight: '500',
412
+ letterSpacing: 0.1,
413
+ includeFontPadding: false,
414
+ },
415
+ })
416
+
417
+ // ─── Share Button ────────────────────────────────────────────────────────────
418
+
419
+ const share = StyleSheet.create({
420
+ btn: {
421
+ position: 'absolute',
422
+ top: 12,
423
+ right: 12,
424
+ width: 36,
425
+ height: 36,
426
+ borderRadius: 18,
427
+ backgroundColor: 'rgba(255,255,255,0.92)',
428
+ alignItems: 'center',
429
+ justifyContent: 'center',
430
+ shadowColor: '#000',
431
+ shadowOffset: { width: 0, height: 1 },
432
+ shadowOpacity: 0.15,
433
+ shadowRadius: 6,
434
+ elevation: 4,
435
+ borderWidth: StyleSheet.hairlineWidth,
436
+ borderColor: 'rgba(0,0,0,0.08)',
437
+ },
438
+ icon: {
439
+ fontSize: 16,
440
+ color: '#007AFF',
441
+ includeFontPadding: false,
442
+ },
443
+ })
444
+
445
+ // ─── Shared Styles ────────────────────────────────────────────────────────────
446
+
447
+ const shared = StyleSheet.create({
448
+ // absoluteFillObject fills whatever container Adalo gives us, independent
449
+ // of the parent's flex direction or layout constraints.
450
+ root: {
451
+ ...StyleSheet.absoluteFillObject,
452
+ overflow: 'hidden',
453
+ },
454
+ // width/height are set dynamically via onLayout — explicit pixels are
455
+ // required because react-native-pdf doesn't handle '100%' strings reliably.
456
+ pdf: {
457
+ flex: 1,
458
+ },
459
+ placeholder: {
460
+ ...StyleSheet.absoluteFillObject,
461
+ alignItems: 'center',
462
+ justifyContent: 'center',
463
+ borderWidth: 1,
464
+ borderColor: '#E5E5EA',
465
+ borderStyle: 'dashed',
466
+ borderRadius: IS_IOS ? 12 : 4,
467
+ margin: 4,
468
+ },
469
+ placeholderIcon: {
470
+ fontSize: 36,
471
+ marginBottom: 10,
472
+ },
473
+ placeholderTitle: {
474
+ fontSize: IS_IOS ? 17 : 16,
475
+ fontWeight: IS_IOS ? '600' : '500',
476
+ color: '#1C1C1E',
477
+ letterSpacing: IS_IOS ? -0.4 : 0,
478
+ marginBottom: 4,
479
+ },
480
+ placeholderSub: {
481
+ fontSize: 13,
482
+ textAlign: 'center',
483
+ },
484
+ loadingContainer: {
485
+ position: 'absolute',
486
+ top: 0,
487
+ left: 0,
488
+ right: 0,
489
+ bottom: 0,
490
+ alignItems: 'center',
491
+ justifyContent: 'center',
492
+ },
493
+ loadingText: {
494
+ marginTop: 12,
495
+ fontSize: 14,
496
+ fontWeight: IS_IOS ? '400' : '500',
497
+ },
498
+ errorOverlay: {
499
+ position: 'absolute',
500
+ top: 0,
501
+ left: 0,
502
+ right: 0,
503
+ bottom: 0,
504
+ alignItems: 'center',
505
+ justifyContent: 'center',
506
+ padding: 24,
507
+ },
508
+ errorIcon: {
509
+ fontSize: 32,
510
+ marginBottom: 10,
511
+ },
512
+ errorText: {
513
+ fontSize: 14,
514
+ textAlign: 'center',
515
+ lineHeight: 20,
516
+ fontWeight: IS_IOS ? '400' : '500',
517
+ },
518
+ })
519
+
520
+ export { PDFViewer }
521
+ export default PDFViewer
@@ -0,0 +1,172 @@
1
+ /**
2
+ * PDFViewer — Web component
3
+ * Uses an iframe with the browser's native PDF renderer.
4
+ * Falls back to a Google Docs viewer for environments that block inline PDFs.
5
+ */
6
+
7
+ import React, { Component } from 'react'
8
+
9
+ // Detect if we're on a mobile browser (not the native app)
10
+ const isMobileBrowser = () => {
11
+ if (typeof window === 'undefined') return false
12
+ return (
13
+ typeof window.orientation !== 'undefined' ||
14
+ navigator.userAgent.indexOf('IEMobile') !== -1
15
+ )
16
+ }
17
+
18
+ class PDFViewerWeb extends Component {
19
+ constructor(props) {
20
+ super(props)
21
+ this.state = {
22
+ loading: true,
23
+ error: false,
24
+ }
25
+ }
26
+
27
+ handleLoad = () => {
28
+ const { onLoadComplete } = this.props
29
+ this.setState({ loading: false, error: false })
30
+ // Web can't know page count from an iframe, so we pass 0
31
+ if (onLoadComplete) onLoadComplete(0)
32
+ }
33
+
34
+ handleError = () => {
35
+ const { onError } = this.props
36
+ this.setState({ loading: false, error: true })
37
+ if (onError) onError()
38
+ }
39
+
40
+ buildEmbedUrl = (pdfUrl) => {
41
+ if (!pdfUrl) return null
42
+ // On mobile browsers, native PDF rendering in iframes is unreliable.
43
+ // Use Google Docs viewer as a universal fallback.
44
+ if (isMobileBrowser()) {
45
+ return `https://docs.google.com/gviewer?embedded=true&url=${encodeURIComponent(pdfUrl)}`
46
+ }
47
+ // On desktop browsers, the browser's built-in PDF renderer is reliable.
48
+ return pdfUrl
49
+ }
50
+
51
+ render() {
52
+ const {
53
+ pdfUrl,
54
+ backgroundColor,
55
+ controlColor,
56
+ loadingText,
57
+ loadingTextColor,
58
+ errorText,
59
+ errorTextColor,
60
+ } = this.props
61
+
62
+ const { loading, error } = this.state
63
+
64
+ const containerStyle = {
65
+ display: 'flex',
66
+ flexDirection: 'column',
67
+ width: '100%',
68
+ height: '100%',
69
+ backgroundColor: backgroundColor || '#fff',
70
+ position: 'relative',
71
+ overflow: 'hidden',
72
+ }
73
+
74
+ const overlayStyle = {
75
+ position: 'absolute',
76
+ top: 0,
77
+ left: 0,
78
+ right: 0,
79
+ bottom: 0,
80
+ display: 'flex',
81
+ flexDirection: 'column',
82
+ alignItems: 'center',
83
+ justifyContent: 'center',
84
+ backgroundColor: backgroundColor || '#fff',
85
+ zIndex: 1,
86
+ }
87
+
88
+ // Placeholder in editor when no URL
89
+ if (!pdfUrl) {
90
+ return (
91
+ <div style={containerStyle}>
92
+ <div style={{ ...overlayStyle, border: '1px dashed #ccc', borderRadius: 4 }}>
93
+ <span style={{ fontSize: 14, color: loadingTextColor || '#888', textAlign: 'center' }}>
94
+ PDF Viewer<br />Set a PDF URL to preview
95
+ </span>
96
+ </div>
97
+ </div>
98
+ )
99
+ }
100
+
101
+ const embedUrl = this.buildEmbedUrl(pdfUrl)
102
+
103
+ return (
104
+ <div style={containerStyle}>
105
+ {/* Loading overlay */}
106
+ {loading && (
107
+ <div style={overlayStyle}>
108
+ <div style={spinnerStyle(controlColor || '#0066cc')} />
109
+ <span style={{ marginTop: 12, fontSize: 14, color: loadingTextColor || '#555' }}>
110
+ {loadingText || 'Loading PDF…'}
111
+ </span>
112
+ </div>
113
+ )}
114
+
115
+ {/* Error overlay */}
116
+ {error && (
117
+ <div style={overlayStyle}>
118
+ <span style={{ fontSize: 14, color: errorTextColor || '#E53E3E', textAlign: 'center', padding: '0 20px' }}>
119
+ {errorText || 'Could not load PDF. Please check the URL.'}
120
+ </span>
121
+ </div>
122
+ )}
123
+
124
+ {/* PDF iframe */}
125
+ {!error && (
126
+ <iframe
127
+ src={embedUrl}
128
+ onLoad={this.handleLoad}
129
+ onError={this.handleError}
130
+ style={{
131
+ flex: 1,
132
+ width: '100%',
133
+ height: '100%',
134
+ border: 'none',
135
+ display: 'block',
136
+ }}
137
+ title="PDF Viewer"
138
+ allow="fullscreen"
139
+ />
140
+ )}
141
+ </div>
142
+ )
143
+ }
144
+ }
145
+
146
+ // Simple CSS spinner — no external library needed on web
147
+ const spinnerStyle = (color) => ({
148
+ width: 36,
149
+ height: 36,
150
+ border: `3px solid ${color}33`,
151
+ borderTop: `3px solid ${color}`,
152
+ borderRadius: '50%',
153
+ animation: 'adalo-pdf-spin 0.8s linear infinite',
154
+ })
155
+
156
+ // Inject keyframes once
157
+ if (typeof document !== 'undefined') {
158
+ const styleId = 'adalo-pdf-viewer-styles'
159
+ if (!document.getElementById(styleId)) {
160
+ const style = document.createElement('style')
161
+ style.id = styleId
162
+ style.textContent = `
163
+ @keyframes adalo-pdf-spin {
164
+ 0% { transform: rotate(0deg); }
165
+ 100% { transform: rotate(360deg); }
166
+ }
167
+ `
168
+ document.head.appendChild(style)
169
+ }
170
+ }
171
+
172
+ export default PDFViewerWeb