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,506 @@
1
+ /**
2
+ * PDFViewer — Web / Editor / Previewer component
3
+ *
4
+ * Toolbar: liquid-glass floating pill at the bottom (mobile only).
5
+ * - Share: fetches the PDF as a Blob → shares it as a FILE via Web Share
6
+ * API Level 2 (iOS/Android shows "Save to Files", AirDrop, etc.).
7
+ * Falls back to URL share if CORS blocks the fetch.
8
+ * - Search: expands an inline text field; on submit reloads the iframe
9
+ * with #search=term (Chrome PDF viewer highlights matches).
10
+ *
11
+ * Fit-to-width: appends #zoom=FitH on non-iOS browsers (Chrome/Edge).
12
+ * Height: uses Adalo's _height / _width internal props for exact sizing.
13
+ */
14
+
15
+ import React, { Component } from 'react'
16
+
17
+ // ─── Global styles ───────────────────────────────────────────────────────────
18
+
19
+ function ensureGlobalStyles() {
20
+ if (typeof document === 'undefined') return
21
+ const id = 'adalo-pdf-viewer-styles'
22
+ if (document.getElementById(id)) return
23
+ const el = document.createElement('style')
24
+ el.id = id
25
+ el.textContent = `
26
+ @keyframes adalo-pdf-spin {
27
+ to { transform: rotate(360deg); }
28
+ }
29
+ @keyframes adalo-pdf-fadein {
30
+ from { opacity: 0; transform: translateY(6px); }
31
+ to { opacity: 1; transform: translateY(0); }
32
+ }
33
+ @keyframes adalo-pdf-toolbar-in {
34
+ from { opacity: 0; transform: translateX(-50%) translateY(12px); }
35
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
36
+ }
37
+ `
38
+ document.head.appendChild(el)
39
+ }
40
+
41
+ // ─── Platform helpers ────────────────────────────────────────────────────────
42
+
43
+ const isIOS = () =>
44
+ typeof navigator !== 'undefined' &&
45
+ /iPad|iPhone|iPod/.test(navigator.userAgent) &&
46
+ !window.MSStream
47
+
48
+ const isMobileBrowser = () =>
49
+ typeof navigator !== 'undefined' &&
50
+ /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent)
51
+
52
+ // ─── SVG icons ───────────────────────────────────────────────────────────────
53
+
54
+ // SF-style "square.and.arrow.up"
55
+ const ShareSVG = () => (
56
+ <svg width="17" height="17" viewBox="0 0 17 17" fill="none">
57
+ <path d="M8.5 10.5V1.5" stroke="white" strokeWidth="1.6" strokeLinecap="round"/>
58
+ <path d="M5.5 4.5l3-3 3 3" stroke="white" strokeWidth="1.6"
59
+ strokeLinecap="round" strokeLinejoin="round"/>
60
+ <path d="M4.5 7.5H3a1 1 0 00-1 1v6a1 1 0 001 1h11a1 1 0 001-1v-6a1 1 0 00-1-1h-1.5"
61
+ stroke="white" strokeWidth="1.6" strokeLinecap="round"/>
62
+ </svg>
63
+ )
64
+
65
+ // SF-style magnifying glass
66
+ const SearchSVG = () => (
67
+ <svg width="17" height="17" viewBox="0 0 17 17" fill="none">
68
+ <circle cx="7.5" cy="7.5" r="5" stroke="white" strokeWidth="1.6"/>
69
+ <path d="M11.5 11.5l3 3" stroke="white" strokeWidth="1.6" strokeLinecap="round"/>
70
+ </svg>
71
+ )
72
+
73
+ // X / close
74
+ const CloseSVG = () => (
75
+ <svg width="13" height="13" viewBox="0 0 13 13" fill="none">
76
+ <path d="M1.5 1.5l10 10M11.5 1.5l-10 10" stroke="rgba(255,255,255,0.7)"
77
+ strokeWidth="1.6" strokeLinecap="round"/>
78
+ </svg>
79
+ )
80
+
81
+ // ─── Component ───────────────────────────────────────────────────────────────
82
+
83
+ class PDFViewerWeb extends Component {
84
+ constructor(props) {
85
+ super(props)
86
+ this.state = {
87
+ loading: true,
88
+ error: false,
89
+ searchActive: false,
90
+ searchQuery: '',
91
+ sharing: false,
92
+ }
93
+ this.containerRef = null
94
+ this.iframeRef = null
95
+ ensureGlobalStyles()
96
+ }
97
+
98
+ // ── iframe events ──────────────────────────────────────────────────────────
99
+
100
+ handleLoad = () => {
101
+ const { onLoadComplete } = this.props
102
+ this.setState({ loading: false, error: false })
103
+ if (onLoadComplete) onLoadComplete(0)
104
+ }
105
+
106
+ handleError = () => {
107
+ const { onError } = this.props
108
+ this.setState({ loading: false, error: true })
109
+ if (onError) onError()
110
+ }
111
+
112
+ // ── Share (file, not link) ─────────────────────────────────────────────────
113
+
114
+ handleShare = async () => {
115
+ const { pdfUrl } = this.props
116
+ if (!pdfUrl || this.state.sharing) return
117
+ this.setState({ sharing: true })
118
+
119
+ try {
120
+ // Attempt to fetch the PDF as a Blob so we share a FILE, not just a URL.
121
+ // This triggers "Save to Files", AirDrop, etc. on iOS.
122
+ let shared = false
123
+
124
+ if (navigator.share) {
125
+ try {
126
+ const resp = await fetch(pdfUrl)
127
+ const blob = await resp.blob()
128
+ const rawName = decodeURIComponent(pdfUrl.split('/').pop().split('?')[0]) || 'document'
129
+ const fileName = rawName.endsWith('.pdf') ? rawName : rawName + '.pdf'
130
+ const file = new File([blob], fileName, { type: 'application/pdf' })
131
+
132
+ if (navigator.canShare && navigator.canShare({ files: [file] })) {
133
+ await navigator.share({ files: [file], title: 'PDF Document' })
134
+ shared = true
135
+ }
136
+ } catch (_) {
137
+ // CORS error or canShare not supported — fall through to URL share
138
+ }
139
+
140
+ if (!shared) {
141
+ try {
142
+ await navigator.share({ url: pdfUrl, title: 'PDF Document' })
143
+ shared = true
144
+ } catch (e) {
145
+ if (e.name === 'AbortError') { shared = true } // user cancelled
146
+ }
147
+ }
148
+ }
149
+
150
+ if (!shared) window.open(pdfUrl, '_blank')
151
+ } finally {
152
+ this.setState({ sharing: false })
153
+ }
154
+ }
155
+
156
+ // ── Search ─────────────────────────────────────────────────────────────────
157
+
158
+ handleSearchToggle = () => {
159
+ this.setState(prev => ({
160
+ searchActive: !prev.searchActive,
161
+ searchQuery: '',
162
+ }), () => {
163
+ // If closing search, reset iframe to non-search URL
164
+ if (!this.state.searchActive && this.iframeRef) {
165
+ this.iframeRef.src = this.buildEmbedUrl(this.props.pdfUrl)
166
+ }
167
+ })
168
+ }
169
+
170
+ handleSearchChange = (e) => {
171
+ this.setState({ searchQuery: e.target.value })
172
+ }
173
+
174
+ handleSearchSubmit = (e) => {
175
+ e.preventDefault()
176
+ const { pdfUrl } = this.props
177
+ const { searchQuery } = this.state
178
+ if (!pdfUrl || !this.iframeRef) return
179
+
180
+ const base = this.buildEmbedUrl(pdfUrl)
181
+ const sep = base.includes('#') ? '&' : '#'
182
+ // Chrome PDF viewer supports #search=term to highlight first match
183
+ this.iframeRef.src = searchQuery
184
+ ? `${base}${sep}search=${encodeURIComponent(searchQuery)}`
185
+ : base
186
+ }
187
+
188
+ // ── URL builder ────────────────────────────────────────────────────────────
189
+
190
+ buildEmbedUrl = (url) => {
191
+ if (!url) return null
192
+ // FitH scales the PDF to fill the iframe width in Chrome / Edge.
193
+ // iOS Safari ignores PDF open parameters — the share button is the
194
+ // better path for iOS users who want to view at full quality.
195
+ if (!isIOS()) {
196
+ const sep = url.includes('#') ? '&' : '#'
197
+ return `${url}${sep}zoom=FitH`
198
+ }
199
+ return url
200
+ }
201
+
202
+ // ── Render ─────────────────────────────────────────────────────────────────
203
+
204
+ render() {
205
+ const {
206
+ pdfUrl,
207
+ backgroundColor,
208
+ controlColor,
209
+ loadingText,
210
+ loadingTextColor,
211
+ errorText,
212
+ errorTextColor,
213
+ _height,
214
+ _width,
215
+ } = this.props
216
+
217
+ const { loading, error, searchActive, searchQuery, sharing } = this.state
218
+ const accent = controlColor || '#0066CC'
219
+ const bg = backgroundColor || '#FFFFFF'
220
+ const mobile = isMobileBrowser()
221
+
222
+ const explicitW = _width ? `${_width}px` : '100%'
223
+ const explicitH = _height ? `${_height}px` : '100%'
224
+
225
+ // ── Styles ──────────────────────────────────────────────────────────────
226
+
227
+ const root = {
228
+ position: 'relative',
229
+ display: 'flex',
230
+ flexDirection: 'column',
231
+ width: explicitW,
232
+ height: explicitH,
233
+ backgroundColor: bg,
234
+ overflow: 'hidden',
235
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
236
+ }
237
+
238
+ const overlay = {
239
+ position: 'absolute',
240
+ top: 0, left: 0, right: 0, bottom: 0,
241
+ display: 'flex',
242
+ flexDirection: 'column',
243
+ alignItems: 'center',
244
+ justifyContent: 'center',
245
+ backgroundColor: bg,
246
+ zIndex: 2,
247
+ animation: 'adalo-pdf-fadein 0.2s ease both',
248
+ }
249
+
250
+ // Liquid-glass pill — dark, blurred, very iOS 26
251
+ const pill = {
252
+ position: 'absolute',
253
+ bottom: 18,
254
+ left: '50%',
255
+ transform: 'translateX(-50%)',
256
+ zIndex: 10,
257
+ display: 'flex',
258
+ flexDirection: 'row',
259
+ alignItems: 'center',
260
+ gap: 2,
261
+ // Liquid glass: dark translucent, heavy blur, subtle inner rim light
262
+ background: 'rgba(20, 20, 22, 0.72)',
263
+ backdropFilter: 'blur(28px) saturate(1.8)',
264
+ WebkitBackdropFilter: 'blur(28px) saturate(1.8)',
265
+ borderRadius: 40,
266
+ border: '0.5px solid rgba(255,255,255,0.16)',
267
+ boxShadow: [
268
+ '0 8px 32px rgba(0,0,0,0.28)',
269
+ 'inset 0 1px 0 rgba(255,255,255,0.10)',
270
+ 'inset 0 -1px 0 rgba(0,0,0,0.20)',
271
+ ].join(', '),
272
+ padding: '5px 5px',
273
+ animation: 'adalo-pdf-toolbar-in 0.3s cubic-bezier(0.34,1.56,0.64,1) both',
274
+ // Prevent layout from extending beyond component
275
+ maxWidth: 'calc(100% - 32px)',
276
+ }
277
+
278
+ const iconBtn = (pressed) => ({
279
+ width: 38,
280
+ height: 38,
281
+ borderRadius: 19,
282
+ border: 'none',
283
+ background: pressed ? 'rgba(255,255,255,0.14)' : 'transparent',
284
+ cursor: 'pointer',
285
+ display: 'flex',
286
+ alignItems: 'center',
287
+ justifyContent: 'center',
288
+ padding: 0,
289
+ flexShrink: 0,
290
+ transition: 'background 0.12s',
291
+ })
292
+
293
+ // ── No URL: placeholder ────────────────────────────────────────────────
294
+ if (!pdfUrl) {
295
+ return (
296
+ <div ref={el => { this.containerRef = el }} style={root}>
297
+ <div style={{
298
+ ...overlay,
299
+ border: '1.5px dashed',
300
+ borderColor: accent + '55',
301
+ borderRadius: 10,
302
+ top: 4, left: 4, right: 4, bottom: 4,
303
+ }}>
304
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none"
305
+ style={{ marginBottom: 12, opacity: 0.75 }}>
306
+ <rect x="8" y="4" width="28" height="36" rx="3" fill={accent} opacity="0.12"/>
307
+ <rect x="8" y="4" width="28" height="36" rx="3" stroke={accent} strokeWidth="1.5"/>
308
+ <path d="M28 4v9h9" stroke={accent} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
309
+ <path d="M16 20h16M16 26h10" stroke={accent} strokeWidth="1.5" strokeLinecap="round"/>
310
+ </svg>
311
+ <span style={{ fontSize: 15, fontWeight: 600, color: '#1A1A1A', marginBottom: 4 }}>
312
+ PDF Viewer
313
+ </span>
314
+ <span style={{ fontSize: 13, color: loadingTextColor || '#8E8E93',
315
+ textAlign: 'center', lineHeight: '18px' }}>
316
+ Set a PDF URL to preview
317
+ </span>
318
+ </div>
319
+ </div>
320
+ )
321
+ }
322
+
323
+ const embedUrl = this.buildEmbedUrl(pdfUrl)
324
+
325
+ return (
326
+ <div ref={el => { this.containerRef = el }} style={root}>
327
+
328
+ {/* ── Loading overlay ───────────────────────────────────────────── */}
329
+ {loading && (
330
+ <div style={overlay}>
331
+ <div style={{
332
+ width: 36, height: 36, borderRadius: '50%',
333
+ border: `3px solid ${accent}22`,
334
+ borderTopColor: accent,
335
+ animation: 'adalo-pdf-spin 0.75s linear infinite',
336
+ marginBottom: 14,
337
+ }}/>
338
+ <span style={{ fontSize: 13, color: loadingTextColor || '#8E8E93' }}>
339
+ {loadingText || 'Loading PDF…'}
340
+ </span>
341
+ </div>
342
+ )}
343
+
344
+ {/* ── Error overlay ─────────────────────────────────────────────── */}
345
+ {error && (
346
+ <div style={overlay}>
347
+ <div style={{
348
+ width: 44, height: 44, borderRadius: '50%',
349
+ backgroundColor: (errorTextColor || '#FF3B30') + '15',
350
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
351
+ marginBottom: 12,
352
+ }}>
353
+ <svg width="22" height="22" viewBox="0 0 22 22" fill="none">
354
+ <circle cx="11" cy="11" r="10" stroke={errorTextColor || '#FF3B30'} strokeWidth="1.5"/>
355
+ <path d="M11 7v5M11 15v.5" stroke={errorTextColor || '#FF3B30'}
356
+ strokeWidth="1.75" strokeLinecap="round"/>
357
+ </svg>
358
+ </div>
359
+ <span style={{ fontSize: 14, color: errorTextColor || '#FF3B30',
360
+ textAlign: 'center', lineHeight: '20px', maxWidth: 220 }}>
361
+ {errorText || 'Could not load PDF.\nPlease check the URL.'}
362
+ </span>
363
+ </div>
364
+ )}
365
+
366
+ {/* ── PDF iframe ────────────────────────────────────────────────── */}
367
+ {!error && (() => {
368
+ const iosDevice = isIOS()
369
+ // iOS Safari ignores PDF open parameters like #zoom=FitH.
370
+ // Workaround: render the iframe at natural PDF page width (612pt),
371
+ // then scale it down so it fits our component width exactly.
372
+ // Standard A4/Letter pages are 612pt wide; we scale to component width.
373
+ const compW = parseInt(_width || '400', 10) || 400
374
+ const compH = parseInt(_height || '687', 10) || 687
375
+ const PDF_PAGE_W = 612 // standard letter/A4 PDF width in points
376
+ const iosScale = compW / PDF_PAGE_W // e.g. 400/612 ≈ 0.65
377
+ const scaledW = PDF_PAGE_W // render at natural width
378
+ const scaledH = Math.round(compH / iosScale)
379
+
380
+ return (
381
+ // Wrapper clips the scaled iframe to the component bounds
382
+ <div style={{
383
+ position: 'relative',
384
+ flexGrow: 1, flexShrink: 1, flexBasis: '0%',
385
+ overflow: 'hidden',
386
+ minHeight: 0,
387
+ }}>
388
+ <iframe
389
+ ref={el => { this.iframeRef = el }}
390
+ src={embedUrl}
391
+ onLoad={this.handleLoad}
392
+ onError={this.handleError}
393
+ title="PDF Viewer"
394
+ allow="fullscreen"
395
+ style={iosDevice ? {
396
+ // Render at the PDF's natural width, then scale to fit
397
+ display: 'block',
398
+ border: 'none',
399
+ width: `${scaledW}px`,
400
+ height: `${scaledH}px`,
401
+ transform: `scale(${iosScale})`,
402
+ transformOrigin: '0 0',
403
+ } : {
404
+ // Chrome / desktop: FitH handles width; just fill the box
405
+ display: 'block',
406
+ border: 'none',
407
+ width: '100%',
408
+ height: '100%',
409
+ position: 'absolute',
410
+ top: 0, left: 0,
411
+ }}
412
+ />
413
+ </div>
414
+ )
415
+ })()}
416
+
417
+ {/* ── Liquid-glass toolbar (mobile, after load) ─────────────────── */}
418
+ {mobile && !loading && !error && (
419
+ <div style={pill}>
420
+
421
+ {/* Search toggle / close */}
422
+ <button
423
+ onClick={this.handleSearchToggle}
424
+ style={iconBtn(false)}
425
+ title={searchActive ? 'Close search' : 'Search PDF'}
426
+ aria-label={searchActive ? 'Close search' : 'Search PDF'}
427
+ >
428
+ {searchActive ? <CloseSVG /> : <SearchSVG />}
429
+ </button>
430
+
431
+ {/* Expandable search input */}
432
+ {searchActive && (
433
+ <form
434
+ onSubmit={this.handleSearchSubmit}
435
+ style={{ display: 'flex', alignItems: 'center', overflow: 'hidden' }}
436
+ >
437
+ <input
438
+ type="text"
439
+ autoFocus
440
+ placeholder="Search…"
441
+ value={searchQuery}
442
+ onChange={this.handleSearchChange}
443
+ style={{
444
+ width: 160,
445
+ height: 30,
446
+ background: 'rgba(255,255,255,0.10)',
447
+ border: 'none',
448
+ borderRadius: 8,
449
+ color: 'white',
450
+ fontSize: 15,
451
+ padding: '0 10px',
452
+ outline: 'none',
453
+ caretColor: 'white',
454
+ // placeholder colour via CSS class isn't possible inline;
455
+ // it'll default to the browser's placeholder style
456
+ }}
457
+ />
458
+ </form>
459
+ )}
460
+
461
+ {/* Divider */}
462
+ {!searchActive && (
463
+ <div style={{
464
+ width: 0.5,
465
+ height: 22,
466
+ background: 'rgba(255,255,255,0.18)',
467
+ margin: '0 2px',
468
+ flexShrink: 0,
469
+ }}/>
470
+ )}
471
+
472
+ {/* Share */}
473
+ <button
474
+ onClick={this.handleShare}
475
+ disabled={sharing}
476
+ style={{
477
+ ...iconBtn(false),
478
+ opacity: sharing ? 0.5 : 1,
479
+ cursor: sharing ? 'default' : 'pointer',
480
+ }}
481
+ title="Share PDF"
482
+ aria-label="Share PDF"
483
+ >
484
+ {sharing
485
+ ? (
486
+ <div style={{
487
+ width: 16, height: 16, borderRadius: '50%',
488
+ border: '2px solid rgba(255,255,255,0.3)',
489
+ borderTopColor: 'white',
490
+ animation: 'adalo-pdf-spin 0.7s linear infinite',
491
+ }}/>
492
+ )
493
+ : <ShareSVG />
494
+ }
495
+ </button>
496
+
497
+ </div>
498
+ )}
499
+
500
+ </div>
501
+ )
502
+ }
503
+ }
504
+
505
+ export { PDFViewerWeb as PDFViewer }
506
+ export default PDFViewerWeb