@boyernick/standard-ui-react 0.4.2-canary.68 → 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/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
|
+
}
|
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,
|