@linktr.ee/messaging-react 2.2.3 → 2.3.0-rc-1779486690
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-DOws_Rs6.js → Card-4takoN_-.js} +2 -2
- package/dist/{Card-DOws_Rs6.js.map → Card-4takoN_-.js.map} +1 -1
- package/dist/{Card-Ccy5fPho.js → Card-BuROm0u7.js} +2 -2
- package/dist/{Card-Ccy5fPho.js.map → Card-BuROm0u7.js.map} +1 -1
- package/dist/{Card-DWH_vCQD.cjs → Card-CexShqpK.cjs} +2 -2
- package/dist/{Card-DWH_vCQD.cjs.map → Card-CexShqpK.cjs.map} +1 -1
- package/dist/{Card-CyszwESe.cjs → Card-CgpHBx-W.cjs} +2 -2
- package/dist/{Card-CyszwESe.cjs.map → Card-CgpHBx-W.cjs.map} +1 -1
- package/dist/{Card-BRub_HpW.js → Card-DdpdnSh_.js} +3 -3
- package/dist/{Card-BRub_HpW.js.map → Card-DdpdnSh_.js.map} +1 -1
- package/dist/{Card-imQIyJzJ.cjs → Card-ot16XqS2.cjs} +2 -2
- package/dist/{Card-imQIyJzJ.cjs.map → Card-ot16XqS2.cjs.map} +1 -1
- package/dist/{LockedThumbnail-D512VE6T.cjs → LockedThumbnail-CydtYOSA.cjs} +2 -2
- package/dist/{LockedThumbnail-D512VE6T.cjs.map → LockedThumbnail-CydtYOSA.cjs.map} +1 -1
- package/dist/{LockedThumbnail-C9eocsRT.js → LockedThumbnail-Drsh4B5o.js} +2 -2
- package/dist/{LockedThumbnail-C9eocsRT.js.map → LockedThumbnail-Drsh4B5o.js.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/{index-uxWUZe1M.js → index-BCbVXFHI.js} +1648 -1740
- package/dist/index-BCbVXFHI.js.map +1 -0
- package/dist/index-CQ913euH.cjs +2 -0
- package/dist/index-CQ913euH.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +22 -13
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/components/MessageAttachment/Image/ImageAttachment.stories.tsx +8 -5
- package/src/components/MessageAttachment/Image/index.tsx +7 -1
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +200 -19
- package/src/components/MessageAttachment/Pdf/index.tsx +14 -15
- package/src/components/MessageAttachment/Video/VideoAttachment.stories.tsx +2 -2
- package/src/components/MessageAttachment/Video/index.tsx +11 -2
- package/src/components/MessageAttachment/_shared/CarouselNav.tsx +47 -0
- package/src/components/MessageAttachment/_shared/DownloadAction.tsx +27 -27
- package/src/components/MessageAttachment/_shared/ImageViewer.tsx +59 -261
- package/src/components/MessageAttachment/_shared/PdfViewer.tsx +56 -30
- package/src/components/MessageAttachment/_shared/VideoViewer.tsx +53 -109
- package/src/components/MessageAttachment/_shared/ViewerShell.tsx +127 -107
- package/src/components/MessageAttachment/_shared/useCarousel.ts +103 -0
- package/src/components/MessageAttachment/index.tsx +18 -9
- package/src/components/MessageAttachment/types.ts +1 -1
- package/src/styles.css +158 -1
- package/dist/index-IlgylJT2.cjs +0 -2
- package/dist/index-IlgylJT2.cjs.map +0 -1
- package/dist/index-uxWUZe1M.js.map +0 -1
|
@@ -11,16 +11,20 @@ import { triggerDownload } from './triggerDownload'
|
|
|
11
11
|
export type DownloadActionVariant =
|
|
12
12
|
/** Solid pill button used inside compact / file rows. */
|
|
13
13
|
| 'pill'
|
|
14
|
-
/** Round translucent overlay button used over media (image / video viewers). */
|
|
15
|
-
| 'overlay'
|
|
16
|
-
/** Flat icon button used in viewer toolbars. */
|
|
17
|
-
| 'toolbar'
|
|
18
14
|
/**
|
|
19
15
|
* Compact round icon button sized for the trailing slot of a
|
|
20
16
|
* `CompactDocumentRow` (PDF / File rows). Adopts the row's tone so
|
|
21
17
|
* it sits inline next to the filename without competing with it.
|
|
22
18
|
*/
|
|
23
19
|
| 'inline'
|
|
20
|
+
/**
|
|
21
|
+
* Round icon button sized to match the close action in the
|
|
22
|
+
* `ViewerShell` chrome — used by `ImageViewer` for in-viewer
|
|
23
|
+
* downloads. Styled with plain CSS (`.mes-media-viewer__action`) so
|
|
24
|
+
* it renders correctly without a Tailwind dependency in the
|
|
25
|
+
* consumer's app.
|
|
26
|
+
*/
|
|
27
|
+
| 'viewer'
|
|
24
28
|
|
|
25
29
|
export interface DownloadActionProps {
|
|
26
30
|
url: string
|
|
@@ -28,7 +32,8 @@ export interface DownloadActionProps {
|
|
|
28
32
|
variant?: DownloadActionVariant
|
|
29
33
|
/**
|
|
30
34
|
* Override the visible label on `pill` variants. Defaults to
|
|
31
|
-
* `'Download'`.
|
|
35
|
+
* `'Download'`. On `inline` / `viewer` variants the label is
|
|
36
|
+
* hidden visually and surfaced as the button's `aria-label`.
|
|
32
37
|
*/
|
|
33
38
|
label?: string
|
|
34
39
|
/** Hide the label, keeping just the icon. Defaults to `true` for non-pill variants. */
|
|
@@ -117,57 +122,52 @@ const DownloadAction: React.FC<DownloadActionProps> = ({
|
|
|
117
122
|
)
|
|
118
123
|
}
|
|
119
124
|
|
|
120
|
-
if (variant === '
|
|
125
|
+
if (variant === 'viewer') {
|
|
126
|
+
// Styled entirely through `.mes-media-viewer__action` (plain CSS
|
|
127
|
+
// in `styles.css`) so the button renders correctly when consumed
|
|
128
|
+
// outside this repo without a Tailwind setup. Sits next to the
|
|
129
|
+
// close action inside `ViewerShell`'s top-right chrome.
|
|
121
130
|
return (
|
|
122
131
|
<button
|
|
123
132
|
type="button"
|
|
124
133
|
onClick={handleClick}
|
|
125
134
|
disabled={busy}
|
|
126
|
-
aria-label={
|
|
127
|
-
className=
|
|
128
|
-
'mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full px-4 text-sm font-medium leading-none transition-colors disabled:opacity-70',
|
|
129
|
-
tone === 'dark'
|
|
130
|
-
? 'bg-[#121110] text-white hover:bg-[#2a2928]'
|
|
131
|
-
: 'bg-white text-[#121110] hover:bg-white/90'
|
|
132
|
-
)}
|
|
135
|
+
aria-label={label}
|
|
136
|
+
className="mes-media-viewer__action"
|
|
133
137
|
>
|
|
134
138
|
{busy ? (
|
|
135
|
-
<CircleNotchIcon
|
|
136
|
-
className="size-4 animate-spin"
|
|
137
|
-
weight="bold"
|
|
138
|
-
aria-hidden
|
|
139
|
-
/>
|
|
139
|
+
<CircleNotchIcon size={20} weight="bold" aria-hidden />
|
|
140
140
|
) : (
|
|
141
|
-
<DownloadSimpleIcon {
|
|
141
|
+
<DownloadSimpleIcon size={20} weight="bold" aria-hidden />
|
|
142
142
|
)}
|
|
143
|
-
{showIconOnly ? null : label}
|
|
144
143
|
</button>
|
|
145
144
|
)
|
|
146
145
|
}
|
|
147
146
|
|
|
148
|
-
//
|
|
147
|
+
// pill — only remaining variant
|
|
149
148
|
return (
|
|
150
149
|
<button
|
|
151
150
|
type="button"
|
|
152
151
|
onClick={handleClick}
|
|
153
152
|
disabled={busy}
|
|
154
|
-
aria-label={label}
|
|
153
|
+
aria-label={showIconOnly ? label : undefined}
|
|
155
154
|
className={classNames(
|
|
156
|
-
'flex
|
|
157
|
-
|
|
158
|
-
? 'bg-
|
|
159
|
-
: 'bg-white
|
|
155
|
+
'mt-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-full px-4 text-sm font-medium leading-none transition-colors disabled:opacity-70',
|
|
156
|
+
tone === 'dark'
|
|
157
|
+
? 'bg-[#121110] text-white hover:bg-[#2a2928]'
|
|
158
|
+
: 'bg-white text-[#121110] hover:bg-white/90'
|
|
160
159
|
)}
|
|
161
160
|
>
|
|
162
161
|
{busy ? (
|
|
163
162
|
<CircleNotchIcon
|
|
164
|
-
className="size-
|
|
163
|
+
className="size-4 animate-spin"
|
|
165
164
|
weight="bold"
|
|
166
165
|
aria-hidden
|
|
167
166
|
/>
|
|
168
167
|
) : (
|
|
169
168
|
<DownloadSimpleIcon {...iconProps} aria-hidden />
|
|
170
169
|
)}
|
|
170
|
+
{showIconOnly ? null : label}
|
|
171
171
|
</button>
|
|
172
172
|
)
|
|
173
173
|
}
|
|
@@ -1,28 +1,18 @@
|
|
|
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'
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
15
2
|
|
|
3
|
+
import CarouselNav from './CarouselNav'
|
|
16
4
|
import DownloadAction from './DownloadAction'
|
|
17
5
|
import { filenameFromUrl } from './fileMeta'
|
|
6
|
+
import { useCarousel } from './useCarousel'
|
|
18
7
|
import ViewerShell from './ViewerShell'
|
|
19
8
|
|
|
20
9
|
export interface ImageViewerItem {
|
|
21
10
|
src: string
|
|
22
11
|
alt?: string
|
|
23
12
|
/**
|
|
24
|
-
* Filename used by the download action
|
|
25
|
-
* pathname segment of
|
|
13
|
+
* Filename used by the download action and as the dialog's
|
|
14
|
+
* accessible name. Falls back to the last pathname segment of
|
|
15
|
+
* `src` when omitted.
|
|
26
16
|
*/
|
|
27
17
|
filename?: string
|
|
28
18
|
}
|
|
@@ -30,34 +20,27 @@ export interface ImageViewerItem {
|
|
|
30
20
|
export interface ImageViewerProps {
|
|
31
21
|
open: boolean
|
|
32
22
|
items: ImageViewerItem[]
|
|
33
|
-
/**
|
|
23
|
+
/** Index to display when the viewer first opens. Defaults to `0`. */
|
|
34
24
|
initialIndex?: number
|
|
35
25
|
onClose: () => void
|
|
36
26
|
}
|
|
37
27
|
|
|
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
28
|
/**
|
|
54
|
-
* Native lightbox-style image viewer
|
|
55
|
-
*
|
|
56
|
-
*
|
|
29
|
+
* Native lightbox-style image viewer — full-viewport `<dialog>` with
|
|
30
|
+
* the image centered at reasonable max dimensions, a single close
|
|
31
|
+
* button, and a download action in the chrome.
|
|
32
|
+
*
|
|
33
|
+
* When `items.length > 1` the viewer becomes a carousel:
|
|
57
34
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
35
|
+
* - Prev / next on-screen controls hug the viewport sides.
|
|
36
|
+
* - `ArrowLeft` / `ArrowRight` step between siblings (the keyboard
|
|
37
|
+
* listener is parked while a `<video>` / `<audio>` element is
|
|
38
|
+
* focused so native media seek still works in mixed contexts).
|
|
39
|
+
* - A `current / total` counter sits in the top-left chrome.
|
|
40
|
+
*
|
|
41
|
+
* Used by every `MessageAttachment.Image.*` variant
|
|
42
|
+
* (Composer / Sent / Received) so admins can preview attachments
|
|
43
|
+
* before sending and recipients can preview after receipt.
|
|
61
44
|
*/
|
|
62
45
|
const ImageViewer: React.FC<ImageViewerProps> = ({
|
|
63
46
|
open,
|
|
@@ -65,247 +48,62 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
|
|
|
65
48
|
initialIndex = 0,
|
|
66
49
|
onClose,
|
|
67
50
|
}) => {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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])
|
|
51
|
+
const { index, prev, next } = useCarousel({
|
|
52
|
+
length: items.length,
|
|
53
|
+
initialIndex,
|
|
54
|
+
open,
|
|
55
|
+
})
|
|
193
56
|
|
|
194
57
|
const item = items[index]
|
|
195
58
|
const filename = useMemo(
|
|
196
|
-
() =>
|
|
197
|
-
item?.filename ?? (item ? filenameFromUrl(item.src) : 'image'),
|
|
59
|
+
() => item?.filename ?? (item ? filenameFromUrl(item.src) : 'image'),
|
|
198
60
|
[item]
|
|
199
61
|
)
|
|
200
62
|
|
|
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
63
|
if (!item) return null
|
|
207
64
|
|
|
208
|
-
const totalLabel = items.length > 1 ? ` (${index + 1} / ${items.length})` : ''
|
|
209
|
-
|
|
210
65
|
return (
|
|
211
66
|
<ViewerShell
|
|
212
67
|
open={open}
|
|
213
68
|
onClose={onClose}
|
|
214
|
-
|
|
69
|
+
ariaLabel={filename}
|
|
70
|
+
counter={
|
|
71
|
+
items.length > 1 ? `${index + 1} / ${items.length}` : undefined
|
|
72
|
+
}
|
|
215
73
|
actions={
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
</>
|
|
74
|
+
<DownloadAction
|
|
75
|
+
url={item.src}
|
|
76
|
+
filename={filename}
|
|
77
|
+
variant="viewer"
|
|
78
|
+
label={`Download ${filename}`}
|
|
79
|
+
/>
|
|
250
80
|
}
|
|
251
81
|
data-testid="image-viewer"
|
|
252
82
|
>
|
|
253
|
-
<
|
|
254
|
-
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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>
|
|
83
|
+
<img
|
|
84
|
+
// Forcing a key swap on item change ensures React replaces the
|
|
85
|
+
// `<img>` cleanly between siblings — otherwise the previous
|
|
86
|
+
// image stays painted for a frame while the new `src`
|
|
87
|
+
// decodes, which reads as a stutter at carousel pace.
|
|
88
|
+
key={`${index}:${item.src}`}
|
|
89
|
+
src={item.src}
|
|
90
|
+
alt={item.alt ?? filename}
|
|
91
|
+
draggable={false}
|
|
92
|
+
// The user has explicitly opened the viewer, so we want the
|
|
93
|
+
// active image to appear immediately rather than fall to the
|
|
94
|
+
// browser's lazy-load heuristics.
|
|
95
|
+
loading="eager"
|
|
96
|
+
decoding="async"
|
|
97
|
+
className="mes-media-viewer__image"
|
|
98
|
+
/>
|
|
289
99
|
|
|
290
100
|
{items.length > 1 ? (
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
</>
|
|
101
|
+
<CarouselNav
|
|
102
|
+
onPrev={prev}
|
|
103
|
+
onNext={next}
|
|
104
|
+
prevLabel="Previous image"
|
|
105
|
+
nextLabel="Next image"
|
|
106
|
+
/>
|
|
309
107
|
) : null}
|
|
310
108
|
</ViewerShell>
|
|
311
109
|
)
|
|
@@ -1,67 +1,84 @@
|
|
|
1
1
|
import React, { useMemo } from 'react'
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import CarouselNav from './CarouselNav'
|
|
4
4
|
import { filenameFromUrl } from './fileMeta'
|
|
5
|
+
import { useCarousel } from './useCarousel'
|
|
5
6
|
import ViewerShell from './ViewerShell'
|
|
6
7
|
|
|
7
|
-
export interface
|
|
8
|
-
open: boolean
|
|
8
|
+
export interface PdfViewerItem {
|
|
9
9
|
/** Source URL of the PDF document. */
|
|
10
10
|
src: string
|
|
11
|
-
/** Filename used
|
|
11
|
+
/** Filename used as the dialog's accessible name. */
|
|
12
12
|
filename?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PdfViewerProps {
|
|
16
|
+
open: boolean
|
|
17
|
+
items: PdfViewerItem[]
|
|
18
|
+
/** Index to display when the viewer first opens. Defaults to `0`. */
|
|
19
|
+
initialIndex?: number
|
|
13
20
|
onClose: () => void
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
/**
|
|
17
|
-
* Modal PDF viewer that hosts the document inside a sandboxed
|
|
18
|
-
* `<iframe>`, leaving native
|
|
19
|
-
* browser
|
|
20
|
-
*
|
|
21
|
-
*
|
|
24
|
+
* Modal PDF viewer that hosts the active document inside a sandboxed
|
|
25
|
+
* `<iframe>`, leaving native rendering, scroll, search, and the
|
|
26
|
+
* browser's built-in download controls to the browser. The lightbox
|
|
27
|
+
* chrome is intentionally just a close button plus, for stacked
|
|
28
|
+
* attachments, carousel prev/next + counter.
|
|
22
29
|
*/
|
|
23
30
|
const PdfViewer: React.FC<PdfViewerProps> = ({
|
|
24
31
|
open,
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
items,
|
|
33
|
+
initialIndex = 0,
|
|
27
34
|
onClose,
|
|
28
35
|
}) => {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
const { index, prev, next } = useCarousel({
|
|
37
|
+
length: items.length,
|
|
38
|
+
initialIndex,
|
|
39
|
+
open,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const item = items[index]
|
|
43
|
+
const filename = useMemo(
|
|
44
|
+
() => item?.filename ?? (item ? filenameFromUrl(item.src) : 'document'),
|
|
45
|
+
[item]
|
|
32
46
|
)
|
|
33
47
|
|
|
34
48
|
// `#toolbar=0` is honored by Chromium-family browsers and hides the
|
|
35
|
-
// built-in PDF toolbar
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
// the user gets a familiar experience either way.
|
|
49
|
+
// built-in PDF toolbar's redundant controls. Firefox / Safari ignore
|
|
50
|
+
// it and keep their native toolbars, which is fine — the user gets a
|
|
51
|
+
// familiar experience either way.
|
|
39
52
|
//
|
|
40
53
|
// Preserve any existing fragment the caller supplied (e.g.
|
|
41
54
|
// `#page=3`) by merging our params into it rather than naively
|
|
42
55
|
// appending a second `#…` segment.
|
|
43
|
-
const iframeSrc = useMemo(
|
|
56
|
+
const iframeSrc = useMemo(
|
|
57
|
+
() => (item ? withPdfViewerParams(item.src) : undefined),
|
|
58
|
+
[item]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if (!item || !iframeSrc) return null
|
|
44
62
|
|
|
45
63
|
return (
|
|
46
64
|
<ViewerShell
|
|
47
65
|
open={open}
|
|
48
66
|
onClose={onClose}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
url={src}
|
|
53
|
-
filename={resolvedFilename}
|
|
54
|
-
variant="overlay"
|
|
55
|
-
label={`Download ${resolvedFilename}`}
|
|
56
|
-
/>
|
|
67
|
+
ariaLabel={filename}
|
|
68
|
+
counter={
|
|
69
|
+
items.length > 1 ? `${index + 1} / ${items.length}` : undefined
|
|
57
70
|
}
|
|
58
|
-
contentClassName="bg-[#1f1e1d]"
|
|
59
71
|
data-testid="pdf-viewer"
|
|
60
72
|
>
|
|
61
73
|
<iframe
|
|
74
|
+
// Force a key swap on item change so the iframe remounts a
|
|
75
|
+
// fresh document rather than re-using the previous scroll
|
|
76
|
+
// position / search state when the user navigates between
|
|
77
|
+
// siblings.
|
|
78
|
+
key={`${index}:${item.src}`}
|
|
62
79
|
src={iframeSrc}
|
|
63
|
-
title={
|
|
64
|
-
className="
|
|
80
|
+
title={filename}
|
|
81
|
+
className="mes-media-viewer__iframe"
|
|
65
82
|
// Sandbox the iframe to stop the embedded document from
|
|
66
83
|
// reaching parent context. We intentionally omit
|
|
67
84
|
// `allow-same-origin`: even when the PDF host happens to share
|
|
@@ -74,6 +91,15 @@ const PdfViewer: React.FC<PdfViewerProps> = ({
|
|
|
74
91
|
// new tab.
|
|
75
92
|
sandbox="allow-scripts allow-forms allow-popups allow-downloads"
|
|
76
93
|
/>
|
|
94
|
+
|
|
95
|
+
{items.length > 1 ? (
|
|
96
|
+
<CarouselNav
|
|
97
|
+
onPrev={prev}
|
|
98
|
+
onNext={next}
|
|
99
|
+
prevLabel="Previous document"
|
|
100
|
+
nextLabel="Next document"
|
|
101
|
+
/>
|
|
102
|
+
) : null}
|
|
77
103
|
</ViewerShell>
|
|
78
104
|
)
|
|
79
105
|
}
|