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.
- package/README.md +153 -0
- package/adalo.json +19 -0
- package/dist/editor.js +2 -0
- package/dist/editor.js.LICENSE.txt +1 -0
- package/dist/icons/PDFViewer.png +0 -0
- package/dist/input/editor.js +9 -0
- package/dist/input/runtime.js +1 -0
- package/dist/logo.png +0 -0
- package/dist/metadata.json +1 -0
- package/dist/runtime.js +2 -0
- package/dist/runtime.js.LICENSE.txt +1 -0
- package/logo.png +0 -0
- package/package.json +30 -0
- package/scripts/install_android.sh +20 -0
- package/scripts/install_android.ts +22 -0
- package/scripts/install_ios.sh +13 -0
- package/scripts/install_ios.ts +23 -0
- package/src/components/PDFViewer/icon.png +0 -0
- package/src/components/PDFViewer/index.js +506 -0
- package/src/components/PDFViewer/index.native.js +521 -0
- package/src/components/PDFViewer/index.web.js +172 -0
- package/src/components/PDFViewer/manifest.json +110 -0
|
@@ -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
|