@linktr.ee/messaging-react 2.1.0 → 2.2.0-rc-1778753733

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.
Files changed (58) hide show
  1. package/dist/{Card-CsJvUF_b.js → Card-BdTueeyk.js} +2 -2
  2. package/dist/{Card-CsJvUF_b.js.map → Card-BdTueeyk.js.map} +1 -1
  3. package/dist/{Card-DlMSDSdm.js → Card-ChR37pLZ.js} +2 -2
  4. package/dist/{Card-DlMSDSdm.js.map → Card-ChR37pLZ.js.map} +1 -1
  5. package/dist/{Card-CFFNq49v.js → Card-EKxCn56j.js} +3 -3
  6. package/dist/{Card-CFFNq49v.js.map → Card-EKxCn56j.js.map} +1 -1
  7. package/dist/{LockedThumbnail-DpJx169C.js → LockedThumbnail-B16qP3eH.js} +2 -2
  8. package/dist/{LockedThumbnail-DpJx169C.js.map → LockedThumbnail-B16qP3eH.js.map} +1 -1
  9. package/dist/index-Dn7BC9xK.js +4748 -0
  10. package/dist/index-Dn7BC9xK.js.map +1 -0
  11. package/dist/index.d.ts +591 -25
  12. package/dist/index.js +24 -19
  13. package/package.json +1 -1
  14. package/src/components/CustomMessage/MessageAttachmentConversations.stories.tsx +841 -0
  15. package/src/components/LinkAttachment/LinkAttachment.stories.tsx +7 -92
  16. package/src/components/LinkAttachment/LinkAttachment.test.tsx +69 -0
  17. package/src/components/LinkAttachment/components/Received/Card.tsx +10 -30
  18. package/src/components/LinkAttachment/components/_shared/CardShell.tsx +5 -1
  19. package/src/components/LinkAttachment/index.tsx +24 -50
  20. package/src/components/LinkAttachment/types.ts +12 -5
  21. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +203 -0
  22. package/src/components/MessageAttachment/Audio/index.tsx +189 -0
  23. package/src/components/MessageAttachment/File/FileAttachment.stories.tsx +352 -0
  24. package/src/components/MessageAttachment/File/index.tsx +240 -0
  25. package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +288 -0
  26. package/src/components/MessageAttachment/Image/index.tsx +257 -0
  27. package/src/components/MessageAttachment/MessageAttachment.test.tsx +783 -0
  28. package/src/components/MessageAttachment/Pdf/PdfAttachment.stories.tsx +292 -0
  29. package/src/components/MessageAttachment/Pdf/index.tsx +228 -0
  30. package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +272 -0
  31. package/src/components/MessageAttachment/Video/index.tsx +281 -0
  32. package/src/components/MessageAttachment/_shared/Bubble.tsx +173 -0
  33. package/src/components/MessageAttachment/_shared/CompactDocumentRow.tsx +152 -0
  34. package/src/components/MessageAttachment/_shared/DismissButton.tsx +39 -0
  35. package/src/components/MessageAttachment/_shared/DownloadAction.tsx +175 -0
  36. package/src/components/MessageAttachment/_shared/ImageViewer.tsx +314 -0
  37. package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +139 -0
  38. package/src/components/MessageAttachment/_shared/PdfViewer.tsx +100 -0
  39. package/src/components/MessageAttachment/_shared/VideoViewer.tsx +171 -0
  40. package/src/components/MessageAttachment/_shared/ViewerShell.tsx +159 -0
  41. package/src/components/MessageAttachment/_shared/fileMeta.test.ts +82 -0
  42. package/src/components/MessageAttachment/_shared/fileMeta.ts +95 -0
  43. package/src/components/MessageAttachment/_shared/triggerDownload.ts +54 -0
  44. package/src/components/MessageAttachment/_shared/useViewer.ts +53 -0
  45. package/src/components/MessageAttachment/index.tsx +149 -0
  46. package/src/components/MessageAttachment/stories/StoryTable.tsx +72 -0
  47. package/src/components/MessageAttachment/types.ts +178 -0
  48. package/src/index.ts +32 -0
  49. package/dist/Card-D32U6KfZ.js +0 -85
  50. package/dist/Card-D32U6KfZ.js.map +0 -1
  51. package/dist/Card-DlSSJPip.js +0 -60
  52. package/dist/Card-DlSSJPip.js.map +0 -1
  53. package/dist/Card-zGbhRBwv.js +0 -48
  54. package/dist/Card-zGbhRBwv.js.map +0 -1
  55. package/dist/CardThumbnail-DTBuRQHF.js +0 -239
  56. package/dist/CardThumbnail-DTBuRQHF.js.map +0 -1
  57. package/dist/index-DfcRe-Hj.js +0 -3103
  58. package/dist/index-DfcRe-Hj.js.map +0 -1
