Skip to content

Commit 469c624

Browse files
committed
feat(Gallery): add image scaling
1 parent 85f8c27 commit 469c624

File tree

11 files changed

+916
-9
lines changed

11 files changed

+916
-9
lines changed

src/components/Gallery/__stories__/Gallery.stories.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,39 @@ const SingleItemGalleryTemplate: StoryFn<GalleryProps> = () => {
337337
};
338338

339339
export const SingleItemGallery = SingleItemGalleryTemplate.bind({});
340+
341+
const SmallImagesTemplate: StoryFn<GalleryProps> = () => {
342+
const [open, setOpen] = React.useState(false);
343+
344+
const handleToggle = React.useCallback(() => {
345+
setOpen(false);
346+
}, []);
347+
348+
const handleOpen = React.useCallback(() => {
349+
setOpen(true);
350+
}, []);
351+
352+
return (
353+
<React.Fragment>
354+
<Button onClick={handleOpen} view="action" size="l">
355+
Open gallery
356+
</Button>
357+
<Gallery open={open} onOpenChange={handleToggle}>
358+
<GalleryItem
359+
{...getGalleryItemImage({
360+
src: 'https://i.pinimg.com/236x/6d/4a/02/6d4a022b29ce165692672be0d35ec1df.jpg',
361+
name: '1',
362+
})}
363+
/>
364+
<GalleryItem
365+
{...getGalleryItemImage({
366+
src: 'https://i.pinimg.com/236x/5a/ec/22/5aec2220c6ebe1869b059801e2107044.jpg',
367+
name: '2',
368+
})}
369+
/>
370+
</Gallery>
371+
</React.Fragment>
372+
);
373+
};
374+
375+
export const SmallImages = SmallImagesTemplate.bind({});

src/components/Gallery/components/views/ImageView/ImageView.scss

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,32 @@
33
$block: '.#{variables.$ns}gallery-image-view';
44

55
#{$block} {
6-
max-width: 100%;
7-
max-height: 100%;
6+
position: relative;
7+
width: 100%;
8+
height: 100%;
9+
overflow: hidden;
10+
display: flex;
11+
align-items: center;
12+
justify-content: center;
13+
14+
&__image {
15+
max-width: 100%;
16+
max-height: 100%;
17+
transition: transform 0.3s ease-out;
18+
transform-origin: center center;
19+
user-select: none;
20+
}
821

922
&__spin {
1023
position: absolute;
1124
}
1225

13-
&_mobile {
14-
user-select: none;
26+
&_mobile #{$block}__image {
27+
// Touch optimizations
28+
touch-action: manipulation;
1529
-webkit-user-drag: none;
1630
-webkit-touch-callout: none;
31+
-webkit-tap-highlight-color: transparent;
32+
will-change: transform;
1733
}
1834
}

src/components/Gallery/components/views/ImageView/ImageView.tsx

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import * as React from 'react';
22

3-
import {Spin} from '@gravity-ui/uikit';
3+
import {Spin, useMobile} from '@gravity-ui/uikit';
44

55
import {block} from '../../../../utils/cn';
6+
import {useImageZoom} from '../../../hooks/useImageZoom';
67
import {GalleryFallbackText} from '../../FallbackText';
78

89
import './ImageView.scss';
@@ -17,29 +18,92 @@ export type ImageViewProps = {
1718

1819
export const ImageView = ({className, src, alt = ''}: ImageViewProps) => {
1920
const [status, setStatus] = React.useState<'loading' | 'complete' | 'error'>('loading');
21+
const imageRef = React.useRef<HTMLImageElement>(null);
22+
const containerRef = React.useRef<HTMLDivElement>(null);
23+
const isMobile = useMobile();
2024

25+
// Initialize zoom functionality with mobile detection
26+
const {imgHandlers, setImageSize, setContainerSize, resetZoom, imgStyles} = useImageZoom({
27+
isMobile,
28+
});
29+
30+
// Track image dimensions on load
2131
const handleLoad = React.useCallback(() => {
2232
setStatus('complete');
23-
}, []);
33+
if (imageRef.current) {
34+
setImageSize({
35+
width: imageRef.current.naturalWidth,
36+
height: imageRef.current.naturalHeight,
37+
});
38+
}
39+
}, [setImageSize]);
2440

2541
const handleError = React.useCallback(() => {
2642
setStatus('error');
2743
}, []);
2844

