@lumx/react 3.7.5 → 3.7.6-alpha.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/index.d.ts +76 -12
- package/index.js +1466 -718
- package/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/image-block/ImageBlock.tsx +13 -42
- package/src/components/image-block/ImageCaption.tsx +73 -0
- package/src/components/image-block/constants.ts +11 -0
- package/src/components/image-lightbox/ImageLightbox.stories.tsx +163 -0
- package/src/components/image-lightbox/ImageLightbox.test.tsx +252 -0
- package/src/components/image-lightbox/ImageLightbox.tsx +72 -0
- package/src/components/image-lightbox/constants.ts +11 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/image-lightbox/internal/ImageSlide.tsx +99 -0
- package/src/components/image-lightbox/internal/ImageSlideshow.tsx +158 -0
- package/src/components/image-lightbox/internal/useAnimateScroll.ts +55 -0
- package/src/components/image-lightbox/internal/usePointerZoom.ts +148 -0
- package/src/components/image-lightbox/types.ts +49 -0
- package/src/components/image-lightbox/useImageLightbox.tsx +122 -0
- package/src/components/lightbox/Lightbox.tsx +13 -12
- package/src/components/thumbnail/useFocusPointStyle.tsx +3 -4
- package/src/hooks/useElementSizeDependentOfWindowSize.ts +32 -0
- package/src/hooks/useImageSize.ts +17 -0
- package/src/index.ts +1 -0
- package/src/utils/findImage.tsx +3 -0
- package/src/utils/getPrefersReducedMotion.ts +6 -0
- package/src/utils/startViewTransition.ts +54 -0
- package/src/utils/type.ts +15 -0
- package/src/utils/unref.ts +6 -0
- package/src/hooks/useOnResize.ts +0 -41
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { getRootClassName } from '@lumx/react/utils/className';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Component display name.
|
|
5
|
+
*/
|
|
6
|
+
export const COMPONENT_NAME = 'ImageLightbox';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Component default class name and class prefix.
|
|
10
|
+
*/
|
|
11
|
+
export const CLASSNAME = getRootClassName(COMPONENT_NAME);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { SlideshowItem, Thumbnail } from '@lumx/react';
|
|
4
|
+
import { mergeRefs } from '@lumx/react/utils/mergeRefs';
|
|
5
|
+
import { useElementSizeDependentOfWindowSize } from '@lumx/react/hooks/useElementSizeDependentOfWindowSize';
|
|
6
|
+
import { useImageSize } from '@lumx/react/hooks/useImageSize';
|
|
7
|
+
|
|
8
|
+
import { getPrefersReducedMotion } from '@lumx/react/utils/getPrefersReducedMotion';
|
|
9
|
+
import { CLASSNAME } from '../constants';
|
|
10
|
+
import { usePointerZoom } from './usePointerZoom';
|
|
11
|
+
import { useAnimateScroll } from './useAnimateScroll';
|
|
12
|
+
import type { InheritedThumbnailProps } from '../types';
|
|
13
|
+
|
|
14
|
+
export interface ImageSlideProps extends InheritedThumbnailProps {
|
|
15
|
+
isActive?: boolean;
|
|
16
|
+
scale?: number;
|
|
17
|
+
onScaleChange?: (value: number) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Internal image slide component for ImageLightbox */
|
|
21
|
+
export const ImageSlide = React.memo((props: ImageSlideProps) => {
|
|
22
|
+
const { isActive, scale, onScaleChange, image, imgRef: propImgRef, imgProps, alt } = props;
|
|
23
|
+
|
|
24
|
+
// Get scroll area size
|
|
25
|
+
const scrollAreaRef = React.useRef<HTMLDivElement>(null);
|
|
26
|
+
const [scrollAreaSize, updateSize] = useElementSizeDependentOfWindowSize(scrollAreaRef);
|
|
27
|
+
React.useEffect(() => {
|
|
28
|
+
// Update size when active
|
|
29
|
+
if (isActive) updateSize();
|
|
30
|
+
}, [isActive, updateSize]);
|
|
31
|
+
|
|
32
|
+
// Get image size
|
|
33
|
+
const imgRef = React.useRef<HTMLImageElement>(null);
|
|
34
|
+
const imageSize = useImageSize(imgRef, () => {
|
|
35
|
+
const initialWidth = Number.parseInt(imgProps?.width as any, 10);
|
|
36
|
+
const initialHeight = Number.parseInt(imgProps?.height as any, 10);
|
|
37
|
+
return initialWidth && initialHeight ? { width: initialWidth, height: initialHeight } : null;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Calculate new image size with scale
|
|
41
|
+
const scaledImageSize = React.useMemo(() => {
|
|
42
|
+
if (!scrollAreaSize || !imageSize) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const horizontalScale = scrollAreaSize.width / imageSize.width;
|
|
46
|
+
const verticalScale = scrollAreaSize.height / imageSize.height;
|
|
47
|
+
const baseScale = Math.min(1, Math.min(horizontalScale, verticalScale));
|
|
48
|
+
return {
|
|
49
|
+
width: imageSize.width * baseScale * (scale ?? 1),
|
|
50
|
+
height: imageSize.height * baseScale * (scale ?? 1),
|
|
51
|
+
};
|
|
52
|
+
}, [scrollAreaSize, imageSize, scale]);
|
|
53
|
+
|
|
54
|
+
// Animate scroll to preserve the center of the current visible window in the scroll area
|
|
55
|
+
const animateScroll = useAnimateScroll(scrollAreaRef);
|
|
56
|
+
|
|
57
|
+
// Zoom via mouse wheel or multi-touch pinch zoom
|
|
58
|
+
const isPointerZooming = usePointerZoom(scrollAreaRef, onScaleChange, animateScroll);
|
|
59
|
+
|
|
60
|
+
// Animate scroll on scale change
|
|
61
|
+
React.useLayoutEffect(() => {
|
|
62
|
+
if (scale && !isPointerZooming) {
|
|
63
|
+
animateScroll();
|
|
64
|
+
}
|
|
65
|
+
}, [isPointerZooming, scale, animateScroll]);
|
|
66
|
+
|
|
67
|
+
const isScrollable =
|
|
68
|
+
scaledImageSize &&
|
|
69
|
+
scrollAreaSize &&
|
|
70
|
+
(scaledImageSize.width > scrollAreaSize.width || scaledImageSize.height > scrollAreaSize.height);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<SlideshowItem
|
|
74
|
+
ref={scrollAreaRef}
|
|
75
|
+
// Make it accessible to keyboard nav when the zone is scrollable
|
|
76
|
+
tabIndex={isScrollable ? 0 : undefined}
|
|
77
|
+
className={`${CLASSNAME}__image-slide`}
|
|
78
|
+
>
|
|
79
|
+
<Thumbnail
|
|
80
|
+
imgRef={mergeRefs(imgRef, propImgRef)}
|
|
81
|
+
image={image}
|
|
82
|
+
alt={alt}
|
|
83
|
+
className={`${CLASSNAME}__thumbnail`}
|
|
84
|
+
imgProps={{
|
|
85
|
+
...imgProps,
|
|
86
|
+
style: {
|
|
87
|
+
...imgProps?.style,
|
|
88
|
+
...(scaledImageSize || {
|
|
89
|
+
maxHeight: scrollAreaSize?.height,
|
|
90
|
+
maxWidth: scrollAreaSize?.width,
|
|
91
|
+
}),
|
|
92
|
+
// Only animate when scale is set, and we are not pointer zooming and the user does not prefer reduced motion
|
|
93
|
+
transition: scale && !isPointerZooming && !getPrefersReducedMotion() ? 'all 250ms' : undefined,
|
|
94
|
+
},
|
|
95
|
+
}}
|
|
96
|
+
/>
|
|
97
|
+
</SlideshowItem>
|
|
98
|
+
);
|
|
99
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import { mdiMagnifyMinusOutline, mdiMagnifyPlusOutline } from '@lumx/icons';
|
|
4
|
+
import { FlexBox, IconButton, Slides, SlideshowControls } from '@lumx/react';
|
|
5
|
+
import { mergeRefs } from '@lumx/react/utils/mergeRefs';
|
|
6
|
+
|
|
7
|
+
import { ImageCaption } from '../../image-block/ImageCaption';
|
|
8
|
+
import { CLASSNAME } from '../constants';
|
|
9
|
+
import type { ImagesProps, InheritedSlideShowProps, ZoomProps } from '../types';
|
|
10
|
+
import { ImageSlide } from './ImageSlide';
|
|
11
|
+
|
|
12
|
+
export interface ImageSlideshowProps extends InheritedSlideShowProps, ZoomProps, ImagesProps {
|
|
13
|
+
currentPaginationItemRef?: React.Ref<HTMLButtonElement>;
|
|
14
|
+
footerRef?: React.Ref<HTMLDivElement>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Internal image slideshow component for ImageLightbox */
|
|
18
|
+
export const ImageSlideshow: React.FC<ImageSlideshowProps> = ({
|
|
19
|
+
activeImageIndex,
|
|
20
|
+
images,
|
|
21
|
+
slideGroupLabel,
|
|
22
|
+
zoomInButtonProps,
|
|
23
|
+
zoomOutButtonProps,
|
|
24
|
+
slideshowControlsProps,
|
|
25
|
+
currentPaginationItemRef,
|
|
26
|
+
footerRef,
|
|
27
|
+
activeImageRef,
|
|
28
|
+
}) => {
|
|
29
|
+
const {
|
|
30
|
+
activeIndex,
|
|
31
|
+
slideshowId,
|
|
32
|
+
setSlideshow,
|
|
33
|
+
slideshowSlidesId,
|
|
34
|
+
slidesCount,
|
|
35
|
+
onNextClick,
|
|
36
|
+
onPaginationClick,
|
|
37
|
+
onPreviousClick,
|
|
38
|
+
toggleAutoPlay,
|
|
39
|
+
} = SlideshowControls.useSlideshowControls({
|
|
40
|
+
itemsCount: images.length,
|
|
41
|
+
activeIndex: activeImageIndex,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Image metadata (caption)
|
|
45
|
+
const title = images[activeIndex]?.title;
|
|
46
|
+
const description = images[activeIndex]?.description;
|
|
47
|
+
const tags = images[activeIndex]?.tags;
|
|
48
|
+
const metadata =
|
|
49
|
+
title || description || tags ? (
|
|
50
|
+
<ImageCaption theme="dark" as="div" title={title} description={description} tags={tags} align="center" />
|
|
51
|
+
) : null;
|
|
52
|
+
|
|
53
|
+
// Slideshow controls
|
|
54
|
+
const slideShowControls =
|
|
55
|
+
slidesCount > 1 && slideshowControlsProps ? (
|
|
56
|
+
<SlideshowControls
|
|
57
|
+
theme="dark"
|
|
58
|
+
activeIndex={activeIndex}
|
|
59
|
+
slidesCount={slidesCount}
|
|
60
|
+
onNextClick={onNextClick}
|
|
61
|
+
onPreviousClick={onPreviousClick}
|
|
62
|
+
onPaginationClick={onPaginationClick}
|
|
63
|
+
{...slideshowControlsProps}
|
|
64
|
+
paginationItemProps={(index: number) => {
|
|
65
|
+
const props = slideshowControlsProps?.paginationItemProps?.(index) || {};
|
|
66
|
+
return {
|
|
67
|
+
...props,
|
|
68
|
+
ref: mergeRefs(
|
|
69
|
+
(props as any)?.ref,
|
|
70
|
+
// Focus the active pagination item once on mount
|
|
71
|
+
activeIndex === index ? currentPaginationItemRef : undefined,
|
|
72
|
+
),
|
|
73
|
+
};
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
) : null;
|
|
77
|
+
|
|
78
|
+
// Zoom controls
|
|
79
|
+
const [scale, setScale] = React.useState<number | undefined>(undefined);
|
|
80
|
+
const zoomEnabled = zoomInButtonProps && zoomOutButtonProps;
|
|
81
|
+
const onScaleChange = React.useMemo(() => {
|
|
82
|
+
if (!zoomEnabled) return undefined;
|
|
83
|
+
return (newScale: number) => {
|
|
84
|
+
setScale((prevScale = 1) => Math.max(1, newScale * prevScale));
|
|
85
|
+
};
|
|
86
|
+
}, [zoomEnabled]);
|
|
87
|
+
const zoomIn = React.useCallback(() => onScaleChange?.(1.5), [onScaleChange]);
|
|
88
|
+
const zoomOut = React.useCallback(() => onScaleChange?.(0.5), [onScaleChange]);
|
|
89
|
+
React.useEffect(() => {
|
|
90
|
+
// Reset scale on slide change
|
|
91
|
+
if (activeIndex) setScale(undefined);
|
|
92
|
+
}, [activeIndex]);
|
|
93
|
+
const zoomControls = zoomEnabled && (
|
|
94
|
+
<>
|
|
95
|
+
<IconButton
|
|
96
|
+
{...zoomInButtonProps}
|
|
97
|
+
theme="dark"
|
|
98
|
+
emphasis="low"
|
|
99
|
+
icon={mdiMagnifyPlusOutline}
|
|
100
|
+
onClick={zoomIn}
|
|
101
|
+
/>
|
|
102
|
+
<IconButton
|
|
103
|
+
{...zoomOutButtonProps}
|
|
104
|
+
theme="dark"
|
|
105
|
+
emphasis="low"
|
|
106
|
+
isDisabled={!scale || scale <= 1}
|
|
107
|
+
icon={mdiMagnifyMinusOutline}
|
|
108
|
+
onClick={zoomOut}
|
|
109
|
+
/>
|
|
110
|
+
</>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<>
|
|
115
|
+
<Slides
|
|
116
|
+
activeIndex={activeIndex}
|
|
117
|
+
theme="dark"
|
|
118
|
+
slideGroupLabel={slideGroupLabel}
|
|
119
|
+
fillHeight
|
|
120
|
+
id={slideshowId}
|
|
121
|
+
ref={setSlideshow}
|
|
122
|
+
slidesId={slideshowSlidesId}
|
|
123
|
+
toggleAutoPlay={toggleAutoPlay}
|
|
124
|
+
>
|
|
125
|
+
{images.map(({ image, imgRef, ...props }, index) => {
|
|
126
|
+
const isActive = index === activeIndex;
|
|
127
|
+
return (
|
|
128
|
+
<ImageSlide
|
|
129
|
+
{...props}
|
|
130
|
+
isActive={isActive}
|
|
131
|
+
key={image}
|
|
132
|
+
imgRef={mergeRefs(imgRef, isActive ? activeImageRef : undefined)}
|
|
133
|
+
image={image}
|
|
134
|
+
scale={isActive ? scale : undefined}
|
|
135
|
+
onScaleChange={onScaleChange}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</Slides>
|
|
140
|
+
{(metadata || slideShowControls || zoomControls) && (
|
|
141
|
+
<FlexBox
|
|
142
|
+
ref={footerRef}
|
|
143
|
+
className={`${CLASSNAME}__footer`}
|
|
144
|
+
orientation="vertical"
|
|
145
|
+
vAlign="center"
|
|
146
|
+
gap="big"
|
|
147
|
+
>
|
|
148
|
+
{metadata}
|
|
149
|
+
|
|
150
|
+
<FlexBox className={`${CLASSNAME}__footer-actions`} orientation="horizontal" gap="regular">
|
|
151
|
+
{slideShowControls}
|
|
152
|
+
{zoomControls}
|
|
153
|
+
</FlexBox>
|
|
154
|
+
</FlexBox>
|
|
155
|
+
)}
|
|
156
|
+
</>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Point, RectSize } from '@lumx/react/utils/type';
|
|
3
|
+
|
|
4
|
+
/** Maintains the scroll position centered relative to the original scroll area's dimensions when the content scales. */
|
|
5
|
+
export function useAnimateScroll(scrollAreaRef: React.RefObject<HTMLDivElement>) {
|
|
6
|
+
return React.useMemo(() => {
|
|
7
|
+
let animationFrame: number | null = null;
|
|
8
|
+
|
|
9
|
+
return function animate(centerPoint?: Point, initialScrollAreaSize?: RectSize) {
|
|
10
|
+
const scrollArea = scrollAreaRef.current as HTMLDivElement;
|
|
11
|
+
if (!scrollArea) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Cancel previously running animation
|
|
16
|
+
if (animationFrame) cancelAnimationFrame(animationFrame);
|
|
17
|
+
|
|
18
|
+
// Center on the given point or else on the scroll area visual center
|
|
19
|
+
const clientHeightRatio = centerPoint?.y ? centerPoint.y / scrollArea.clientHeight : 0.5;
|
|
20
|
+
const clientWidthRatio = centerPoint?.x ? centerPoint.x / scrollArea.clientWidth : 0.5;
|
|
21
|
+
|
|
22
|
+
const initialScrollHeight = initialScrollAreaSize?.height || scrollArea.scrollHeight;
|
|
23
|
+
const initialScrollWidth = initialScrollAreaSize?.width || scrollArea.scrollWidth;
|
|
24
|
+
|
|
25
|
+
const heightCenter = scrollArea.scrollTop + scrollArea.clientHeight * clientHeightRatio;
|
|
26
|
+
const heightRatio = heightCenter / initialScrollHeight;
|
|
27
|
+
|
|
28
|
+
const widthCenter = scrollArea.scrollLeft + scrollArea.clientWidth * clientWidthRatio;
|
|
29
|
+
const widthRatio = widthCenter / initialScrollWidth;
|
|
30
|
+
|
|
31
|
+
let prevScrollHeight = 0;
|
|
32
|
+
let prevScrollWidth = 0;
|
|
33
|
+
|
|
34
|
+
function nextFrame() {
|
|
35
|
+
const { scrollHeight, scrollWidth, clientHeight, clientWidth } = scrollArea;
|
|
36
|
+
|
|
37
|
+
// Scroll area stopped expanding => stop animation
|
|
38
|
+
if (scrollHeight === prevScrollHeight && scrollWidth === prevScrollWidth) {
|
|
39
|
+
animationFrame = null;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Compute next scroll position
|
|
44
|
+
const top = heightRatio * scrollHeight - clientHeight * clientHeightRatio;
|
|
45
|
+
const left = widthRatio * scrollWidth - clientWidth * clientWidthRatio;
|
|
46
|
+
|
|
47
|
+
scrollArea.scrollTo({ top, left });
|
|
48
|
+
prevScrollHeight = scrollHeight;
|
|
49
|
+
prevScrollWidth = scrollWidth;
|
|
50
|
+
animationFrame = requestAnimationFrame(nextFrame);
|
|
51
|
+
}
|
|
52
|
+
animationFrame = requestAnimationFrame(nextFrame);
|
|
53
|
+
};
|
|
54
|
+
}, [scrollAreaRef]);
|
|
55
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Point } from '@lumx/react/utils/type';
|
|
3
|
+
import type { useAnimateScroll } from './useAnimateScroll';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Listen to mouse wheel + ctrl and multi-pointer pinch to zoom
|
|
7
|
+
*/
|
|
8
|
+
export function usePointerZoom(
|
|
9
|
+
scrollAreaRef: React.RefObject<HTMLDivElement>,
|
|
10
|
+
onScaleChange: ((value: number) => void) | undefined,
|
|
11
|
+
animateScroll: ReturnType<typeof useAnimateScroll>,
|
|
12
|
+
) {
|
|
13
|
+
const [isPointerZooming, setPointerZooming] = React.useState(false);
|
|
14
|
+
React.useEffect(() => {
|
|
15
|
+
const scrollArea = scrollAreaRef.current as HTMLDivElement;
|
|
16
|
+
if (!scrollArea || !onScaleChange) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let animationFrame: number | null;
|
|
21
|
+
let zoomStateTimeoutId: ReturnType<typeof setTimeout> | null;
|
|
22
|
+
|
|
23
|
+
function updateScaleOnNextFrame(newScale: number, mousePosition: Point): void {
|
|
24
|
+
// Cancel previously scheduled frame
|
|
25
|
+
if (animationFrame) cancelAnimationFrame(animationFrame);
|
|
26
|
+
|
|
27
|
+
// Cancel previously scheduled zoom state change
|
|
28
|
+
if (zoomStateTimeoutId) clearTimeout(zoomStateTimeoutId);
|
|
29
|
+
|
|
30
|
+
function nextFrame() {
|
|
31
|
+
setPointerZooming(true);
|
|
32
|
+
onScaleChange?.(newScale);
|
|
33
|
+
|
|
34
|
+
animationFrame = null;
|
|
35
|
+
// Wait a bit before indicating the pointer zooming is finished
|
|
36
|
+
zoomStateTimeoutId = setTimeout(() => setPointerZooming(false), 100);
|
|
37
|
+
}
|
|
38
|
+
animationFrame = requestAnimationFrame(nextFrame);
|
|
39
|
+
|
|
40
|
+
// Animate scroll in parallel (centering on the current mouse position)
|
|
41
|
+
animateScroll(mousePosition, {
|
|
42
|
+
width: scrollArea.scrollWidth,
|
|
43
|
+
height: scrollArea.scrollHeight,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function onWheel(event: WheelEvent) {
|
|
48
|
+
if (!event.ctrlKey) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
event.preventDefault();
|
|
52
|
+
const newScale = Math.exp(-event.deltaY / 50);
|
|
53
|
+
|
|
54
|
+
// Update scale on next frame (focused on the mouse position)
|
|
55
|
+
updateScaleOnNextFrame(newScale, {
|
|
56
|
+
x: event.pageX,
|
|
57
|
+
y: event.pageY,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const activePointers: Record<PointerEvent['pointerId'], PointerEvent> = {};
|
|
62
|
+
let prevDistance: number | null = null;
|
|
63
|
+
let previousCenterPoint: Point | null = null;
|
|
64
|
+
|
|
65
|
+
function onPointerDown(event: PointerEvent) {
|
|
66
|
+
activePointers[event.pointerId] = event;
|
|
67
|
+
}
|
|
68
|
+
function onPointerMove(event: PointerEvent) {
|
|
69
|
+
// Update pointer in cache
|
|
70
|
+
if (activePointers[event.pointerId]) {
|
|
71
|
+
activePointers[event.pointerId] = event;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const pointers = Object.values(activePointers);
|
|
75
|
+
|
|
76
|
+
// Make sure we run computation on one of the pointer in the group
|
|
77
|
+
if (pointers[0].pointerId !== event.pointerId) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Centered point between all pointers
|
|
82
|
+
const centerPoint: Point = {
|
|
83
|
+
x: pointers.reduce((x, { clientX }) => x + clientX, 0) / pointers.length,
|
|
84
|
+
y: pointers.reduce((y, { clientY }) => y + clientY, 0) / pointers.length,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Movement of the center point
|
|
88
|
+
const deltaCenterPoint = previousCenterPoint && {
|
|
89
|
+
left: previousCenterPoint.x - centerPoint.x,
|
|
90
|
+
top: previousCenterPoint.y - centerPoint.y,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Pan X & Y
|
|
94
|
+
if (deltaCenterPoint) {
|
|
95
|
+
// Apply movement of the center point to the scroll
|
|
96
|
+
scrollArea.scrollBy({
|
|
97
|
+
top: deltaCenterPoint.top / 2,
|
|
98
|
+
left: deltaCenterPoint.left / 2,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Pinch to zoom
|
|
103
|
+
if (pointers.length === 2) {
|
|
104
|
+
const [pointer1, pointer2] = pointers;
|
|
105
|
+
const distance = Math.hypot(pointer2.clientX - pointer1.clientX, pointer2.clientY - pointer1.clientY);
|
|
106
|
+
|
|
107
|
+
if (prevDistance && deltaCenterPoint) {
|
|
108
|
+
const delta = prevDistance - distance;
|
|
109
|
+
const absDelta = Math.abs(delta);
|
|
110
|
+
|
|
111
|
+
// Zoom only if we are "pinching" more than we are moving the pointers
|
|
112
|
+
if (absDelta > Math.abs(deltaCenterPoint.left) && absDelta > Math.abs(deltaCenterPoint.top)) {
|
|
113
|
+
// Update scale on next frame (focused on the center point between the two pointers)
|
|
114
|
+
const newScale = Math.exp(-delta / 100);
|
|
115
|
+
updateScaleOnNextFrame(newScale, centerPoint);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
prevDistance = distance;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
previousCenterPoint = centerPoint;
|
|
123
|
+
}
|
|
124
|
+
function onPointerUp(event: PointerEvent) {
|
|
125
|
+
prevDistance = null;
|
|
126
|
+
previousCenterPoint = null;
|
|
127
|
+
delete activePointers[event.pointerId];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
scrollArea.addEventListener('wheel', onWheel, { passive: false });
|
|
131
|
+
const isMultiTouch = navigator.maxTouchPoints >= 2;
|
|
132
|
+
if (isMultiTouch) {
|
|
133
|
+
scrollArea.addEventListener('pointerdown', onPointerDown);
|
|
134
|
+
scrollArea.addEventListener('pointermove', onPointerMove);
|
|
135
|
+
scrollArea.addEventListener('pointerup', onPointerUp);
|
|
136
|
+
}
|
|
137
|
+
return () => {
|
|
138
|
+
scrollArea.removeEventListener('wheel', onWheel);
|
|
139
|
+
if (isMultiTouch) {
|
|
140
|
+
scrollArea.removeEventListener('pointerdown', onPointerDown);
|
|
141
|
+
scrollArea.removeEventListener('pointermove', onPointerMove);
|
|
142
|
+
scrollArea.removeEventListener('pointerup', onPointerUp);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}, [animateScroll, onScaleChange, scrollAreaRef]);
|
|
146
|
+
|
|
147
|
+
return isPointerZooming;
|
|
148
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import type { IconButtonProps, LightboxProps, SlideshowProps, ThumbnailProps } from '@lumx/react';
|
|
4
|
+
import type { HasClassName } from '@lumx/react/utils/type';
|
|
5
|
+
import type { ImageCaptionMetadata } from '@lumx/react/components/image-block/ImageCaption';
|
|
6
|
+
|
|
7
|
+
export type InheritedSlideShowProps = Pick<
|
|
8
|
+
SlideshowProps,
|
|
9
|
+
'activeIndex' | 'slideshowControlsProps' | 'slideGroupLabel'
|
|
10
|
+
>;
|
|
11
|
+
|
|
12
|
+
export interface ZoomProps {
|
|
13
|
+
/** */
|
|
14
|
+
zoomInButtonProps?: IconButtonProps;
|
|
15
|
+
zoomOutButtonProps?: IconButtonProps;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type InheritedThumbnailProps = Pick<ThumbnailProps, 'image' | 'alt' | 'imgProps' | 'imgRef'>;
|
|
19
|
+
|
|
20
|
+
export type InheritedImageMetadata = Pick<ImageCaptionMetadata, 'title' | 'description' | 'tags'>;
|
|
21
|
+
|
|
22
|
+
export type ImageProps = InheritedThumbnailProps & InheritedImageMetadata;
|
|
23
|
+
|
|
24
|
+
export interface ImagesProps {
|
|
25
|
+
/** Index of the active thumbnail to show on open */
|
|
26
|
+
activeImageIndex?: number;
|
|
27
|
+
/** List of images to display */
|
|
28
|
+
images: Array<ImageProps>;
|
|
29
|
+
/** Ref of the active image when the lightbox is open */
|
|
30
|
+
activeImageRef?: React.Ref<HTMLImageElement>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type InheritedLightboxProps = Pick<LightboxProps, 'isOpen' | 'parentElement' | 'onClose' | 'closeButtonProps'>;
|
|
34
|
+
|
|
35
|
+
export type InheritedAriaAttributes = Pick<React.AriaAttributes, 'aria-label' | 'aria-labelledby'>;
|
|
36
|
+
|
|
37
|
+
export type ForwardedProps = React.ComponentPropsWithoutRef<'div'>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ImageLightbox component props
|
|
41
|
+
*/
|
|
42
|
+
export interface ImageLightboxProps
|
|
43
|
+
extends HasClassName,
|
|
44
|
+
ZoomProps,
|
|
45
|
+
ImagesProps,
|
|
46
|
+
InheritedSlideShowProps,
|
|
47
|
+
InheritedLightboxProps,
|
|
48
|
+
InheritedAriaAttributes,
|
|
49
|
+
ForwardedProps {}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
import memoize from 'lodash/memoize';
|
|
4
|
+
|
|
5
|
+
import { startViewTransition } from '@lumx/react/utils/startViewTransition';
|
|
6
|
+
import { findImage } from '@lumx/react/utils/findImage';
|
|
7
|
+
|
|
8
|
+
import type { ImageLightboxProps } from './types';
|
|
9
|
+
import { CLASSNAME } from './constants';
|
|
10
|
+
|
|
11
|
+
/** Subset of the ImageLightboxProps managed by the useImageLightbox hook */
|
|
12
|
+
type ManagedProps = Pick<
|
|
13
|
+
ImageLightboxProps,
|
|
14
|
+
'isOpen' | 'images' | 'parentElement' | 'activeImageRef' | 'onClose' | 'activeImageIndex'
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
const EMPTY_PROPS: ManagedProps = { isOpen: false, images: [], parentElement: React.createRef() };
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Set up an ImageLightbox with images and triggers.
|
|
21
|
+
*
|
|
22
|
+
* - Associate a trigger with the lightbox to properly focus the trigger on close
|
|
23
|
+
* - Associate a trigger with an image to display on open
|
|
24
|
+
* - Automatically provide a view transition between an image trigger and the displayed image on open & close
|
|
25
|
+
*
|
|
26
|
+
* @param images Images to display in the image lightbox
|
|
27
|
+
*/
|
|
28
|
+
export function useImageLightbox(images: ImageLightboxProps['images'] = []): {
|
|
29
|
+
/**
|
|
30
|
+
* Generates trigger props
|
|
31
|
+
* @param index Provide an index to choose which image to display when the image lightbox opens.
|
|
32
|
+
* */
|
|
33
|
+
getTriggerProps: (index?: number) => { onClick: React.MouseEventHandler; ref: React.Ref<any> };
|
|
34
|
+
/** Props to forward to the ImageLightbox */
|
|
35
|
+
imageLightboxProps: ManagedProps;
|
|
36
|
+
} {
|
|
37
|
+
const imagesPropsRef = React.useRef(images);
|
|
38
|
+
React.useEffect(() => {
|
|
39
|
+
imagesPropsRef.current = images.map((props) => ({ imgRef: React.createRef(), ...props }));
|
|
40
|
+
}, [images]);
|
|
41
|
+
|
|
42
|
+
const currentImageRef = React.useRef<HTMLImageElement>(null);
|
|
43
|
+
const [imageLightboxProps, setImageLightboxProps] = React.useState<ManagedProps>(EMPTY_PROPS);
|
|
44
|
+
|
|
45
|
+
const getTriggerProps = React.useMemo(() => {
|
|
46
|
+
const triggerImageRefs: Record<number, React.RefObject<HTMLImageElement>> = {};
|
|
47
|
+
|
|
48
|
+
async function closeLightbox() {
|
|
49
|
+
const currentImage = currentImageRef.current;
|
|
50
|
+
if (!currentImage) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const currentIndex = imagesPropsRef.current.findIndex(
|
|
54
|
+
({ imgRef }) => (imgRef as any)?.current === currentImage,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
await startViewTransition({
|
|
58
|
+
changes() {
|
|
59
|
+
// Close lightbox with reset empty props
|
|
60
|
+
setImageLightboxProps(({ parentElement }) => ({ ...EMPTY_PROPS, parentElement }));
|
|
61
|
+
},
|
|
62
|
+
// Morph from the image in lightbox to the image in trigger
|
|
63
|
+
viewTransitionName: {
|
|
64
|
+
source: currentImageRef,
|
|
65
|
+
//source: imageIsVisible ? currentImageRef : null,
|
|
66
|
+
target: triggerImageRefs[currentIndex],
|
|
67
|
+
name: CLASSNAME,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function openLightbox(triggerElement: HTMLElement, index?: number) {
|
|
73
|
+
// If we find an image inside the trigger, animate it in transition with the opening image
|
|
74
|
+
const triggerImage = triggerImageRefs[index as any]?.current || findImage(triggerElement);
|
|
75
|
+
|
|
76
|
+
// Inject the trigger image size as a fallback for better loading state
|
|
77
|
+
const imagesWithFallbackSize = imagesPropsRef.current.map((image, idx) => {
|
|
78
|
+
if (triggerImage && idx === index && !image.imgProps?.width && !image.imgProps?.height) {
|
|
79
|
+
const imgProps = {
|
|
80
|
+
...image.imgProps,
|
|
81
|
+
height: triggerImage.naturalHeight,
|
|
82
|
+
width: triggerImage.naturalWidth,
|
|
83
|
+
};
|
|
84
|
+
return { ...image, imgProps };
|
|
85
|
+
}
|
|
86
|
+
return image;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await startViewTransition({
|
|
90
|
+
changes: () => {
|
|
91
|
+
// Open lightbox with setup props
|
|
92
|
+
setImageLightboxProps({
|
|
93
|
+
activeImageRef: currentImageRef,
|
|
94
|
+
parentElement: { current: triggerElement },
|
|
95
|
+
isOpen: true,
|
|
96
|
+
onClose: closeLightbox,
|
|
97
|
+
images: imagesWithFallbackSize,
|
|
98
|
+
activeImageIndex: index || 0,
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
// Morph from the image in trigger to the image in lightbox
|
|
102
|
+
viewTransitionName: {
|
|
103
|
+
source: triggerImage,
|
|
104
|
+
target: currentImageRef,
|
|
105
|
+
name: CLASSNAME,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return memoize((index?: number) => ({
|
|
111
|
+
ref(element: HTMLElement | null) {
|
|
112
|
+
const triggerImage = findImage(element);
|
|
113
|
+
if (index !== undefined && triggerImage) triggerImageRefs[index] = { current: triggerImage };
|
|
114
|
+
},
|
|
115
|
+
onClick(e: React.MouseEvent) {
|
|
116
|
+
openLightbox(e.target as HTMLElement, index);
|
|
117
|
+
},
|
|
118
|
+
}));
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
return { getTriggerProps, imageLightboxProps };
|
|
122
|
+
}
|