yet-another-react-lightbox-lite 1.2.0 → 1.3.0

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Lightweight React lightbox component. This is a trimmed-down version of the
4
4
  [yet-another-react-lightbox](https://github.com/igordanchenko/yet-another-react-lightbox)
5
- that provides essential lightbox features and slick UX with just 3KB bundle
5
+ that provides essential lightbox features and slick UX with just 4.3KB bundle
6
6
  size.
7
7
 
8
8
  ## Overview
@@ -13,6 +13,7 @@ size.
13
13
 
14
14
  - **Built for React:** works with React 18+
15
15
  - **UX:** supports keyboard, mouse, touchpad and touchscreen navigation
16
+ - **Zoom:** zoom is supported out of the box
16
17
  - **Performance:** preloads a fixed number of images without compromising
17
18
  performance or UX
18
19
  - **Responsive:** responsive images with automatic resolution switching are
@@ -269,14 +270,14 @@ An object providing custom render functions.
269
270
  ```tsx
270
271
  <Lightbox
271
272
  render={{
272
- slide: ({ slide, rect, current }) => (
273
- <CustomSlide {...{ slide, rect, current }} />
273
+ slide: ({ slide, rect, zoom, current }) => (
274
+ <CustomSlide {...{ slide, rect, zoom, current }} />
274
275
  ),
275
- slideHeader: ({ slide, rect, current }) => (
276
- <SlideHeader {...{ slide, rect, current }} />
276
+ slideHeader: ({ slide, rect, zoom, current }) => (
277
+ <SlideHeader {...{ slide, rect, zoom, current }} />
277
278
  ),
278
- slideFooter: ({ slide, rect, current }) => (
279
- <SlideFooter {...{ slide, rect, current }} />
279
+ slideFooter: ({ slide, rect, zoom, current }) => (
280
+ <SlideFooter {...{ slide, rect, zoom, current }} />
280
281
  ),
281
282
  controls: () => <CustomControls />,
282
283
  iconPrev: () => <IconPrev />,
@@ -287,15 +288,23 @@ An object providing custom render functions.
287
288
  />
288
289
  ```
289
290
 
290
- #### slide: ({ slide, rect, current }) => ReactNode
291
+ #### slide: ({ slide, rect, zoom, current }) => ReactNode
291
292
 
292
293
  Render custom slide type, or override the default image slide implementation.
293
294
 
294
- #### slideHeader: ({ slide, rect, current }) => ReactNode
295
+ Parameters:
296
+
297
+ - `slide` - slide object (type: `Slide`)
298
+ - `rect` - slide rect size (type: `Rect`)
299
+ - `zoom` - current zoom level (type: `number`)
300
+ - `current` - if `true`, the slide is the current slide in the viewport (type:
301
+ `boolean`)
302
+
303
+ #### slideHeader: ({ slide, rect, zoom, current }) => ReactNode
295
304
 
296
305
  Render custom elements above each slide.
297
306
 
298
- #### slideFooter: ({ slide, rect, current }) => ReactNode
307
+ #### slideFooter: ({ slide, rect, zoom, current }) => ReactNode
299
308
 
300
309
  Render custom elements below or over each slide. By default, the content is
301
310
  rendered right under the slide. Alternatively, you can use
@@ -515,6 +524,31 @@ fixed-positioned element container should not have its own border or padding
515
524
  styles. If that's the case, you can always add an extra wrapper that just
516
525
  defines the fixed position without visual styles.
517
526
 
527
+ ## Hooks (experimental)
528
+
529
+ The library exports the following experimental hooks that you may find helpful
530
+ in customizing lightbox functionality. All experimental hooks are currently
531
+ exported with the `unstable_` prefix.
532
+
533
+ ### useZoom
534
+
535
+ You can use the `useZoom` hook to build your custom zoom controls.
536
+
537
+ ```tsx
538
+ import { unstable_useZoom as useZoom } from "yet-another-react-lightbox-lite";
539
+ ```
540
+
541
+ The hook provides an object with the following props:
542
+
543
+ - `rect` - slide rect
544
+ - `zoom` - current zoom level (numeric value between `1` and `8`)
545
+ - `maxZoom` - maximum zoom level (`1` if zoom is not supported on the current
546
+ slide)
547
+ - `offsetX` - horizontal slide position offset
548
+ - `offsetY` - vertical slide position offset
549
+ - `changeZoom` - change zoom level
550
+ - `changeOffsets` - change position offsets
551
+
518
552
  ## License
519
553
 
520
554
  MIT © 2024 [Igor Danchenko](https://github.com/igordanchenko)
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { MouseEvent } from 'react';
2
2
  import * as react_jsx_runtime from 'react/jsx-runtime';
3
3
 
4
4
  /** Lightbox props */
@@ -17,6 +17,8 @@ interface LightboxProps {
17
17
  toolbar?: ToolbarSettings;
18
18
  /** controller settings */
19
19
  controller?: ControllerSettings;
20
+ /** zoom settings */
21
+ zoom?: ZoomSettings;
20
22
  /** customization slots styles */
21
23
  styles?: SlotStyles;
22
24
  /** CSS class of the lightbox root element */
@@ -91,8 +93,10 @@ interface Render {
91
93
  interface RenderSlideProps {
92
94
  /** slide */
93
95
  slide: Slide;
94
- /** slide */
96
+ /** slide rect size */
95
97
  rect: Rect;
98
+ /** zoom level */
99
+ zoom: number;
96
100
  /** if `true`, the slide is the current slide in the viewport */
97
101
  current: boolean;
98
102
  }
@@ -112,6 +116,11 @@ interface ControllerSettings {
112
116
  /** if `true`, close the lightbox when the backdrop is clicked (default: `true`) */
113
117
  closeOnBackdropClick?: boolean;
114
118
  }
119
+ /** Zoom settings */
120
+ interface ZoomSettings {
121
+ /** zoom-enabled custom slide types */
122
+ supports?: SlideTypeKey[];
123
+ }
115
124
  /** Customization slots */
116
125
  interface SlotType {
117
126
  /** lightbox portal (root) customization slot */
@@ -149,6 +158,31 @@ type Callback<T = void> = () => T;
149
158
  /** Render function */
150
159
  type RenderFunction<T = void> = [T] extends [void] ? () => React.ReactNode : (props: T) => React.ReactNode;
151
160
 
161
+ /** Lightbox component */
152
162
  declare function Lightbox({ slides, index, setIndex, ...rest }: LightboxProps): react_jsx_runtime.JSX.Element | null;
153
163
 
154
- export { type Callback, type ControllerSettings, type GenericSlide, type ImageSource, type Label, type Labels, type LightboxProps, type Rect, type Render, type RenderFunction, type RenderSlideProps, type Slide, type SlideImage, type SlideTypeKey, type SlideTypes, type Slot, type SlotStyles, type SlotType, type ToolbarSettings, Lightbox as default };
164
+ /** Zoom context */
165
+ type ZoomContextType = {
166
+ /** slide rect */
167
+ rect?: Rect;
168
+ /** zoom level */
169
+ zoom: number;
170
+ /** maximum zoom level */
171
+ maxZoom: number;
172
+ /** horizontal slide position offset */
173
+ offsetX: number;
174
+ /** vertical slide position offset */
175
+ offsetY: number;
176
+ /** change zoom level */
177
+ changeZoom: (
178
+ /** new zoom value */
179
+ newZoom: number,
180
+ /** pointer/mouse/wheel event that determines zoom-in point */
181
+ event?: Pick<MouseEvent, "clientX" | "clientY">) => void;
182
+ /** change position offsets */
183
+ changeOffsets: (dx: number, dy: number) => void;
184
+ };
185
+ /** `useZoom` hook */
186
+ declare const useZoom: () => ZoomContextType;
187
+
188
+ export { type Callback, type ControllerSettings, type GenericSlide, type ImageSource, type Label, type Labels, type LightboxProps, type Rect, type Render, type RenderFunction, type RenderSlideProps, type Slide, type SlideImage, type SlideTypeKey, type SlideTypes, type Slot, type SlotStyles, type SlotType, type ToolbarSettings, type ZoomSettings, Lightbox as default, useZoom as unstable_useZoom };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
- import { useContext, createContext, useState, useRef, useCallback, useMemo, useEffect, isValidElement, cloneElement } from 'react';
2
+ import { useContext, createContext, useState, useRef, useLayoutEffect, useCallback, useMemo, useEffect, isValidElement, cloneElement } from 'react';
3
3
  import { flushSync, createPortal } from 'react-dom';
4
4
 
5
5
  const cssPrefix = "yarll__";
@@ -33,6 +33,16 @@ function makeUseContext(context) {
33
33
  return ctx;
34
34
  };
35
35
  }
36
+ function round(value, decimals = 0) {
37
+ const factor = 10 ** decimals;
38
+ return Math.round((value + Number.EPSILON) * factor) / factor;
39
+ }
40
+ function scaleZoom(value, delta, factor = 100, clamp = 2) {
41
+ return value * Math.min(1 + Math.abs(delta / factor), clamp) ** Math.sign(delta);
42
+ }
43
+ function isImageSlide(slide) {
44
+ return (slide.type === undefined || slide.type === "image") && typeof slide.src === "string";
45
+ }
36
46
 
37
47
  const LightboxContext = createContext(null);
38
48
  const useLightboxContext = makeUseContext(LightboxContext);
@@ -40,25 +50,43 @@ function LightboxContextProvider({ children, ...props }) {
40
50
  return jsx(LightboxContext.Provider, { value: props, children: children });
41
51
  }
42
52
 
43
- function ImageSlide({ slide, rect }) {
44
- const { styles } = useLightboxContext();
45
- const { width, height } = slide.srcSet?.[0] ?? slide;
46
- const imageAspectRatio = width && height ? width / height : undefined;
47
- const srcSet = slide.srcSet
48
- ?.sort((a, b) => a.width - b.width)
49
- .map((image) => `${image.src} ${image.width}w`)
50
- .join(", ");
51
- const sizes = imageAspectRatio
52
- ? `${imageAspectRatio < rect.width / rect.height ? Math.round(imageAspectRatio * rect.height) : rect.width}px`
53
- : undefined;
54
- return (jsx("img", { draggable: false, style: styles?.image, className: cssClass("slide_image"), srcSet: srcSet, sizes: sizes, src: slide.src, alt: slide.alt }));
55
- }
56
-
57
- function Carousel() {
58
- const { slides, index, styles, render: { slide: renderSlide, slideHeader, slideFooter } = {} } = useLightboxContext();
53
+ const ZoomContext = createContext(null);
54
+ const useZoom = makeUseContext(ZoomContext);
55
+ const ZoomInternalContext = createContext(null);
56
+ const useZoomInternal = makeUseContext(ZoomInternalContext);
57
+ function Zoom({ children }) {
58
+ const [zoom, setZoom] = useState(1);
59
+ const [offsetX, setOffsetX] = useState(0);
60
+ const [offsetY, setOffsetY] = useState(0);
59
61
  const [rect, setRect] = useState();
60
62
  const observer = useRef();
61
- const handleRef = useCallback((node) => {
63
+ const carouselRef = useRef(null);
64
+ const { index, slides, zoom: { supports } = {} } = useLightboxContext();
65
+ const slide = slides[index];
66
+ const maxZoom = isImageSlide(slide) || (supports || []).includes(slide.type) ? 8 : 1;
67
+ useLayoutEffect(() => {
68
+ setZoom(1);
69
+ setOffsetX(0);
70
+ setOffsetY(0);
71
+ }, [index]);
72
+ useLayoutEffect(() => {
73
+ const carouselHalfWidth = (rect?.width || 0) / 2;
74
+ const carouselHalfHeight = (rect?.height || 0) / 2;
75
+ const [slideHalfWidth, slideHalfHeight] = Array.from(Array.from(carouselRef.current?.children || []).find((node) => node instanceof HTMLElement && !node.hidden)
76
+ ?.children || [])
77
+ .filter((node) => node instanceof HTMLElement)
78
+ .map((node) => [
79
+ Math.max(carouselHalfWidth - node.offsetLeft, node.offsetLeft + node.offsetWidth - carouselHalfWidth),
80
+ Math.max(carouselHalfHeight - node.offsetTop, node.offsetTop + node.offsetHeight - carouselHalfHeight),
81
+ ])
82
+ .reduce(([maxWidth, maxHeight], [width, height]) => [Math.max(width, maxWidth), Math.max(height, maxHeight)], [0, 0]);
83
+ const maxOffsetX = Math.max(slideHalfWidth * zoom - carouselHalfWidth, 0);
84
+ const maxOffsetY = Math.max(slideHalfHeight * zoom - carouselHalfHeight, 0);
85
+ setOffsetX(Math.min(maxOffsetX, Math.max(-maxOffsetX, offsetX)));
86
+ setOffsetY(Math.min(maxOffsetY, Math.max(-maxOffsetY, offsetY)));
87
+ }, [zoom, rect, offsetX, offsetY]);
88
+ const setCarouselRef = useCallback((node) => {
89
+ carouselRef.current = node;
62
90
  observer.current?.disconnect();
63
91
  observer.current = undefined;
64
92
  const updateRect = () => setRect(node ? { width: node.clientWidth, height: node.clientHeight } : undefined);
@@ -70,15 +98,75 @@ function Carousel() {
70
98
  updateRect();
71
99
  }
72
100
  }, []);
73
- return (jsx("div", { ref: handleRef, style: styles?.carousel, className: cssClass("carousel"), children: rect &&
101
+ const changeOffsets = useCallback((dx, dy) => {
102
+ setOffsetX(offsetX + dx);
103
+ setOffsetY(offsetY + dy);
104
+ }, [offsetX, offsetY]);
105
+ const changeZoom = useCallback((targetZoom, event) => {
106
+ const newZoom = Math.min(Math.max(targetZoom, 1), maxZoom);
107
+ setZoom(newZoom);
108
+ if (event && carouselRef.current) {
109
+ const { clientX, clientY } = event;
110
+ const { left, top, width, height } = carouselRef.current.getBoundingClientRect();
111
+ const zoomDelta = newZoom / zoom - 1;
112
+ changeOffsets((left + width / 2 + offsetX - clientX) * zoomDelta, (top + height / 2 + offsetY - clientY) * zoomDelta);
113
+ }
114
+ }, [zoom, maxZoom, offsetX, offsetY, changeOffsets]);
115
+ return (jsx(ZoomContext.Provider, { value: useMemo(() => ({ rect, zoom, maxZoom, offsetX, offsetY, changeZoom, changeOffsets }), [rect, zoom, maxZoom, offsetX, offsetY, changeZoom, changeOffsets]), children: jsx(ZoomInternalContext.Provider, { value: useMemo(() => ({ carouselRef, setCarouselRef }), [setCarouselRef]), children: children }) }));
116
+ }
117
+
118
+ function getImageDimensions(slide, rect) {
119
+ const { width, height } = slide.srcSet?.[0] || slide;
120
+ const imageAspectRatio = width && height ? width / height : undefined;
121
+ const rectAspectRatio = rect.width / rect.height;
122
+ return imageAspectRatio
123
+ ? [
124
+ round(imageAspectRatio < rectAspectRatio ? imageAspectRatio * rect.height : rect.width, 2),
125
+ round(imageAspectRatio > rectAspectRatio ? rect.width / imageAspectRatio : rect.height, 2),
126
+ ]
127
+ : [];
128
+ }
129
+ function ImageSlide({ slide, rect, zoom }) {
130
+ const [scale, setScale] = useState(1);
131
+ const persistScaleTimeout = useRef();
132
+ const { styles } = useLightboxContext();
133
+ useEffect(() => {
134
+ if (zoom && zoom > scale) {
135
+ clearTimeout(persistScaleTimeout.current);
136
+ persistScaleTimeout.current = setTimeout(() => {
137
+ persistScaleTimeout.current = undefined;
138
+ setScale(zoom);
139
+ }, 300);
140
+ }
141
+ }, [zoom, scale]);
142
+ const srcSet = slide.srcSet
143
+ ?.sort((a, b) => a.width - b.width)
144
+ .map((image) => `${image.src} ${image.width}w`)
145
+ .join(", ");
146
+ const [width, height] = getImageDimensions(slide, rect);
147
+ const sizes = width ? `${round(width * scale, 2)}px` : undefined;
148
+ return (jsx("img", { draggable: false, style: styles?.image, className: cssClass("slide_image"), srcSet: srcSet, sizes: sizes, width: width, height: height, src: slide.src, alt: slide.alt }));
149
+ }
150
+
151
+ function Carousel() {
152
+ const { slides, index, styles, render: { slide: renderSlide, slideHeader, slideFooter } = {} } = useLightboxContext();
153
+ const { rect, zoom, offsetX, offsetY } = useZoom();
154
+ const { setCarouselRef } = useZoomInternal();
155
+ return (jsx("div", { ref: setCarouselRef, style: styles?.carousel, className: cssClass("carousel"), children: rect &&
74
156
  Array.from({ length: 5 }).map((_, i) => {
75
157
  const slideIndex = index - 2 + i;
76
158
  if (slideIndex < 0 || slideIndex >= slides.length)
77
159
  return null;
78
160
  const slide = slides[slideIndex];
79
161
  const current = slideIndex === index;
80
- const context = { slide, rect, current };
81
- return (jsxs("div", { role: "group", "aria-roledescription": "slide", className: cssClass("slide"), hidden: !current, style: styles?.slide, children: [slideHeader?.(context), renderSlide?.(context) ?? jsx(ImageSlide, { ...context }), slideFooter?.(context)] }, slide.key ?? `${slideIndex}-${slide.src}`));
162
+ const context = { slide, rect, current, zoom: round(current ? zoom : 1, 3) };
163
+ return (jsxs("div", { role: "group", "aria-roledescription": "slide", className: cssClass("slide"), hidden: !current, style: {
164
+ transform: current && zoom > 1
165
+ ? `translateX(${round(offsetX, 3)}px) translateY(${round(offsetY, 3)}px) scale(${round(zoom, 3)})`
166
+ : undefined,
167
+ ...styles?.slide,
168
+ }, children: [slideHeader?.(context), renderSlide?.(context) ??
169
+ (isImageSlide(slide) && jsx(ImageSlide, { ...context })), slideFooter?.(context)] }, slide.key ?? `${slideIndex}-${isImageSlide(slide) ? slide.src : undefined}`));
82
170
  }) }));
83
171
  }
84
172
 
@@ -142,11 +230,25 @@ function Navigation() {
142
230
  return (jsxs(Fragment, { children: [slides.length > 1 && (jsxs(Fragment, { children: [jsx(Button, { label: "Previous", icon: Previous, renderIcon: iconPrev, onClick: prev, className: cssClass("button_prev"), disabled: index <= 0 }), jsx(Button, { label: "Next", icon: Next, renderIcon: iconNext, onClick: next, className: cssClass("button_next"), disabled: index >= slides.length - 1 })] })), controls?.()] }));
143
231
  }
144
232
 
233
+ const WHEEL_ZOOM_FACTOR = 100;
234
+ const WHEEL_SWIPE_DISTANCE = 100;
235
+ const WHEEL_SWIPE_COOLDOWN_TIME = 1000;
236
+ const POINTER_SWIPE_DISTANCE = 100;
237
+ const KEYBOARD_ZOOM_FACTOR = 8 ** (1 / 4);
238
+ const KEYBOARD_MOVE_DISTANCE = 50;
239
+ const PINCH_ZOOM_DISTANCE_FACTOR = 100;
240
+ const PREVAILING_DIRECTION_FACTOR = 1.2;
241
+ function distance(pointerA, pointerB) {
242
+ return ((pointerA.clientX - pointerB.clientX) ** 2 + (pointerA.clientY - pointerB.clientY) ** 2) ** 0.5;
243
+ }
145
244
  function useSensors() {
146
245
  const wheelEvents = useRef([]);
147
246
  const wheelCooldown = useRef(null);
148
247
  const wheelCooldownMomentum = useRef(null);
149
- const activePointer = useRef(null);
248
+ const activePointers = useRef([]);
249
+ const pinchZoomDistance = useRef();
250
+ const { zoom, changeZoom, changeOffsets } = useZoom();
251
+ const { carouselRef } = useZoomInternal();
150
252
  const { prev, next, close } = useController();
151
253
  const { closeOnPullUp, closeOnPullDown, closeOnBackdropClick } = {
152
254
  closeOnPullUp: true,
@@ -156,34 +258,94 @@ function useSensors() {
156
258
  };
157
259
  return useMemo(() => {
158
260
  const onKeyDown = (event) => {
159
- switch (event.key) {
160
- case "Escape":
161
- close();
162
- break;
163
- case "ArrowLeft":
164
- prev();
165
- break;
166
- case "ArrowRight":
167
- next();
168
- break;
261
+ const meta = event.getModifierState("Meta");
262
+ const preventDefault = () => event.preventDefault();
263
+ const handleChangeZoom = (newZoom) => {
264
+ preventDefault();
265
+ changeZoom(newZoom);
266
+ };
267
+ if (event.key === "+" || (meta && event.key === "="))
268
+ handleChangeZoom(zoom * KEYBOARD_ZOOM_FACTOR);
269
+ if (event.key === "-" || (meta && event.key === "_"))
270
+ handleChangeZoom(zoom / KEYBOARD_ZOOM_FACTOR);
271
+ if (meta && event.key === "0")
272
+ handleChangeZoom(1);
273
+ if (event.key === "Escape")
274
+ close();
275
+ if (zoom > 1) {
276
+ const move = (deltaX, deltaY) => {
277
+ preventDefault();
278
+ changeOffsets(deltaX, deltaY);
279
+ };
280
+ if (event.key === "ArrowUp")
281
+ move(0, KEYBOARD_MOVE_DISTANCE);
282
+ if (event.key === "ArrowDown")
283
+ move(0, -KEYBOARD_MOVE_DISTANCE);
284
+ if (event.key === "ArrowLeft")
285
+ move(KEYBOARD_MOVE_DISTANCE, 0);
286
+ if (event.key === "ArrowRight")
287
+ move(-KEYBOARD_MOVE_DISTANCE, 0);
288
+ return;
169
289
  }
290
+ if (event.key === "ArrowLeft")
291
+ prev();
292
+ if (event.key === "ArrowRight")
293
+ next();
294
+ };
295
+ const removePointer = (event) => {
296
+ const pointers = activePointers.current;
297
+ pointers.splice(0, pointers.length, ...pointers.filter((pointer) => pointer.pointerId !== event.pointerId));
298
+ };
299
+ const addPointer = (event) => {
300
+ event.persist();
301
+ removePointer(event);
302
+ activePointers.current.push(event);
170
303
  };
171
304
  const onPointerDown = (event) => {
172
- if (!activePointer.current) {
173
- event.persist();
174
- activePointer.current = event;
305
+ const pointers = activePointers.current;
306
+ if (event.target instanceof Element &&
307
+ (event.target.classList.contains(cssClass("button")) ||
308
+ event.target.classList.contains(cssClass("icon")) ||
309
+ carouselRef.current?.parentElement?.querySelector(`.${cssClass("toolbar")}`)?.contains(event.target))) {
310
+ return;
175
311
  }
176
- else {
177
- activePointer.current = null;
312
+ addPointer(event);
313
+ if (pointers.length === 2) {
314
+ pinchZoomDistance.current = distance(pointers[0], pointers[1]);
315
+ }
316
+ };
317
+ const onPointerMove = (event) => {
318
+ const pointers = activePointers.current;
319
+ const activePointer = pointers.find((pointer) => pointer.pointerId === event.pointerId);
320
+ if (pointers.length === 2 && pinchZoomDistance.current) {
321
+ addPointer(event);
322
+ const currentDistance = distance(pointers[0], pointers[1]);
323
+ const delta = currentDistance - pinchZoomDistance.current;
324
+ if (Math.abs(delta) > 0) {
325
+ changeZoom(scaleZoom(zoom, delta, PINCH_ZOOM_DISTANCE_FACTOR), {
326
+ clientX: (pointers[0].clientX + pointers[1].clientX) / 2,
327
+ clientY: (pointers[0].clientY + pointers[1].clientY) / 2,
328
+ });
329
+ pinchZoomDistance.current = currentDistance;
330
+ }
331
+ return;
332
+ }
333
+ if (zoom > 1 && activePointer) {
334
+ if (pointers.length === 1) {
335
+ changeOffsets(event.clientX - activePointer.clientX, event.clientY - activePointer.clientY);
336
+ }
337
+ addPointer(event);
178
338
  }
179
339
  };
180
340
  const onPointerUp = (event) => {
181
- if (event.pointerId === activePointer.current?.pointerId) {
182
- const dx = event.clientX - activePointer.current.clientX;
183
- const dy = event.clientY - activePointer.current.clientY;
341
+ const pointers = activePointers.current;
342
+ const activePointer = pointers.find((pointer) => pointer.pointerId === event.pointerId);
343
+ if (activePointer && pointers.length === 1 && zoom === 1) {
344
+ const dx = event.clientX - activePointer.clientX;
345
+ const dy = event.clientY - activePointer.clientY;
184
346
  const deltaX = Math.abs(dx);
185
347
  const deltaY = Math.abs(dy);
186
- if (deltaX > 50 && deltaX > 1.2 * deltaY) {
348
+ if (deltaX > POINTER_SWIPE_DISTANCE && deltaX > PREVAILING_DIRECTION_FACTOR * deltaY) {
187
349
  if (dx > 0) {
188
350
  prev();
189
351
  }
@@ -191,21 +353,33 @@ function useSensors() {
191
353
  next();
192
354
  }
193
355
  }
194
- else if ((deltaY > 50 && deltaY > 1.2 * deltaX && ((closeOnPullUp && dy < 0) || (closeOnPullDown && dy > 0))) ||
356
+ else if ((deltaY > POINTER_SWIPE_DISTANCE &&
357
+ deltaY > PREVAILING_DIRECTION_FACTOR * deltaX &&
358
+ ((closeOnPullUp && dy < 0) || (closeOnPullDown && dy > 0))) ||
195
359
  (closeOnBackdropClick &&
196
- activePointer.current.target instanceof Element &&
197
- Array.from(activePointer.current.target.classList).some((className) => [cssClass("slide"), cssClass("portal")].includes(className)))) {
360
+ activePointer.target instanceof Element &&
361
+ Array.from(activePointer.target.classList).some((className) => [cssClass("slide"), cssClass("portal")].includes(className)))) {
198
362
  close();
199
363
  }
200
- activePointer.current = null;
201
364
  }
365
+ removePointer(event);
202
366
  };
203
367
  const onWheel = (event) => {
368
+ if (event.ctrlKey) {
369
+ if (Math.abs(event.deltaY) > Math.abs(event.deltaX)) {
370
+ changeZoom(scaleZoom(zoom, -event.deltaY, WHEEL_ZOOM_FACTOR), event);
371
+ }
372
+ return;
373
+ }
374
+ if (zoom > 1) {
375
+ changeOffsets(-event.deltaX, -event.deltaY);
376
+ return;
377
+ }
204
378
  if (wheelCooldown.current && wheelCooldownMomentum.current) {
205
379
  if (event.deltaX * wheelCooldownMomentum.current > 0 &&
206
- (event.timeStamp <= wheelCooldown.current + 500 ||
207
- (event.timeStamp <= wheelCooldown.current + 1000 &&
208
- Math.abs(event.deltaX) < 1.2 * Math.abs(wheelCooldownMomentum.current)))) {
380
+ (event.timeStamp <= wheelCooldown.current + WHEEL_SWIPE_COOLDOWN_TIME / 2 ||
381
+ (event.timeStamp <= wheelCooldown.current + WHEEL_SWIPE_COOLDOWN_TIME &&
382
+ Math.abs(event.deltaX) < PREVAILING_DIRECTION_FACTOR * Math.abs(wheelCooldownMomentum.current)))) {
209
383
  wheelCooldownMomentum.current = event.deltaX;
210
384
  return;
211
385
  }
@@ -218,7 +392,7 @@ function useSensors() {
218
392
  const dx = wheelEvents.current.map((e) => e.deltaX).reduce((a, b) => a + b, 0);
219
393
  const deltaX = Math.abs(dx);
220
394
  const deltaY = Math.abs(wheelEvents.current.map((e) => e.deltaY).reduce((a, b) => a + b, 0));
221
- if (deltaX > 100 && deltaX > 1.2 * deltaY) {
395
+ if (deltaX > WHEEL_SWIPE_DISTANCE && deltaX > PREVAILING_DIRECTION_FACTOR * deltaY) {
222
396
  if (dx < 0) {
223
397
  prev();
224
398
  }
@@ -233,12 +407,24 @@ function useSensors() {
233
407
  return {
234
408
  onKeyDown,
235
409
  onPointerDown,
410
+ onPointerMove,
236
411
  onPointerUp,
237
412
  onPointerLeave: onPointerUp,
238
413
  onPointerCancel: onPointerUp,
239
414
  onWheel,
240
415
  };
241
- }, [prev, next, close, closeOnPullUp, closeOnPullDown, closeOnBackdropClick]);
416
+ }, [
417
+ prev,
418
+ next,
419
+ close,
420
+ zoom,
421
+ changeZoom,
422
+ changeOffsets,
423
+ carouselRef,
424
+ closeOnPullUp,
425
+ closeOnPullDown,
426
+ closeOnBackdropClick,
427
+ ]);
242
428
  }
243
429
 
244
430
  function setAttribute(element, attribute, value) {
@@ -334,7 +520,7 @@ function Toolbar() {
334
520
  function Lightbox({ slides, index, setIndex, ...rest }) {
335
521
  if (!Array.isArray(slides) || index === undefined || index < 0 || index >= slides.length)
336
522
  return null;
337
- return (jsx(LightboxContextProvider, { slides, index, ...rest, children: jsx(Controller, { setIndex, children: jsxs(Portal, { children: [jsx(Toolbar, {}), jsx(Carousel, {}), jsx(Navigation, {})] }) }) }));
523
+ return (jsx(LightboxContextProvider, { slides, index, ...rest, children: jsx(Controller, { setIndex, children: jsx(Zoom, { children: jsxs(Portal, { children: [jsx(Toolbar, {}), jsx(Carousel, {}), jsx(Navigation, {})] }) }) }) }));
338
524
  }
339
525
 
340
- export { Lightbox as default };
526
+ export { Lightbox as default, useZoom as unstable_useZoom };
package/dist/styles.css CHANGED
@@ -1 +1 @@
1
- body:has(>.yarll__portal){overscroll-behavior:none}body:has(>.yarll__portal:not(.yarll__no_scroll_lock)){height:100%;overflow:hidden;padding-right:var(--yarll__scrollbar-width,0)}body:has(>.yarll__portal:not(.yarll__no_scroll_lock)) .yarll_fixed{padding-right:var(--yarll__scrollbar-width,0)}.yarll__portal{align-items:center;display:flex;flex-direction:column;inset:0;justify-content:center;outline:none;overflow:hidden;overscroll-behavior:none;position:fixed;touch-action:none;-moz-user-select:none;user-select:none;-webkit-user-select:none;z-index:var(--yarll__portal_zindex,9999);-webkit-touch-callout:none;background-color:var(--yarll__backdrop_color,#000);color:var(--yarll__color,#fff);opacity:1;transition:var(--yarll__fade_transition,opacity .3s ease)}.yarll__portal_closed{opacity:0}.yarll__portal *{box-sizing:border-box}.yarll__carousel{align-self:stretch;flex:1;margin:var(--yarll__carousel_margin,16px);position:relative}.yarll__slide{align-items:center;display:flex;flex-direction:column;inset:0;justify-content:center;position:absolute}.yarll__slide[hidden]{display:none}.yarll__slide_image{display:block;flex:1;max-height:100%;max-width:100%;min-height:0;min-width:0;-o-object-fit:contain;object-fit:contain}.yarll__toolbar{align-items:center;display:flex;position:absolute;right:var(--yarll__toolbar_margin,8px);top:var(--yarll__toolbar_margin,8px);z-index:1}.yarll__toolbar_fixed{align-self:flex-end;margin-inline-end:var(--yarll__toolbar_margin,8px);position:static;z-index:unset}.yarll__toolbar_fixed,.yarll__toolbar_fixed+.yarll__carousel{margin-block-start:var(--yarll__toolbar_margin,8px)}.yarll__button{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--yarll__button_background_color,transparent);border:0;color:var(--yarll__button_color,var(--yarll__button_color,hsla(0,0%,100%,.8)));cursor:pointer;filter:var(--yarll__button_filter,drop-shadow(2px 2px 2px rgba(0,0,0,.8)));margin:0;padding:var(--yarll__button_padding,8px)}.yarll__button:focus-visible{box-shadow:var(--yarll__button_focus_box_shadow,0 0 0 4px #fff);color:var(--yarll__button_color_active,#fff);outline:var(--yarll__button_focus_outline,6px double #000)}@supports not selector(:focus-visible){.yarll__button:focus{box-shadow:var(--yarll__button_focus_box_shadow,0 0 0 4px #fff);color:var(--yarll__button_color_active,#fff);outline:var(--yarll__button_focus_outline,6px double #000)}}@media (hover:hover){.yarll__button:focus-visible:hover,.yarll__button:focus:hover,.yarll__button:hover{color:var(--yarll__button_color_active,#fff)}}.yarll__button_next,.yarll__button_prev{padding:var(--yarll__navigation_button_padding,24px 8px);position:absolute}.yarll__button_prev{left:8px}.yarll__button_next{right:8px}.yarll__button:disabled{color:var(--yarll__button_color_disabled,var(--yarll__button_color_disabled,hsla(0,0%,100%,.4)));cursor:default}.yarll__icon{display:block;height:var(--yarll__icon_size,32px);width:var(--yarll__icon_size,32px)}
1
+ body:has(>.yarll__portal){overscroll-behavior:none}body:has(>.yarll__portal:not(.yarll__no_scroll_lock)){height:100%;overflow:hidden;padding-right:var(--yarll__scrollbar-width,0)}body:has(>.yarll__portal:not(.yarll__no_scroll_lock)) .yarll_fixed{padding-right:var(--yarll__scrollbar-width,0)}.yarll__portal{align-items:center;display:flex;flex-direction:column;inset:0;justify-content:center;outline:none;overflow:hidden;overscroll-behavior:none;position:fixed;touch-action:none;-moz-user-select:none;user-select:none;-webkit-user-select:none;z-index:var(--yarll__portal_zindex,9999);-webkit-touch-callout:none;background-color:var(--yarll__backdrop_color,#000);color:var(--yarll__color,#fff);opacity:1;transition:var(--yarll__fade_transition,opacity .3s ease)}.yarll__portal_closed{opacity:0}.yarll__portal *{box-sizing:border-box}.yarll__carousel{align-self:stretch;flex:1;margin:var(--yarll__carousel_margin,16px);position:relative}.yarll__slide{align-items:center;display:flex;flex-direction:column;inset:0;justify-content:center;position:absolute}.yarll__slide[hidden]{display:none}.yarll__slide_image{display:block;max-height:100%;max-width:100%;min-height:0;min-width:0;-o-object-fit:contain;object-fit:contain}.yarll__toolbar{align-items:center;display:flex;position:absolute;right:var(--yarll__toolbar_margin,8px);top:var(--yarll__toolbar_margin,8px);z-index:1}.yarll__toolbar_fixed{align-self:flex-end;margin-inline-end:var(--yarll__toolbar_margin,8px);position:static;z-index:unset}.yarll__toolbar_fixed,.yarll__toolbar_fixed+.yarll__carousel{margin-block-start:var(--yarll__toolbar_margin,8px)}.yarll__button{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--yarll__button_background_color,transparent);border:0;color:var(--yarll__button_color,var(--yarll__button_color,hsla(0,0%,100%,.8)));cursor:pointer;filter:var(--yarll__button_filter,drop-shadow(2px 2px 2px rgba(0,0,0,.8)));margin:0;padding:var(--yarll__button_padding,8px)}.yarll__button:focus-visible{box-shadow:var(--yarll__button_focus_box_shadow,0 0 0 4px #fff);color:var(--yarll__button_color_active,#fff);outline:var(--yarll__button_focus_outline,6px double #000)}@supports not selector(:focus-visible){.yarll__button:focus{box-shadow:var(--yarll__button_focus_box_shadow,0 0 0 4px #fff);color:var(--yarll__button_color_active,#fff);outline:var(--yarll__button_focus_outline,6px double #000)}}@media (hover:hover){.yarll__button:focus-visible:hover,.yarll__button:focus:hover,.yarll__button:hover{color:var(--yarll__button_color_active,#fff)}}.yarll__button_next,.yarll__button_prev{padding:var(--yarll__navigation_button_padding,24px 8px);position:absolute}.yarll__button_prev{left:8px}.yarll__button_next{right:8px}.yarll__button:disabled{color:var(--yarll__button_color_disabled,var(--yarll__button_color_disabled,hsla(0,0%,100%,.4)));cursor:default}.yarll__icon{display:block;height:var(--yarll__icon_size,32px);pointer-events:none;width:var(--yarll__icon_size,32px)}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yet-another-react-lightbox-lite",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Lightweight React lightbox component",
5
5
  "author": "Igor Danchenko",
6
6
  "license": "MIT",