@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.
- package/dist/{Card-CsJvUF_b.js → Card-BdTueeyk.js} +2 -2
- package/dist/{Card-CsJvUF_b.js.map → Card-BdTueeyk.js.map} +1 -1
- package/dist/{Card-DlMSDSdm.js → Card-ChR37pLZ.js} +2 -2
- package/dist/{Card-DlMSDSdm.js.map → Card-ChR37pLZ.js.map} +1 -1
- package/dist/{Card-CFFNq49v.js → Card-EKxCn56j.js} +3 -3
- package/dist/{Card-CFFNq49v.js.map → Card-EKxCn56j.js.map} +1 -1
- package/dist/{LockedThumbnail-DpJx169C.js → LockedThumbnail-B16qP3eH.js} +2 -2
- package/dist/{LockedThumbnail-DpJx169C.js.map → LockedThumbnail-B16qP3eH.js.map} +1 -1
- package/dist/index-Dn7BC9xK.js +4748 -0
- package/dist/index-Dn7BC9xK.js.map +1 -0
- package/dist/index.d.ts +591 -25
- package/dist/index.js +24 -19
- package/package.json +1 -1
- package/src/components/CustomMessage/MessageAttachmentConversations.stories.tsx +841 -0
- package/src/components/LinkAttachment/LinkAttachment.stories.tsx +7 -92
- package/src/components/LinkAttachment/LinkAttachment.test.tsx +69 -0
- package/src/components/LinkAttachment/components/Received/Card.tsx +10 -30
- package/src/components/LinkAttachment/components/_shared/CardShell.tsx +5 -1
- package/src/components/LinkAttachment/index.tsx +24 -50
- package/src/components/LinkAttachment/types.ts +12 -5
- package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +203 -0
- package/src/components/MessageAttachment/Audio/index.tsx +189 -0
- package/src/components/MessageAttachment/File/FileAttachment.stories.tsx +352 -0
- package/src/components/MessageAttachment/File/index.tsx +240 -0
- package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +288 -0
- package/src/components/MessageAttachment/Image/index.tsx +257 -0
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +783 -0
- package/src/components/MessageAttachment/Pdf/PdfAttachment.stories.tsx +292 -0
- package/src/components/MessageAttachment/Pdf/index.tsx +228 -0
- package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +272 -0
- package/src/components/MessageAttachment/Video/index.tsx +281 -0
- package/src/components/MessageAttachment/_shared/Bubble.tsx +173 -0
- package/src/components/MessageAttachment/_shared/CompactDocumentRow.tsx +152 -0
- package/src/components/MessageAttachment/_shared/DismissButton.tsx +39 -0
- package/src/components/MessageAttachment/_shared/DownloadAction.tsx +175 -0
- package/src/components/MessageAttachment/_shared/ImageViewer.tsx +314 -0
- package/src/components/MessageAttachment/_shared/MediaStackGrid.tsx +139 -0
- package/src/components/MessageAttachment/_shared/PdfViewer.tsx +100 -0
- package/src/components/MessageAttachment/_shared/VideoViewer.tsx +171 -0
- package/src/components/MessageAttachment/_shared/ViewerShell.tsx +159 -0
- package/src/components/MessageAttachment/_shared/fileMeta.test.ts +82 -0
- package/src/components/MessageAttachment/_shared/fileMeta.ts +95 -0
- package/src/components/MessageAttachment/_shared/triggerDownload.ts +54 -0
- package/src/components/MessageAttachment/_shared/useViewer.ts +53 -0
- package/src/components/MessageAttachment/index.tsx +149 -0
- package/src/components/MessageAttachment/stories/StoryTable.tsx +72 -0
- package/src/components/MessageAttachment/types.ts +178 -0
- package/src/index.ts +32 -0
- package/dist/Card-D32U6KfZ.js +0 -85
- package/dist/Card-D32U6KfZ.js.map +0 -1
- package/dist/Card-DlSSJPip.js +0 -60
- package/dist/Card-DlSSJPip.js.map +0 -1
- package/dist/Card-zGbhRBwv.js +0 -48
- package/dist/Card-zGbhRBwv.js.map +0 -1
- package/dist/CardThumbnail-DTBuRQHF.js +0 -239
- package/dist/CardThumbnail-DTBuRQHF.js.map +0 -1
- package/dist/index-DfcRe-Hj.js +0 -3103
- 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
|