45+
// Track container dimensions and handle resize
46+
React.useEffect(() => {
47+
if (!containerRef.current) return undefined;
48+
49+
const updateSize = () => {
50+
if (containerRef.current) {
51+
const size = {
52+
width: containerRef.current.clientWidth,
53+
height: containerRef.current.clientHeight,
54+
};
55+
56+
// Only update if dimensions are valid
57+
if (size.width > 0 && size.height > 0) {
58+
setContainerSize(size);
59+
}
60+
}
61+
};
62+
63+
// Use ResizeObserver for more reliable dimension tracking
64+
const resizeObserver = new ResizeObserver(() => {
65+
updateSize();
66+
});
67+
68+
resizeObserver.observe(containerRef.current);
69+
70+
// Also try to update immediately (might work if container is already rendered)
71+
updateSize();
72+
73+
// Fallback: try again after a short delay
74+
const timeoutId = setTimeout(updateSize, 100);
75+
76+
window.addEventListener('resize', updateSize);
77+
78+
return () => {
79+
resizeObserver.disconnect();
80+
clearTimeout(timeoutId);
81+
window.removeEventListener('resize', updateSize);
82+
};
83+
}, [setContainerSize]);
84+
85+
// Reset zoom when image source changes
86+
React.useEffect(() => {
87+
resetZoom();
88+
}, [src, resetZoom]);
89+
2990
if (status === 'error') {
3091
return <GalleryFallbackText />;
3192
}
3293

