@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyernick/standard-ui-react",
3
- "version": "0.4.2-canary.68",
3
+ "version": "0.4.2-canary.70",
4
4
  "description": "React components for StandardUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
@@ -1007,6 +1007,8 @@ export {
1007
1007
  IconVolumeFull,
1008
1008
  IconVolumeOff,
1009
1009
  IconX,
1010
+ IconZoomIn,
1011
+ IconZoomOut,
1010
1012
  type CentralIconProps,
1011
1013
  type CentralIconComponent,
1012
1014
  } from "./icons";
@@ -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
- "transition-opacity duration-[var(--duration-lg)] ease-linear motion-reduce:transition-none",
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
- "rounded-lg object-contain shadow-lg select-none",
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 className="mt-3 max-w-[min(40rem,88vw)] shrink-0 px-4 text-center text-sm text-fg-scrim-secondary">
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 src={src} alt={alt} imgProps={imgProps} />
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,