@@ -0,0 +1,314 @@
1
+ import {
2
+ CaretLeftIcon,
3
+ CaretRightIcon,
4
+ MagnifyingGlassMinusIcon,
5
+ MagnifyingGlassPlusIcon,
6
+ } from '@phosphor-icons/react'
7
+ import classNames from 'classnames'
8
+ import React, {
9
+ useCallback,
10
+ useEffect,
11
+ useMemo,
12
+ useRef,
13
+ useState,
14
+ } from 'react'
15
+
16
+ import DownloadAction from './DownloadAction'
17
+ import { filenameFromUrl } from './fileMeta'
18
+ import ViewerShell from './ViewerShell'
19
+
20
+ export interface ImageViewerItem {
21
+ src: string
22
+ alt?: string
23
+ /**
24
+ * Filename used by the download action. Falls back to the last
25
+ * pathname segment of `src` when omitted.
26
+ */
27
+ filename?: string
28
+ }
29
+
30
+ export interface ImageViewerProps {
31
+ open: boolean
32
+ items: ImageViewerItem[]
33
+ /** Currently active item. Defaults to `0`. */
34
+ initialIndex?: number
35
+ onClose: () => void
36
+ }
37
+
38
+ const MIN_SCALE = 1
39
+ const MAX_SCALE = 8
40
+ const ZOOM_STEP = 1.25
41
+
42
+ const clamp = (value: number, min: number, max: number) =>
43
+ Math.min(Math.max(value, min), max)
44
+
45
+ interface ViewportState {
46
+ scale: number
47
+ x: number
48
+ y: number
49
+ }
50
+
51
+ const RESET_STATE: ViewportState = { scale: 1, x: 0, y: 0 }
52
+
53
+ /**
54
+ * Native lightbox-style image viewer with mouse-wheel zoom, drag-to-pan,
55
+ * double-click toggle (1× ↔ 2×), keyboard arrow navigation between
56
+ * stacked items, and built-in download.
57
+ *
58
+ * Used by every `MessageAttachment.Image.*` variant — Composer / Sent /
59
+ * Received all open this viewer when activated, so admins can preview
60
+ * attachments before sending and recipients can preview after receipt.
61
+ */
62
+ const ImageViewer: React.FC<ImageViewerProps> = ({
63
+ open,
64
+ items,
65
+ initialIndex = 0,
66
+ onClose,
67
+ }) => {
68
+ const safeIndex = clamp(initialIndex, 0, Math.max(items.length - 1, 0))
69
+ const [index, setIndex] = useState(safeIndex)
70
+ const [view, setView] = useState<ViewportState>(RESET_STATE)
71
+ const [dragging, setDragging] = useState(false)
72
+ const dragStart = useRef<{
73
+ x: number
74
+ y: number
75
+ panX: number
76
+ panY: number
77
+ } | null>(null)
78
+ const stageRef = useRef<HTMLDivElement | null>(null)
79
+
80
+ // Reset zoom whenever the viewer opens or the active item changes.
81
+ useEffect(() => {
82
+ if (!open) return
83
+ setIndex(clamp(initialIndex, 0, Math.max(items.length - 1, 0)))
84
+ setView(RESET_STATE)
85
+ }, [open, initialIndex, items.length])
86
+
87
+ useEffect(() => {
88
+ setView(RESET_STATE)
89
+ }, [index])
90
+
91
+ const goPrev = useCallback(() => {
92
+ setIndex((i) => (i <= 0 ? items.length - 1 : i - 1))
93
+ }, [items.length])
94
+
95
+ const goNext = useCallback(() => {
96
+ setIndex((i) => (i >= items.length - 1 ? 0 : i + 1))
97
+ }, [items.length])
98
+
99
+ // Arrow-key navigation between items in the stack.
100
+ useEffect(() => {
101
+ if (!open) return undefined
102
+ const onKey = (e: KeyboardEvent) => {
103
+ if (items.length <= 1) return
104
+ if (e.key === 'ArrowRight') {
105
+ e.preventDefault()
106
+ goNext()
107
+ } else if (e.key === 'ArrowLeft') {
108
+ e.preventDefault()
109
+ goPrev()
110
+ }
111
+ }
112
+ window.addEventListener('keydown', onKey)
113
+ return () => window.removeEventListener('keydown', onKey)
114
+ }, [open, items.length, goPrev, goNext])
115
+
116
+ const zoomBy = useCallback((factor: number) => {
117
+ setView((prev) => {
118
+ const nextScale = clamp(prev.scale * factor, MIN_SCALE, MAX_SCALE)
119
+ // Re-center when we hit 1× so the image is never offset off-screen.
120
+ if (nextScale === MIN_SCALE) return RESET_STATE
121
+ return { scale: nextScale, x: prev.x, y: prev.y }
122
+ })
123
+ }, [])
124
+
125
+ // React's synthetic `onWheel` is registered as a passive listener
126
+ // (since React 17), so `e.preventDefault()` is a no-op inside an
127
+ // `onWheel` handler — the page scrolls behind the viewer regardless.
128
+ // Attach the listener manually on the stage element with
129
+ // `{ passive: false }` so we can actually stop the page scroll
130
+ // while the user zooms.
131
+ useEffect(() => {
132
+ if (!open) return undefined
133
+ const stage = stageRef.current
134
+ if (!stage) return undefined
135
+
136
+ const handleWheel = (e: WheelEvent) => {
137
+ e.preventDefault()
138
+ const factor = e.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP
139
+ zoomBy(factor)
140
+ }
141
+
142
+ stage.addEventListener('wheel', handleWheel, { passive: false })
143
+ return () => stage.removeEventListener('wheel', handleWheel)
144
+ }, [open, zoomBy])
145
+
146
+ const handleDoubleClick = useCallback(() => {
147
+ setView((prev) =>
148
+ prev.scale > 1
149
+ ? RESET_STATE
150
+ : { scale: 2, x: prev.x, y: prev.y }
151
+ )
152
+ }, [])
153
+
154
+ const handleMouseDown = useCallback(
155
+ (e: React.MouseEvent<HTMLDivElement>) => {
156
+ // Only allow drag-pan once the user has zoomed in.
157
+ if (view.scale <= 1) return
158
+ e.preventDefault()
159
+ setDragging(true)
160
+ dragStart.current = {
161
+ x: e.clientX,
162
+ y: e.clientY,
163
+ panX: view.x,
164
+ panY: view.y,
165
+ }
166
+ },
167
+ [view.scale, view.x, view.y]
168
+ )
169
+
170
+ useEffect(() => {
171
+ if (!dragging) return undefined
172
+
173
+ const onMove = (e: MouseEvent) => {
174
+ const start = dragStart.current
175
+ if (!start) return
176
+ setView((prev) => ({
177
+ scale: prev.scale,
178
+ x: start.panX + (e.clientX - start.x),
179
+ y: start.panY + (e.clientY - start.y),
180
+ }))
181
+ }
182
+ const onUp = () => {
183
+ dragStart.current = null
184
+ setDragging(false)
185
+ }
186
+ window.addEventListener('mousemove', onMove)
187
+ window.addEventListener('mouseup', onUp)
188
+ return () => {
189
+ window.removeEventListener('mousemove', onMove)
190
+ window.removeEventListener('mouseup', onUp)
191
+ }
192
+ }, [dragging])
193
+
194
+ const item = items[index]
195
+ const filename = useMemo(
196
+ () =>
197
+ item?.filename ?? (item ? filenameFromUrl(item.src) : 'image'),
198
+ [item]
199
+ )
200
+
201
+ const cursorClass = useMemo(() => {
202
+ if (view.scale <= 1) return 'cursor-zoom-in'
203
+ return dragging ? 'cursor-grabbing' : 'cursor-grab'
204
+ }, [view.scale, dragging])
205
+
206
+ if (!item) return null
207
+
208
+ const totalLabel = items.length > 1 ? ` (${index + 1} / ${items.length})` : ''
209
+
210
+ return (
211
+ <ViewerShell
212
+ open={open}
213
+ onClose={onClose}
214
+ title={`${filename}${totalLabel}`}
215
+ actions={
216
+ <>
217
+ <button
218
+ type="button"
219
+ onClick={() => zoomBy(1 / ZOOM_STEP)}
220
+ disabled={view.scale <= MIN_SCALE}
221
+ aria-label="Zoom out"
222
+ className="flex size-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 disabled:opacity-40"
223
+ >
224
+ <MagnifyingGlassMinusIcon
225
+ className="size-5"
226
+ weight="bold"
227
+ aria-hidden
228
+ />
229
+ </button>
230
+ <button
231
+ type="button"
232
+ onClick={() => zoomBy(ZOOM_STEP)}
233
+ disabled={view.scale >= MAX_SCALE}
234
+ aria-label="Zoom in"
235
+ className="flex size-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20 disabled:opacity-40"
236
+ >
237
+ <MagnifyingGlassPlusIcon
238
+ className="size-5"
239
+ weight="bold"
240
+ aria-hidden
241
+ />
242
+ </button>
243
+ <DownloadAction
244
+ url={item.src}
245
+ filename={filename}
246
+ variant="overlay"
247
+ label={`Download ${filename}`}
248
+ />
249
+ </>
250
+ }
251
+ data-testid="image-viewer"
252
+ >
253
+ <div
254
+ ref={stageRef}
255
+ // The stage swallows wheel / double-click / drag gestures to
256
+ // implement the zoom + pan controls. It's not a button (clicks
257
+ // don't have a single discrete action), so suppress the
258
+ // a11y/no-static-element-interactions rule here — the discrete
259
+ // zoom / download actions live in the toolbar above. The wheel
260
+ // listener is attached manually (non-passive) in the effect
261
+ // above so `preventDefault()` actually stops page scroll.
262
+ // eslint-disable-next-line jsx-a11y/no-static-element-interactions
263
+ role="presentation"
264
+ onDoubleClick={handleDoubleClick}
265
+ onMouseDown={handleMouseDown}
266
+ className={classNames(
267
+ 'relative flex h-full w-full select-none items-center justify-center overflow-hidden',
268
+ cursorClass
269
+ )}
270
+ >
271
+ <img
272
+ src={item.src}
273
+ alt={item.alt ?? filename}
274
+ draggable={false}
275
+ // Once the user has explicitly opened the viewer the active
276
+ // image needs to appear immediately, so we eager-load it.
277
+ // Adjacent / off-screen viewer images aren't rendered here
278
+ // (we only mount `items[index]`), so the lazy default on
279
+ // siblings is automatic.
280
+ loading="eager"
281
+ decoding="async"
282
+ style={{
283
+ transform: `translate3d(${view.x}px, ${view.y}px, 0) scale(${view.scale})`,
284
+ transition: dragging ? 'none' : 'transform 120ms ease-out',
285
+ }}
286
+ className="max-h-full max-w-full object-contain"
287
+ />
288
+ </div>
289
+
290
+ {items.length > 1 ? (
291
+ <>
292
+ <button
293
+ type="button"
294
+ onClick={goPrev}
295
+ aria-label="Previous image"
296
+ className="absolute left-4 top-1/2 z-10 flex size-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
297
+ >
298
+ <CaretLeftIcon className="size-5" weight="bold" aria-hidden />
299
+ </button>
300
+ <button
301
+ type="button"
302
+ onClick={goNext}
303
+ aria-label="Next image"
304
+ className="absolute right-4 top-1/2 z-10 flex size-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
305
+ >
306
+ <CaretRightIcon className="size-5" weight="bold" aria-hidden />
307
+ </button>
308
+ </>
309
+ ) : null}
310
+ </ViewerShell>
311
+ )
312
+ }
313
+
314
+ export default ImageViewer
@@ -0,0 +1,139 @@
1
+ import classNames from 'classnames'
2
+ import React from 'react'
3
+
4
+ export interface MediaStackTile {
5
+ /** Renderable tile content (image / video / poster). */
6
+ content: React.ReactNode
7
+ /** Pure-tile aria label (e.g. `'Photo 1'`). Used by `Pressable` mode. */
8
+ ariaLabel?: string
9
+ }
10
+
11
+ export interface MediaStackGridProps {
12
+ tiles: MediaStackTile[]
13
+ /**
14
+ * When set, every tile is wrapped in a `<button>` and forwards the
15
+ * tile index to `onTileActivate`. Used by Image / Video so any tile
16
+ * opens the lightbox / video viewer at the right index.
17
+ */
18
+ onTileActivate?: (index: number) => void
19
+ /**
20
+ * Maximum tiles to render before the last one collapses into an
21
+ * "+N more" overflow indicator. Defaults to `4`.
22
+ */
23
+ maxVisible?: number
24
+ className?: string
25
+ }
26
+
27
+ const TILE_SHELL =
28
+ 'relative block size-full overflow-hidden bg-black/5 outline-none focus-visible:ring-2 focus-visible:ring-white/80 focus-visible:ring-offset-2 focus-visible:ring-offset-black'
29
+
30
+ /**
31
+ * Adaptive grid used by stacked image / video attachments. Layouts:
32
+ *
33
+ * - 1 tile — full-bleed
34
+ * - 2 tiles — equal-width side-by-side row
35
+ * - 3 tiles — one large left tile + two stacked right tiles
36
+ * - 4 tiles — 2×2 grid
37
+ * - 5+ — 2×2 grid with the bottom-right tile showing "+N more"
38
+ *
39
+ * The grid is square-ish overall (1:1 for 1, 16:9 for 2, 4:3 for 3+) so
40
+ * stacks fit comfortably inside the bubble width without dominating the
41
+ * conversation.
42
+ */
43
+ const MediaStackGrid: React.FC<MediaStackGridProps> = ({
44
+ tiles,
45
+ onTileActivate,
46
+ maxVisible = 4,
47
+ className,
48
+ }) => {
49
+ const total = tiles.length
50
+ if (total === 0) return null
51
+
52
+ const visible = tiles.slice(0, Math.min(total, maxVisible))
53
+ const overflow = total - visible.length
54
+
55
+ const renderTile = (tile: MediaStackTile, index: number, extra?: React.ReactNode) => {
56
+ const sharedClass = classNames(TILE_SHELL, 'h-full w-full')
57
+ if (onTileActivate) {
58
+ return (
59
+ <button
60
+ type="button"
61
+ key={index}
62
+ onClick={() => onTileActivate(index)}
63
+ aria-label={tile.ariaLabel ?? `Open media ${index + 1}`}
64
+ className={classNames(sharedClass, 'cursor-zoom-in')}
65
+ >
66
+ {tile.content}
67
+ {extra}
68
+ </button>
69
+ )
70
+ }
71
+ return (
72
+ <div key={index} className={sharedClass}>
73
+ {tile.content}
74
+ {extra}
75
+ </div>
76
+ )
77
+ }
78
+
79
+ if (visible.length === 1) {
80
+ return (
81
+ <div className={classNames('aspect-square w-full', className)}>
82
+ {renderTile(visible[0], 0)}
83
+ </div>
84
+ )
85
+ }
86
+
87
+ if (visible.length === 2) {
88
+ return (
89
+ <div
90
+ className={classNames(
91
+ 'grid aspect-[16/9] w-full grid-cols-2 gap-0.5',
92
+ className
93
+ )}
94
+ >
95
+ {visible.map((tile, index) => renderTile(tile, index))}
96
+ </div>
97
+ )
98
+ }
99
+
100
+ if (visible.length === 3) {
101
+ return (
102
+ <div
103
+ className={classNames(
104
+ 'grid aspect-[4/3] w-full grid-cols-2 grid-rows-2 gap-0.5',
105
+ className
106
+ )}
107
+ >
108
+ <div className="row-span-2">{renderTile(visible[0], 0)}</div>
109
+ {renderTile(visible[1], 1)}
110
+ {renderTile(visible[2], 2)}
111
+ </div>
112
+ )
113
+ }
114
+
115
+ // 4 tiles (or 4 visible with overflow on the last)
116
+ return (
117
+ <div
118
+ className={classNames(
119
+ 'grid aspect-[4/3] w-full grid-cols-2 grid-rows-2 gap-0.5',
120
+ className
121
+ )}
122
+ >
123
+ {visible.map((tile, index) => {
124
+ const isLastWithOverflow = overflow > 0 && index === visible.length - 1
125
+ return renderTile(
126
+ tile,
127
+ index,
128
+ isLastWithOverflow ? (
129
+ <div className="absolute inset-0 flex items-center justify-center bg-black/55 text-2xl font-semibold text-white">
130
+ +{overflow}
131
+ </div>
132
+ ) : null
133
+ )
134
+ })}
135
+ </div>
136
+ )
137
+ }
138
+
139
+ export default MediaStackGrid
@@ -0,0 +1,100 @@
1
+ import React, { useMemo } from 'react'
2
+
3
+ import DownloadAction from './DownloadAction'
4
+ import { filenameFromUrl } from './fileMeta'
5
+ import ViewerShell from './ViewerShell'
6
+
7
+ export interface PdfViewerProps {
8
+ open: boolean
9
+ /** Source URL of the PDF document. */
10
+ src: string
11
+ /** Filename used by the toolbar title and the download action. */
12
+ filename?: string
13
+ onClose: () => void
14
+ }
15
+
16
+ /**
17
+ * Modal PDF viewer that hosts the document inside a sandboxed
18
+ * `<iframe>`, leaving native PDF rendering, scroll, and zoom to the
19
+ * browser. Adds a thin top toolbar with the filename + a download
20
+ * action so users get the same `download / close` affordances as the
21
+ * `ImageViewer` and the inline audio / video players.
22
+ */
23
+ const PdfViewer: React.FC<PdfViewerProps> = ({
24
+ open,
25
+ src,
26
+ filename,
27
+ onClose,
28
+ }) => {
29
+ const resolvedFilename = useMemo(
30
+ () => filename ?? filenameFromUrl(src),
31
+ [filename, src]
32
+ )
33
+
34
+ // `#toolbar=0` is honored by Chromium-family browsers and hides the
35
+ // built-in PDF toolbar so our own toolbar reads as the single source
36
+ // of truth (otherwise the user sees two stacked toolbars). Firefox /
37
+ // Safari ignore it and keep their native toolbars, which is fine —
38
+ // the user gets a familiar experience either way.
39
+ //
40
+ // Preserve any existing fragment the caller supplied (e.g.
41
+ // `#page=3`) by merging our params into it rather than naively
42
+ // appending a second `#…` segment.
43
+ const iframeSrc = useMemo(() => withPdfViewerParams(src), [src])
44
+
45
+ return (
46
+ <ViewerShell
47
+ open={open}
48
+ onClose={onClose}
49
+ title={resolvedFilename}
50
+ actions={
51
+ <DownloadAction
52
+ url={src}
53
+ filename={resolvedFilename}
54
+ variant="overlay"
55
+ label={`Download ${resolvedFilename}`}
56
+ />
57
+ }
58
+ contentClassName="bg-[#1f1e1d]"
59
+ data-testid="pdf-viewer"
60
+ >
61
+ <iframe
62
+ src={iframeSrc}
63
+ title={resolvedFilename}
64
+ className="absolute inset-0 size-full bg-white"
65
+ // Sandbox the iframe to stop the embedded document from
66
+ // reaching parent context. We intentionally omit
67
+ // `allow-same-origin`: even when the PDF host happens to share
68
+ // our origin, granting it defeats the rest of the sandbox.
69
+ // `allow-scripts` keeps the native PDF viewer's interactive
70
+ // affordances (search, page nav, form filling) working,
71
+ // `allow-downloads` lets the user save via the built-in
72
+ // toolbar where it's still visible (Firefox / Safari), and
73
+ // `allow-popups` lets external links inside the PDF open in a
74
+ // new tab.
75
+ sandbox="allow-scripts allow-forms allow-popups allow-downloads"
76
+ />
77
+ </ViewerShell>
78
+ )
79
+ }
80
+
81
+ /**
82
+ * Merge our PDF-viewer params (`toolbar=0`, `navpanes=0`) into the
83
+ * URL's existing fragment so callers can still pass things like
84
+ * `#page=3` without us clobbering them — naively appending
85
+ * `#toolbar=0` to a URL that already has a fragment produces
86
+ * `…#page=3#toolbar=0` which most readers ignore.
87
+ */
88
+ const withPdfViewerParams = (src: string): string => {
89
+ const hashIndex = src.indexOf('#')
90
+ const base = hashIndex === -1 ? src : src.slice(0, hashIndex)
91
+ const existing = hashIndex === -1 ? '' : src.slice(hashIndex + 1)
92
+
93
+ const params = new URLSearchParams(existing)
94
+ if (!params.has('toolbar')) params.set('toolbar', '0')
95
+ if (!params.has('navpanes')) params.set('navpanes', '0')
96
+
97
+ return `${base}#${params.toString()}`
98
+ }
99
+
100
+ export default PdfViewer