3394
return (
34-
<React.Fragment>
95+
<div ref={containerRef} className={cnImageView({mobile: isMobile}, className)}>
3596
{status === 'loading' && <Spin className={cnImageView('spin')} size="xl" />}
3697
<img
37-
className={cnImageView(null, className)}
98+
ref={imageRef}
99+
className={cnImageView('image')}
38100
src={src}
39101
alt={alt}
102+
{...imgHandlers}
40103
onLoad={handleLoad}
41104
onError={handleError}
105+
style={imgStyles}
42106
/>
43-
</React.Fragment>
107+
</div>
44108
);
45109
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Constants
2+
export const MIN_SCALE = 1;
3+
export const MAX_SCALE_DESKTOP = 2; // Desktop max zoom
4+
export const MAX_SCALE_TOUCH = 3; // Mobile max zoom
5+
export const DRAG_THRESHOLD = 3; // px - minimum movement to consider as drag
6+
export const DOUBLE_TAP_DELAY = 300; // ms
7+
export const DOUBLE_TAP_DISTANCE = 30; // px
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export {useImageZoom, type UseImageZoomProps, type UseImageZoomReturn} from './useImageZoom';
2+
export type {Position, Size} from './types';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export type Ref<T> = {readonly current: T};
2+
3+
export type Position = {
4+
readonly x: number;
5+
readonly y: number;
6+
};
7+
8+
export type Size = {
9+
readonly width: number;
10+
readonly height: number;
11+
};
12+
13+
export type ZoomState = {
14+
readonly scale: number;
15+
readonly position: Position;
16+
};
17+
18+
export type ZoomActions = {
19+
setScale: (scale: number) => void;
20+
setPosition: (position: Position) => void;
21+
resetZoom: () => void;
22+
};
23+
24+
export type ZoomConstraints = {
25+
imageSize: Size;
26+
containerSize: Size;
27+
imageSizeRef: Ref<Size>;
28+
containerSizeRef: Ref<Size>;
29+
constrainPosition: (pos: Position, scale: number) => Position;
30+
imageFitsContainer: boolean;
31+
};
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import * as React from 'react';
2+
3+
import {useLatest} from '../useLatest';
4+
5+
import {MIN_SCALE} from './constants';
6+
import type {Position, Size, ZoomActions, ZoomConstraints, ZoomState} from './types';
7+
import {useImageZoomDesktop} from './useImageZoomDesktop';
8+
import {useImageZoomTouch} from './useImageZoomTouch';
9+
import {checkImageFitsContainer, createConstrainPosition} from './utils';
10+
11+
export interface UseImageZoomProps {
12+
isMobile: boolean;
13+
}
14+
15+
export interface UseImageZoomReturn {
16+
position: Position;
17+
imgHandlers: {
18+
onClick?: (e: React.MouseEvent) => void;
19+
onMouseDown?: (e: React.MouseEvent) => void;
20+
onTouchStart?: (e: React.TouchEvent) => void;
21+
onTouchMove?: (e: React.TouchEvent) => void;
22+
onTouchEnd?: (e: React.TouchEvent) => void;
23+
};
24+
setImageSize: (size: Size) => void;
25+
setContainerSize: (size: Size) => void;
26+
resetZoom: () => void;
27+
imgStyles: {
28+
transform: string;
29+
cursor: string;
30+
transition: string;
31+
};
32+
}
33+
34+
/**
35+
* Main zoom hook that coordinates desktop and touch zoom functionality
36+
*
37+
* This hook manages shared state and delegates platform-specific logic to:
38+
* - useImageZoomDesktop: Mouse-based zoom and drag
39+
* - useImageZoomTouch: Touch-based zoom with pinch and double tap
40+
*
41+
* Features:
42+
* - Shared state management (scale, position, dimensions)
43+
* - Boundary constraints to keep image within visible area
44+
* - Platform-specific handlers based on isMobile prop
45+
* - Auto-recalculation of constraints when dimensions change
46+
*
47+
* @param props - Configuration options
48+
* @returns Zoom state, handlers, and style properties
49+
*/
50+
export function useImageZoom({isMobile}: UseImageZoomProps): UseImageZoomReturn {
51+
// Shared state
52+
const [scale, setScale] = React.useState<number>(1);
53+
const [position, setPosition] = React.useState<Position>({x: 0, y: 0});
54+
const [imageSize, setImageSize] = React.useState<Size>({width: 0, height: 0});
55+
const [containerSize, setContainerSize] = React.useState<Size>({width: 0, height: 0});
56+
57+
// Refs for current values (avoid stale closures)
58+
const imageSizeRef = useLatest(imageSize);
59+
const containerSizeRef = useLatest(containerSize);
60+
61+
// Shared constraint function
62+
const constrainPosition = React.useMemo(
63+
() => createConstrainPosition(imageSizeRef, containerSizeRef),
64+
[imageSizeRef, containerSizeRef],
65+
);
66+
67+
// Check if image fits in container at current scale
68+
const imageFitsContainer = React.useMemo(
69+
() => checkImageFitsContainer(imageSize, containerSize, scale),
70+
[imageSize, containerSize, scale],
71+
);
72+
73+
// Shared actions
74+
const resetZoom = React.useCallback(() => {
75+
setScale(1);
76+
setPosition({x: 0, y: 0});
77+
}, []);
78+
79+
const zoomState: ZoomState = {scale, position};
80+
const zoomActions: ZoomActions = {setScale, setPosition, resetZoom};
81+
const constraints: ZoomConstraints = {
82+
imageSize,
83+
containerSize,
84+
imageSizeRef,
85+
containerSizeRef,
86+
constrainPosition,
87+
imageFitsContainer,
88+
};
89+
90+
// Platform-specific hooks
91+
const desktop = useImageZoomDesktop({
92+
enabled: !isMobile,
93+
zoomState,
94+
zoomActions,
95+
constraints,
96+
});
97+
98+
const touch = useImageZoomTouch({
99+
enabled: isMobile,
100+
zoomState,
101+
zoomActions,
102+
constraints,
103+
});
104+
105+
// Recalculate position constraints when dimensions change
106+
React.useEffect(() => {
107+
if (scale > MIN_SCALE) {
108+
const constrainedPosition = constrainPosition(position, scale);
109+
if (constrainedPosition.x !== position.x || constrainedPosition.y !== position.y) {
110+
setPosition(constrainedPosition);
111+
}
112+
}
113+
}, [imageSize, containerSize, scale, position, constrainPosition]);
114+
115+
// Calculate styles
116+
const transform = `translate(${position.x}px, ${position.y}px) scale(${scale})`;
117+
// Show zoom-in cursor when not zoomed or when zoomed but image fits in container
118+
const cursor = (scale > MIN_SCALE && (imageFitsContainer ? 'zoom-out' : 'move')) || 'zoom-in';
119+
const isInteracting = desktop.isDragging || touch.isTouching;
120+
const transition = isInteracting ? 'none' : 'transform 0.3s ease-out';
121+
122+
// Merge handlers based on platform
123+
const imgHandlers = isMobile ? touch.handlers : desktop.handlers;
124+
125+
return {
126+
position,
127+
imgHandlers,
128+
setImageSize,
129+
setContainerSize,
130+
resetZoom,
131+
imgStyles: {
132+
transform,
133+
cursor,
134+
transition,
135+
},
136+
};
137+
}

0 commit comments

Comments
 (0)