@boyernick/standard-ui-react 0.4.2-canary.68 → 0.4.2-canary.70
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 +139 -11
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
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from "./icons"
|
|
40
40
|
import { cn } from "./lib/cn"
|
|
41
41
|
import { focusRing, focusRingBorder } from "./lib/focus"
|
|
42
|
+
import { IMAGE_ZOOM, panZoomedImage } from "./lib/image-zoom"
|
|
42
43
|
import { motion } from "./lib/motion"
|
|
43
44
|
import {
|
|
44
45
|
Tooltip,
|
|
@@ -86,6 +87,8 @@ export type ModalContentProps = Omit<
|
|
|
86
87
|
downloadName?: string
|
|
87
88
|
imgProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt">
|
|
88
89
|
children?: ReactNode
|
|
90
|
+
/** Magnification when the image is clicked. `false` turns zoom off. Default 2. */
|
|
91
|
+
zoom?: number | false
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
export type GalleryItem = {
|
|
@@ -176,18 +179,24 @@ const ModalImage = ({
|
|
|
176
179
|
alt,
|
|
177
180
|
imgProps,
|
|
178
181
|
fit = "viewport",
|
|
182
|
+
imageRef,
|
|
183
|
+
zoom,
|
|
179
184
|
}: Pick<GalleryItem, "src" | "alt" | "imgProps"> & {
|
|
180
185
|
fit?: "viewport" | "contain"
|
|
186
|
+
imageRef?: { current: HTMLImageElement | null }
|
|
187
|
+
/** Zoom state and toggle, for an image that magnifies in place. */
|
|
188
|
+
zoom?: { scale: number; zoomed: boolean; toggle: (x: number, y: number) => void }
|
|
181
189
|
}) => {
|
|
182
|
-
const { className, onLoad, onError, ...props } = imgProps ?? {}
|
|
190
|
+
const { className, onLoad, onError, onClick, style, ...props } = imgProps ?? {}
|
|
183
191
|
// Held transparent until decoded, then faded in: a large file otherwise
|
|
184
192
|
// paints in top-down bands inside the lightbox. The ref catches an image the
|
|
185
193
|
// browser already has (a cached file, or the one the trigger just showed)
|
|
186
194
|
// before first paint, so it appears at once with nothing to fade.
|
|
187
195
|
const [loaded, setLoaded] = useState(false)
|
|
188
196
|
const catchLoaded = useCallback((image: HTMLImageElement | null) => {
|
|
197
|
+
if (imageRef) imageRef.current = image
|
|
189
198
|
if (image?.complete && image.naturalWidth > 0) setLoaded(true)
|
|
190
|
-
}, [])
|
|
199
|
+
}, [imageRef])
|
|
191
200
|
|
|
192
201
|
return (
|
|
193
202
|
// eslint-disable-next-line @next/next/no-img-element
|
|
@@ -208,16 +217,32 @@ const ModalImage = ({
|
|
|
208
217
|
// Reveal the broken image rather than leave an invisible one.
|
|
209
218
|
setLoaded(true)
|
|
210
219
|
}}
|
|
220
|
+
onClick={(event) => {
|
|
221
|
+
onClick?.(event)
|
|
222
|
+
if (!event.defaultPrevented) zoom?.toggle(event.clientX, event.clientY)
|
|
223
|
+
}}
|
|
211
224
|
className={cn(
|
|
212
|
-
// Linear, like every fade in `motion` — see the note there.
|
|
213
|
-
|
|
225
|
+
// Linear, like every fade in `motion` — see the note there. The zoom
|
|
226
|
+
// eases scale and translate; while the pointer pans, translate
|
|
227
|
+
// follows it without easing.
|
|
228
|
+
zoom
|
|
229
|
+
? "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]"
|
|
230
|
+
: "transition-opacity duration-[var(--duration-lg)] ease-linear motion-reduce:transition-none",
|
|
214
231
|
!loaded && "opacity-0",
|
|
215
|
-
|
|
232
|
+
// Sized by the image itself: width and height attributes with
|
|
233
|
+
// separate max caps made a box of another shape, which object-contain
|
|
234
|
+
// letterboxed, leaving the rounded corners on empty space.
|
|
235
|
+
"h-auto w-auto rounded-lg object-contain shadow-lg select-none",
|
|
236
|
+
// Scaled from the top-left, so the pan lands the image exactly where
|
|
237
|
+
// it is measured.
|
|
238
|
+
zoom && "origin-top-left",
|
|
239
|
+
zoom && (zoom.zoomed ? "cursor-zoom-out touch-none" : "cursor-zoom-in"),
|
|
216
240
|
fit === "contain"
|
|
217
241
|
? "max-h-full max-w-full"
|
|
218
242
|
: "max-h-[82dvh] max-w-[88vw] max-sm:max-h-[calc(100dvh-2rem)] max-sm:max-w-[calc(100vw-2rem)]",
|
|
219
243
|
className,
|
|
220
244
|
)}
|
|
245
|
+
style={zoom ? { ...style, scale: zoom.zoomed ? String(zoom.scale) : "1" } : style}
|
|
221
246
|
{...props}
|
|
222
247
|
/>
|
|
223
248
|
)
|
|
@@ -298,12 +323,104 @@ const ModalDismiss = () => (
|
|
|
298
323
|
</ModalControlTooltip>
|
|
299
324
|
)
|
|
300
325
|
|
|
301
|
-
const ModalCaption = ({ children }: { children: ReactNode }) => (
|
|
302
|
-
<p
|
|
326
|
+
const ModalCaption = ({ children, hidden }: { children: ReactNode; hidden?: boolean }) => (
|
|
327
|
+
<p
|
|
328
|
+
className={cn(
|
|
329
|
+
"mt-3 max-w-[min(40rem,88vw)] shrink-0 px-4 text-center text-sm text-fg-scrim-secondary",
|
|
330
|
+
"transition-opacity duration-[var(--duration-sm)] ease-linear motion-reduce:transition-none",
|
|
331
|
+
// The magnified image covers the caption's place; don't show through it.
|
|
332
|
+
hidden && "opacity-0",
|
|
333
|
+
)}
|
|
334
|
+
>
|
|
303
335
|
{children}
|
|
304
336
|
</p>
|
|
305
337
|
)
|
|
306
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Zoom for one image: a click magnifies at the point, then the image pans
|
|
341
|
+
* under the pointer. A mouse or pen pans by moving; a finger drags.
|
|
342
|
+
*/
|
|
343
|
+
function useImageZoom(scale: number | false) {
|
|
344
|
+
const [zoomed, setZoomed] = useState(false)
|
|
345
|
+
const imageRef = useRef<HTMLImageElement | null>(null)
|
|
346
|
+
const frameRef = useRef<HTMLDivElement | null>(null)
|
|
347
|
+
// Where the pan is aimed, in client pixels.
|
|
348
|
+
const aim = useRef({ x: 0, y: 0 })
|
|
349
|
+
|
|
350
|
+
const toggle = useCallback(
|
|
351
|
+
(x: number, y: number) => {
|
|
352
|
+
const image = imageRef.current
|
|
353
|
+
const frame = frameRef.current
|
|
354
|
+
if (!scale || !image || !frame) return
|
|
355
|
+
// The zoom itself eases, towards the point.
|
|
356
|
+
delete image.dataset.panning
|
|
357
|
+
if (zoomed) {
|
|
358
|
+
image.style.translate = "0px 0px"
|
|
359
|
+
} else {
|
|
360
|
+
aim.current = { x, y }
|
|
361
|
+
panZoomedImage(image, frame.getBoundingClientRect(), x, y, scale)
|
|
362
|
+
}
|
|
363
|
+
setZoomed(!zoomed)
|
|
364
|
+
},
|
|
365
|
+
[scale, zoomed],
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
useEffect(() => {
|
|
369
|
+
const image = imageRef.current
|
|
370
|
+
const frame = frameRef.current
|
|
371
|
+
if (!zoomed || !scale || !image || !frame) return
|
|
372
|
+
let pending = 0
|
|
373
|
+
let drag: { x: number; y: number } | null = null
|
|
374
|
+
const pan = () => {
|
|
375
|
+
pending = 0
|
|
376
|
+
image.dataset.panning = ""
|
|
377
|
+
panZoomedImage(image, frame.getBoundingClientRect(), aim.current.x, aim.current.y, scale)
|
|
378
|
+
}
|
|
379
|
+
const schedule = () => {
|
|
380
|
+
if (!pending) pending = requestAnimationFrame(pan)
|
|
381
|
+
}
|
|
382
|
+
const down = (event: PointerEvent) => {
|
|
383
|
+
if (event.pointerType === "touch") drag = { x: event.clientX, y: event.clientY }
|
|
384
|
+
}
|
|
385
|
+
const move = (event: PointerEvent) => {
|
|
386
|
+
if (event.pointerType !== "touch") {
|
|
387
|
+
aim.current = { x: event.clientX, y: event.clientY }
|
|
388
|
+
schedule()
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
if (!drag) return
|
|
392
|
+
// A finger carries the image: aim the opposite way, by the ratio of
|
|
393
|
+
// the frame to the image's travel past it.
|
|
394
|
+
const bounds = frame.getBoundingClientRect()
|
|
395
|
+
const rendered = image.getBoundingClientRect()
|
|
396
|
+
const travelX = Math.max(1, rendered.width - bounds.width)
|
|
397
|
+
const travelY = Math.max(1, rendered.height - bounds.height)
|
|
398
|
+
aim.current = {
|
|
399
|
+
x: Math.max(bounds.left, Math.min(bounds.right, aim.current.x - ((event.clientX - drag.x) * bounds.width) / travelX)),
|
|
400
|
+
y: Math.max(bounds.top, Math.min(bounds.bottom, aim.current.y - ((event.clientY - drag.y) * bounds.height) / travelY)),
|
|
401
|
+
}
|
|
402
|
+
drag = { x: event.clientX, y: event.clientY }
|
|
403
|
+
schedule()
|
|
404
|
+
}
|
|
405
|
+
const up = () => {
|
|
406
|
+
drag = null
|
|
407
|
+
}
|
|
408
|
+
document.addEventListener("pointerdown", down, { passive: true })
|
|
409
|
+
document.addEventListener("pointermove", move, { passive: true })
|
|
410
|
+
document.addEventListener("pointerup", up, { passive: true })
|
|
411
|
+
document.addEventListener("pointercancel", up, { passive: true })
|
|
412
|
+
return () => {
|
|
413
|
+
document.removeEventListener("pointerdown", down)
|
|
414
|
+
document.removeEventListener("pointermove", move)
|
|
415
|
+
document.removeEventListener("pointerup", up)
|
|
416
|
+
document.removeEventListener("pointercancel", up)
|
|
417
|
+
cancelAnimationFrame(pending)
|
|
418
|
+
}
|
|
419
|
+
}, [scale, zoomed])
|
|
420
|
+
|
|
421
|
+
return { zoomed, toggle, imageRef, frameRef }
|
|
422
|
+
}
|
|
423
|
+
|
|
307
424
|
export const ModalContent = ({
|
|
308
425
|
src,
|
|
309
426
|
alt,
|
|
@@ -314,8 +431,11 @@ export const ModalContent = ({
|
|
|
314
431
|
className,
|
|
315
432
|
imgProps,
|
|
316
433
|
children,
|
|
434
|
+
zoom = IMAGE_ZOOM,
|
|
317
435
|
...props
|
|
318
|
-
}: ModalContentProps) =>
|
|
436
|
+
}: ModalContentProps) => {
|
|
437
|
+
const { zoomed, toggle, imageRef, frameRef } = useImageZoom(zoom)
|
|
438
|
+
return (
|
|
319
439
|
<DialogPortal>
|
|
320
440
|
<ModalBackdrop />
|
|
321
441
|
<DialogPopup
|
|
@@ -332,12 +452,19 @@ export const ModalContent = ({
|
|
|
332
452
|
<ModalBackground src={src} />
|
|
333
453
|
<DialogTitle className="sr-only">{alt}</DialogTitle>
|
|
334
454
|
<div
|
|
455
|
+
ref={frameRef}
|
|
335
456
|
data-slot="modal-stage"
|
|
336
457
|
className="relative z-10 flex size-full flex-col items-center justify-center p-4"
|
|
337
458
|
>
|
|
338
|
-
<ModalImage
|
|
459
|
+
<ModalImage
|
|
460
|
+
src={src}
|
|
461
|
+
alt={alt}
|
|
462
|
+
imgProps={imgProps}
|
|
463
|
+
imageRef={imageRef}
|
|
464
|
+
zoom={zoom ? { scale: zoom, zoomed, toggle } : undefined}
|
|
465
|
+
/>
|
|
339
466
|
{variant === "caption" && (caption || children) ? (
|
|
340
|
-
<ModalCaption>{caption ?? children}</ModalCaption>
|
|
467
|
+
<ModalCaption hidden={zoomed}>{caption ?? children}</ModalCaption>
|
|
341
468
|
) : null}
|
|
342
469
|
</div>
|
|
343
470
|
<ModalDismiss />
|
|
@@ -349,7 +476,8 @@ export const ModalContent = ({
|
|
|
349
476
|
</TooltipProvider>
|
|
350
477
|
</DialogPopup>
|
|
351
478
|
</DialogPortal>
|
|
352
|
-
)
|
|
479
|
+
)
|
|
480
|
+
}
|
|
353
481
|
|
|
354
482
|
export const GalleryTrigger = ({
|
|
355
483
|
index,
|