@boyernick/standard-ui-react 0.4.2-canary.67 → 0.4.2-canary.69
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/package.json +1 -1
- package/src/icons.tsx +6 -0
- package/src/index.ts +2 -0
- package/src/lib/image-zoom.ts +86 -0
- package/src/lib/lens-glass/config.ts +6 -5
- package/src/lib/lens-glass/shader.ts +13 -3
- package/src/modal.tsx +174 -10
package/package.json
CHANGED
package/src/icons.tsx
CHANGED
|
@@ -39,6 +39,8 @@ import { IconSun as IconSunBase } from "@central-icons-react/round-outlined-radi
|
|
|
39
39
|
import { IconVolumeFull as IconVolumeFullBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconVolumeFull"
|
|
40
40
|
import { IconVolumeOff as IconVolumeOffBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconVolumeOff"
|
|
41
41
|
import { IconX as IconXBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconX"
|
|
42
|
+
import { IconZoomIn as IconZoomInBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconZoomIn"
|
|
43
|
+
import { IconZoomOut as IconZoomOutBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconZoomOut"
|
|
42
44
|
import type { ComponentType } from "react"
|
|
43
45
|
|
|
44
46
|
/** Round outlined Central Icons: 2px radius, 2px stroke, default 20px. */
|
|
@@ -140,11 +142,15 @@ export const IconSun = withCentralIconDefaults(IconSunBase)
|
|
|
140
142
|
export const IconVolumeFull = withCentralIconDefaults(IconVolumeFullBase)
|
|
141
143
|
export const IconVolumeOff = withCentralIconDefaults(IconVolumeOffBase)
|
|
142
144
|
export const IconX = withCentralIconDefaults(IconXBase)
|
|
145
|
+
export const IconZoomIn = withCentralIconDefaults(IconZoomInBase)
|
|
146
|
+
export const IconZoomOut = withCentralIconDefaults(IconZoomOutBase)
|
|
143
147
|
|
|
144
148
|
export const iconGallery = [
|
|
145
149
|
{ name: "IconHome", Icon: IconHome },
|
|
146
150
|
{ name: "IconCalendar1", Icon: IconCalendar1 },
|
|
147
151
|
{ name: "IconMagnifyingGlass", Icon: IconMagnifyingGlass },
|
|
152
|
+
{ name: "IconZoomIn", Icon: IconZoomIn },
|
|
153
|
+
{ name: "IconZoomOut", Icon: IconZoomOut },
|
|
148
154
|
{ name: "IconSettingsGear1", Icon: IconSettingsGear1 },
|
|
149
155
|
{ name: "IconBell", Icon: IconBell },
|
|
150
156
|
{ name: "IconPeople", Icon: IconPeople },
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Magnify an image in place and pan it under the pointer.
|
|
3
|
+
*
|
|
4
|
+
* The image scales from its top-left corner and moves with `translate`, so
|
|
5
|
+
* the zoom can ease while panning stays instant. Crossing the frame maps to
|
|
6
|
+
* the image's whole travel: the pointer reaches every edge at any size, and
|
|
7
|
+
* the image never parks with an edge out of reach.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const IMAGE_ZOOM = 2
|
|
11
|
+
|
|
12
|
+
export interface ZoomAxis {
|
|
13
|
+
/** Pointer position, in client pixels. */
|
|
14
|
+
cursor: number
|
|
15
|
+
/** Start and size of the frame the image is seen through. */
|
|
16
|
+
frameStart: number
|
|
17
|
+
frameSize: number
|
|
18
|
+
/** Where the unmoved image starts, in client pixels. */
|
|
19
|
+
layoutStart: number
|
|
20
|
+
/** The magnified image's size, in client pixels. */
|
|
21
|
+
rendered: number
|
|
22
|
+
/** Client pixels per image pixel from ancestors (an opening dialog's scale). */
|
|
23
|
+
ancestor: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The translate, in the image's own pixels, for one axis. */
|
|
27
|
+
export function zoomTranslate({
|
|
28
|
+
cursor,
|
|
29
|
+
frameStart,
|
|
30
|
+
frameSize,
|
|
31
|
+
layoutStart,
|
|
32
|
+
rendered,
|
|
33
|
+
ancestor,
|
|
34
|
+
}: ZoomAxis) {
|
|
35
|
+
const unit = ancestor || 1
|
|
36
|
+
// Smaller than the frame: centre it, nothing to pan.
|
|
37
|
+
if (rendered <= frameSize) {
|
|
38
|
+
return (frameStart + frameSize / 2 - layoutStart - rendered / 2) / unit
|
|
39
|
+
}
|
|
40
|
+
const atStart = (frameStart - layoutStart) / unit
|
|
41
|
+
const atEnd = (frameStart + frameSize - rendered - layoutStart) / unit
|
|
42
|
+
const progress = Math.max(0, Math.min(1, (cursor - frameStart) / frameSize))
|
|
43
|
+
return atStart + (atEnd - atStart) * progress
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The presented scale and translate, including an in-flight transition. */
|
|
47
|
+
function presented(image: HTMLElement) {
|
|
48
|
+
const style = getComputedStyle(image)
|
|
49
|
+
const scale = Number.parseFloat(style.scale) || 1
|
|
50
|
+
const [x = 0, y = 0] = (style.translate || "").split(" ").map(Number.parseFloat)
|
|
51
|
+
return { scale, x: Number.isFinite(x) ? x : 0, y: Number.isFinite(y) ? y : 0 }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Move a magnified image so the point under the pointer comes into view. */
|
|
55
|
+
export function panZoomedImage(
|
|
56
|
+
image: HTMLElement,
|
|
57
|
+
frame: DOMRect,
|
|
58
|
+
clientX: number,
|
|
59
|
+
clientY: number,
|
|
60
|
+
scale = IMAGE_ZOOM,
|
|
61
|
+
) {
|
|
62
|
+
const width = image.offsetWidth
|
|
63
|
+
const height = image.offsetHeight
|
|
64
|
+
if (!width || !height) return
|
|
65
|
+
|
|
66
|
+
const applied = presented(image)
|
|
67
|
+
const rect = image.getBoundingClientRect()
|
|
68
|
+
const ancestor = rect.width / (applied.scale * width) || 1
|
|
69
|
+
const x = zoomTranslate({
|
|
70
|
+
cursor: clientX,
|
|
71
|
+
frameStart: frame.left,
|
|
72
|
+
frameSize: frame.width,
|
|
73
|
+
layoutStart: rect.left - ancestor * applied.x,
|
|
74
|
+
rendered: ancestor * scale * width,
|
|
75
|
+
ancestor,
|
|
76
|
+
})
|
|
77
|
+
const y = zoomTranslate({
|
|
78
|
+
cursor: clientY,
|
|
79
|
+
frameStart: frame.top,
|
|
80
|
+
frameSize: frame.height,
|
|
81
|
+
layoutStart: rect.top - ancestor * applied.y,
|
|
82
|
+
rendered: ancestor * scale * height,
|
|
83
|
+
ancestor,
|
|
84
|
+
})
|
|
85
|
+
image.style.translate = `${x}px ${y}px`
|
|
86
|
+
}
|
|
@@ -19,10 +19,11 @@ export interface GlassConfig {
|
|
|
19
19
|
/** Rim light width. Default 3.5, less on small panes. */
|
|
20
20
|
rimWidth?: number;
|
|
21
21
|
/**
|
|
22
|
-
* A hairline shade just inside the outline
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* A hairline shade just inside the outline. On a light page the rim light
|
|
23
|
+
* has nothing to brighten, so without it a pane's only edge is its soft
|
|
24
|
+
* shadow. Any edge also turns the border into an angled gradient: rim and
|
|
25
|
+
* hairline are strongest at the top-left and bottom-right corners and fade
|
|
26
|
+
* toward the other two. Default 0.22 over a light ground, 0 over a dark one.
|
|
26
27
|
*/
|
|
27
28
|
edge?: number;
|
|
28
29
|
/** Specular sheen on the lit side of the bezel. Default 0.35. */
|
|
@@ -152,7 +153,7 @@ export function resolveGlass(
|
|
|
152
153
|
dispersionBlue: pick(config.dispersionBlue, 0.8, 0, 1),
|
|
153
154
|
rim: pick(config.rim, 0.5, 0, 2),
|
|
154
155
|
rimWidth: pick(config.rimWidth, Math.min(3.5, Math.max(1.25, short * 0.04)), 0.5, 12),
|
|
155
|
-
edge: pick(config.edge, options?.lightGround ? 0.
|
|
156
|
+
edge: pick(config.edge, options?.lightGround ? 0.22 : 0, 0, 1),
|
|
156
157
|
specular: pick(config.specular, 0.35, 0, 2),
|
|
157
158
|
frost: pick(config.frost, 3, 0, 16),
|
|
158
159
|
grain: pick(config.grain, 0.07, 0, 0.5),
|
|
@@ -118,18 +118,28 @@ void main() {
|
|
|
118
118
|
);
|
|
119
119
|
color *= u_dim + 0.06 * u_hover;
|
|
120
120
|
|
|
121
|
+
// Where the edge sits on the diagonal through the lit corner: 1 at the top
|
|
122
|
+
// left and bottom right, 0 at the other two corners. Over a light ground the
|
|
123
|
+
// rim and hairline follow it, so the border runs as an angled gradient
|
|
124
|
+
// rather than an even ring.
|
|
125
|
+
float diagonal = dot(n, normalize(vec2(-1.0, -1.0)));
|
|
126
|
+
float angled = pow(abs(diagonal), 1.5);
|
|
127
|
+
float lightGround = step(0.001, u_edge);
|
|
128
|
+
|
|
121
129
|
// Rim light, strongest where the edge faces the light (top left), and a
|
|
122
130
|
// sheen across the lit side of the bezel.
|
|
123
131
|
vec2 light = normalize(vec2(-0.55, -1.0));
|
|
124
132
|
float facing = max(dot(n, light), 0.0);
|
|
125
133
|
float rim = 1.0 - smoothstep(0.0, u_rimWidth, inside);
|
|
126
|
-
|
|
134
|
+
float rimShape = mix(0.55 + 0.45 * facing, 0.1 + 0.9 * angled * (diagonal > 0.0 ? 1.0 : 0.7), lightGround);
|
|
135
|
+
color += u_rim * (1.0 + 0.4 * u_hover) * rim * rimShape;
|
|
127
136
|
color += u_specular * 0.35 * pow(t, 3.0) * facing;
|
|
128
137
|
|
|
129
138
|
// Hairline edge: a light ground clips the rim to white, so shade one CSS
|
|
130
|
-
// pixel inside the outline
|
|
139
|
+
// pixel inside the outline too, deepest at the bottom right.
|
|
131
140
|
float hairline = 1.0 - smoothstep(0.0, 1.0, inside);
|
|
132
|
-
|
|
141
|
+
float shade = (0.2 + 0.8 * angled) * (diagonal > 0.0 ? 0.65 : 1.0);
|
|
142
|
+
color = min(color, vec3(1.0)) * (1.0 - u_edge * hairline * shade);
|
|
133
143
|
|
|
134
144
|
// Grain, fixed to the device pixel grid so a still pane never needs redrawing.
|
|
135
145
|
color += (hash(floor(scenePx * u_dpr)) - 0.5) * u_grain;
|
package/src/modal.tsx
CHANGED
|
@@ -36,9 +36,12 @@ import {
|
|
|
36
36
|
IconArrowDownSmall,
|
|
37
37
|
IconChevronRightSmall,
|
|
38
38
|
IconCrossSmall,
|
|
39
|
+
IconZoomIn,
|
|
40
|
+
IconZoomOut,
|
|
39
41
|
} from "./icons"
|
|
40
42
|
import { cn } from "./lib/cn"
|
|
41
43
|
import { focusRing, focusRingBorder } from "./lib/focus"
|
|
44
|
+
import { IMAGE_ZOOM, panZoomedImage } from "./lib/image-zoom"
|
|
42
45
|
import { motion } from "./lib/motion"
|
|
43
46
|
import {
|
|
44
47
|
Tooltip,
|
|
@@ -86,6 +89,11 @@ export type ModalContentProps = Omit<
|
|
|
86
89
|
downloadName?: string
|
|
87
90
|
imgProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt">
|
|
88
91
|
children?: ReactNode
|
|
92
|
+
/**
|
|
93
|
+
* Magnification when the image is clicked or the zoom control pressed.
|
|
94
|
+
* `false` turns zoom off. Default 2.
|
|
95
|
+
*/
|
|
96
|
+
zoom?: number | false
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
export type GalleryItem = {
|
|
@@ -176,18 +184,24 @@ const ModalImage = ({
|
|
|
176
184
|
alt,
|
|
177
185
|
imgProps,
|
|
178
186
|
fit = "viewport",
|
|
187
|
+
imageRef,
|
|
188
|
+
zoom,
|
|
179
189
|
}: Pick<GalleryItem, "src" | "alt" | "imgProps"> & {
|
|
180
190
|
fit?: "viewport" | "contain"
|
|
191
|
+
imageRef?: { current: HTMLImageElement | null }
|
|
192
|
+
/** Zoom state and toggle, for an image that magnifies in place. */
|
|
193
|
+
zoom?: { scale: number; zoomed: boolean; toggle: (x: number, y: number) => void }
|
|
181
194
|
}) => {
|
|
182
|
-
const { className, onLoad, onError, ...props } = imgProps ?? {}
|
|
195
|
+
const { className, onLoad, onError, onClick, style, ...props } = imgProps ?? {}
|
|
183
196
|
// Held transparent until decoded, then faded in: a large file otherwise
|
|
184
197
|
// paints in top-down bands inside the lightbox. The ref catches an image the
|
|
185
198
|
// browser already has (a cached file, or the one the trigger just showed)
|
|
186
199
|
// before first paint, so it appears at once with nothing to fade.
|
|
187
200
|
const [loaded, setLoaded] = useState(false)
|
|
188
201
|
const catchLoaded = useCallback((image: HTMLImageElement | null) => {
|
|
202
|
+
if (imageRef) imageRef.current = image
|
|
189
203
|
if (image?.complete && image.naturalWidth > 0) setLoaded(true)
|
|
190
|
-
}, [])
|
|
204
|
+
}, [imageRef])
|
|
191
205
|
|
|
192
206
|
return (
|
|
193
207
|
// eslint-disable-next-line @next/next/no-img-element
|
|
@@ -208,16 +222,29 @@ const ModalImage = ({
|
|
|
208
222
|
// Reveal the broken image rather than leave an invisible one.
|
|
209
223
|
setLoaded(true)
|
|
210
224
|
}}
|
|
225
|
+
onClick={(event) => {
|
|
226
|
+
onClick?.(event)
|
|
227
|
+
if (!event.defaultPrevented) zoom?.toggle(event.clientX, event.clientY)
|
|
228
|
+
}}
|
|
211
229
|
className={cn(
|
|
212
|
-
// Linear, like every fade in `motion` — see the note there.
|
|
213
|
-
|
|
230
|
+
// Linear, like every fade in `motion` — see the note there. The zoom
|
|
231
|
+
// eases scale and translate; while the pointer pans, translate
|
|
232
|
+
// follows it without easing.
|
|
233
|
+
zoom
|
|
234
|
+
? "motion-safe:[transition:opacity_var(--duration-lg)_linear,scale_var(--duration-md)_var(--ease-move),translate_var(--duration-md)_var(--ease-move)] motion-safe:data-[panning]:[transition-property:opacity,scale]"
|
|
235
|
+
: "transition-opacity duration-[var(--duration-lg)] ease-linear motion-reduce:transition-none",
|
|
214
236
|
!loaded && "opacity-0",
|
|
215
237
|
"rounded-lg object-contain shadow-lg select-none",
|
|
238
|
+
// Scaled from the top-left, so the pan lands the image exactly where
|
|
239
|
+
// it is measured.
|
|
240
|
+
zoom && "origin-top-left",
|
|
241
|
+
zoom && (zoom.zoomed ? "cursor-zoom-out touch-none" : "cursor-zoom-in"),
|
|
216
242
|
fit === "contain"
|
|
217
243
|
? "max-h-full max-w-full"
|
|
218
244
|
: "max-h-[82dvh] max-w-[88vw] max-sm:max-h-[calc(100dvh-2rem)] max-sm:max-w-[calc(100vw-2rem)]",
|
|
219
245
|
className,
|
|
220
246
|
)}
|
|
247
|
+
style={zoom ? { ...style, scale: zoom.zoomed ? String(zoom.scale) : "1" } : style}
|
|
221
248
|
{...props}
|
|
222
249
|
/>
|
|
223
250
|
)
|
|
@@ -298,12 +325,137 @@ const ModalDismiss = () => (
|
|
|
298
325
|
</ModalControlTooltip>
|
|
299
326
|
)
|
|
300
327
|
|
|
301
|
-
const
|
|
302
|
-
|
|
328
|
+
const ModalZoom = ({
|
|
329
|
+
zoomed,
|
|
330
|
+
onToggle,
|
|
331
|
+
}: {
|
|
332
|
+
zoomed: boolean
|
|
333
|
+
onToggle: () => void
|
|
334
|
+
}) => (
|
|
335
|
+
<ModalControlTooltip label={zoomed ? "Zoom out" : "Zoom in"}>
|
|
336
|
+
<GlassButton
|
|
337
|
+
type="button"
|
|
338
|
+
config={mediaGlass}
|
|
339
|
+
size="md"
|
|
340
|
+
iconOnly
|
|
341
|
+
rounded
|
|
342
|
+
aria-label={zoomed ? "Zoom out" : "Zoom in"}
|
|
343
|
+
aria-pressed={zoomed}
|
|
344
|
+
onClick={onToggle}
|
|
345
|
+
className={cn(
|
|
346
|
+
modalControlClassName,
|
|
347
|
+
// Beside Download: 16px inset, a 36px control and an 8px gap.
|
|
348
|
+
"absolute top-4 left-15 z-20",
|
|
349
|
+
)}
|
|
350
|
+
>
|
|
351
|
+
{zoomed ? <IconZoomOut aria-hidden /> : <IconZoomIn aria-hidden />}
|
|
352
|
+
</GlassButton>
|
|
353
|
+
</ModalControlTooltip>
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
const ModalCaption = ({ children, hidden }: { children: ReactNode; hidden?: boolean }) => (
|
|
357
|
+
<p
|
|
358
|
+
className={cn(
|
|
359
|
+
"mt-3 max-w-[min(40rem,88vw)] shrink-0 px-4 text-center text-sm text-fg-scrim-secondary",
|
|
360
|
+
"transition-opacity duration-[var(--duration-sm)] ease-linear motion-reduce:transition-none",
|
|
361
|
+
// The magnified image covers the caption's place; don't show through it.
|
|
362
|
+
hidden && "opacity-0",
|
|
363
|
+
)}
|
|
364
|
+
>
|
|
303
365
|
{children}
|
|
304
366
|
</p>
|
|
305
367
|
)
|
|
306
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Zoom for one image: magnify at a point, then pan under the pointer. A
|
|
371
|
+
* mouse or pen pans by moving; a finger drags the image.
|
|
372
|
+
*/
|
|
373
|
+
function useImageZoom(scale: number | false) {
|
|
374
|
+
const [zoomed, setZoomed] = useState(false)
|
|
375
|
+
const imageRef = useRef<HTMLImageElement | null>(null)
|
|
376
|
+
const frameRef = useRef<HTMLDivElement | null>(null)
|
|
377
|
+
// Where the pan is aimed, in client pixels.
|
|
378
|
+
const aim = useRef({ x: 0, y: 0 })
|
|
379
|
+
|
|
380
|
+
const toggle = useCallback(
|
|
381
|
+
(x?: number, y?: number) => {
|
|
382
|
+
const image = imageRef.current
|
|
383
|
+
const frame = frameRef.current
|
|
384
|
+
if (!scale || !image || !frame) return
|
|
385
|
+
const bounds = frame.getBoundingClientRect()
|
|
386
|
+
// The zoom itself eases, towards the point.
|
|
387
|
+
delete image.dataset.panning
|
|
388
|
+
if (zoomed) {
|
|
389
|
+
image.style.translate = "0px 0px"
|
|
390
|
+
} else {
|
|
391
|
+
// No point (the control): open on the middle.
|
|
392
|
+
aim.current = {
|
|
393
|
+
x: x ?? bounds.left + bounds.width / 2,
|
|
394
|
+
y: y ?? bounds.top + bounds.height / 2,
|
|
395
|
+
}
|
|
396
|
+
panZoomedImage(image, bounds, aim.current.x, aim.current.y, scale)
|
|
397
|
+
}
|
|
398
|
+
setZoomed(!zoomed)
|
|
399
|
+
},
|
|
400
|
+
[scale, zoomed],
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
useEffect(() => {
|
|
404
|
+
const image = imageRef.current
|
|
405
|
+
const frame = frameRef.current
|
|
406
|
+
if (!zoomed || !scale || !image || !frame) return
|
|
407
|
+
let pending = 0
|
|
408
|
+
let drag: { x: number; y: number } | null = null
|
|
409
|
+
const pan = () => {
|
|
410
|
+
pending = 0
|
|
411
|
+
image.dataset.panning = ""
|
|
412
|
+
panZoomedImage(image, frame.getBoundingClientRect(), aim.current.x, aim.current.y, scale)
|
|
413
|
+
}
|
|
414
|
+
const schedule = () => {
|
|
415
|
+
if (!pending) pending = requestAnimationFrame(pan)
|
|
416
|
+
}
|
|
417
|
+
const down = (event: PointerEvent) => {
|
|
418
|
+
if (event.pointerType === "touch") drag = { x: event.clientX, y: event.clientY }
|
|
419
|
+
}
|
|
420
|
+
const move = (event: PointerEvent) => {
|
|
421
|
+
if (event.pointerType !== "touch") {
|
|
422
|
+
aim.current = { x: event.clientX, y: event.clientY }
|
|
423
|
+
schedule()
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
if (!drag) return
|
|
427
|
+
// A finger carries the image: aim the opposite way, by the ratio of
|
|
428
|
+
// the frame to the image's travel past it.
|
|
429
|
+
const bounds = frame.getBoundingClientRect()
|
|
430
|
+
const rendered = image.getBoundingClientRect()
|
|
431
|
+
const travelX = Math.max(1, rendered.width - bounds.width)
|
|
432
|
+
const travelY = Math.max(1, rendered.height - bounds.height)
|
|
433
|
+
aim.current = {
|
|
434
|
+
x: Math.max(bounds.left, Math.min(bounds.right, aim.current.x - ((event.clientX - drag.x) * bounds.width) / travelX)),
|
|
435
|
+
y: Math.max(bounds.top, Math.min(bounds.bottom, aim.current.y - ((event.clientY - drag.y) * bounds.height) / travelY)),
|
|
436
|
+
}
|
|
437
|
+
drag = { x: event.clientX, y: event.clientY }
|
|
438
|
+
schedule()
|
|
439
|
+
}
|
|
440
|
+
const up = () => {
|
|
441
|
+
drag = null
|
|
442
|
+
}
|
|
443
|
+
document.addEventListener("pointerdown", down, { passive: true })
|
|
444
|
+
document.addEventListener("pointermove", move, { passive: true })
|
|
445
|
+
document.addEventListener("pointerup", up, { passive: true })
|
|
446
|
+
document.addEventListener("pointercancel", up, { passive: true })
|
|
447
|
+
return () => {
|
|
448
|
+
document.removeEventListener("pointerdown", down)
|
|
449
|
+
document.removeEventListener("pointermove", move)
|
|
450
|
+
document.removeEventListener("pointerup", up)
|
|
451
|
+
document.removeEventListener("pointercancel", up)
|
|
452
|
+
cancelAnimationFrame(pending)
|
|
453
|
+
}
|
|
454
|
+
}, [scale, zoomed])
|
|
455
|
+
|
|
456
|
+
return { zoomed, toggle, imageRef, frameRef }
|
|
457
|
+
}
|
|
458
|
+
|
|
307
459
|
export const ModalContent = ({
|
|
308
460
|
src,
|
|
309
461
|
alt,
|
|
@@ -314,8 +466,11 @@ export const ModalContent = ({
|
|
|
314
466
|
className,
|
|
315
467
|
imgProps,
|
|
316
468
|
children,
|
|
469
|
+
zoom = IMAGE_ZOOM,
|
|
317
470
|
...props
|
|
318
|
-
}: ModalContentProps) =>
|
|
471
|
+
}: ModalContentProps) => {
|
|
472
|
+
const { zoomed, toggle, imageRef, frameRef } = useImageZoom(zoom)
|
|
473
|
+
return (
|
|
319
474
|
<DialogPortal>
|
|
320
475
|
<ModalBackdrop />
|
|
321
476
|
<DialogPopup
|
|
@@ -332,12 +487,19 @@ export const ModalContent = ({
|
|
|
332
487
|
<ModalBackground src={src} />
|
|
333
488
|
<DialogTitle className="sr-only">{alt}</DialogTitle>
|
|
334
489
|
<div
|
|
490
|
+
ref={frameRef}
|
|
335
491
|
data-slot="modal-stage"
|
|
336
492
|
className="relative z-10 flex size-full flex-col items-center justify-center p-4"
|
|
337
493
|
>
|
|
338
|
-
<ModalImage
|
|
494
|
+
<ModalImage
|
|
495
|
+
src={src}
|
|
496
|
+
alt={alt}
|
|
497
|
+
imgProps={imgProps}
|
|
498
|
+
imageRef={imageRef}
|
|
499
|
+
zoom={zoom ? { scale: zoom, zoomed, toggle } : undefined}
|
|
500
|
+
/>
|
|
339
501
|
{variant === "caption" && (caption || children) ? (
|
|
340
|
-
<ModalCaption>{caption ?? children}</ModalCaption>
|
|
502
|
+
<ModalCaption hidden={zoomed}>{caption ?? children}</ModalCaption>
|
|
341
503
|
) : null}
|
|
342
504
|
</div>
|
|
343
505
|
<ModalDismiss />
|
|
@@ -345,11 +507,13 @@ export const ModalContent = ({
|
|
|
345
507
|
src={downloadSrc ?? src}
|
|
346
508
|
name={downloadName}
|
|
347
509
|
/>
|
|
510
|
+
{zoom ? <ModalZoom zoomed={zoomed} onToggle={() => toggle()} /> : null}
|
|
348
511
|
</GlassRoot>
|
|
349
512
|
</TooltipProvider>
|
|
350
513
|
</DialogPopup>
|
|
351
514
|
</DialogPortal>
|
|
352
|
-
)
|
|
515
|
+
)
|
|
516
|
+
}
|
|
353
517
|
|
|
354
518
|
export const GalleryTrigger = ({
|
|
355
519
|
index,
|