Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 91 additions & 70 deletions src/components/Gallery/Gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {GalleryHeader} from './components/GalleryHeader/GalleryHeader';
import {NavigationButton} from './components/NavigationButton/NavigationButton';
import {BODY_CONTENT_CLASS_NAME, cnGallery} from './constants';
import {GalleryContextProvider} from './contexts/GalleryContext';
import {GalleryImageRotationProvider} from './contexts/GalleryImageRotationContext';
import {useFullScreen} from './hooks/useFullScreen';
import {ROTATION_STEP} from './hooks/useImageRotation/constants';
import {useMobileGestures} from './hooks/useMobileGestures/useMobileGestures';
import type {UseNavigationProps} from './hooks/useNavigation';
import {useNavigation} from './hooks/useNavigation';
Expand Down Expand Up @@ -70,6 +72,14 @@ export const Gallery = ({

const {fullScreen, setFullScreen} = useFullScreen();

const [rotation, setRotation] = React.useState(0);
const rotateLeft = React.useCallback(() => setRotation((r) => r - ROTATION_STEP), []);
const rotateRight = React.useCallback(() => setRotation((r) => r + ROTATION_STEP), []);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be moved to a separate hook – useRotationState(), to which you can pass the initial value and custom rotation step as arguments.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but i think for now it enough to keep this hook in Gallery/Image scope without args


React.useEffect(() => {
setRotation(0);
}, [activeItemIndex]);

const handleBackClick = React.useCallback(() => {
onOpenChange?.(false);
}, [onOpenChange]);
Expand Down Expand Up @@ -131,80 +141,91 @@ export const Gallery = ({
overflow: mode === 'default' ? 'auto' : 'hidden',
}}
>
<div
className={cnGallery('content')}
onTouchStart={isMobile ? handleTouchStart : undefined}
onTouchMove={isMobile ? handleTouchMove : undefined}
onTouchEnd={isMobile ? handleTouchEnd : undefined}
<GalleryImageRotationProvider
rotation={rotation}
rotateLeft={rotateLeft}
rotateRight={rotateRight}
>
<GalleryHeader
itemName={activeItem?.name}
actions={activeItem?.actions}
withNavigation={withNavigation}
activeItemIndex={activeItemIndex}
itemsLength={items.length}
fullScreen={fullScreen}
onBackClick={handleBackClick}
onGoToPrevious={handleGoToPrevious}
onGoToNext={handleGoToNext}
onUpdateFullScreen={setFullScreen}
onClose={handleClose}
hidden={hiddenHeader}
interactive={activeItem?.interactive}
/>
<div key={activeItemIndex} className={cnGallery('body')}>
<div
className={cnGallery(BODY_CONTENT_CLASS_NAME, {
switching: isMobile && isSwitching,
})}
>
{!items.length && (
<GalleryFallbackText>
{emptyMessage ?? t('no-items')}
</GalleryFallbackText>
)}
<GalleryContextProvider
onTap={handleTap}
onViewInteractionChange={setIsViewInteracting}
<div
className={cnGallery('content')}
onTouchStart={isMobile ? handleTouchStart : undefined}
onTouchMove={isMobile ? handleTouchMove : undefined}
onTouchEnd={isMobile ? handleTouchEnd : undefined}
>
<GalleryHeader
itemName={activeItem?.name}
actions={activeItem?.actions}
withNavigation={withNavigation}
activeItemIndex={activeItemIndex}
itemsLength={items.length}
fullScreen={fullScreen}
onBackClick={handleBackClick}
onGoToPrevious={handleGoToPrevious}
onGoToNext={handleGoToNext}
onUpdateFullScreen={setFullScreen}
onClose={handleClose}
hidden={hiddenHeader}
interactive={activeItem?.interactive}
/>
<div key={activeItemIndex} className={cnGallery('body')}>
<div
className={cnGallery(BODY_CONTENT_CLASS_NAME, {
switching: isMobile && isSwitching,
})}
>
{activeItem?.view}
</GalleryContextProvider>
{showNavigationButtons && (
<React.Fragment>
<NavigationButton onClick={handleGoToPrevious} position="start" />
<NavigationButton onClick={handleGoToNext} position="end" />
</React.Fragment>
)}
{!items.length && (
<GalleryFallbackText>
{emptyMessage ?? t('no-items')}
</GalleryFallbackText>
)}
<GalleryContextProvider
onTap={handleTap}
onViewInteractionChange={setIsViewInteracting}
>
{activeItem?.view}
</GalleryContextProvider>
{showNavigationButtons && (
<React.Fragment>
<NavigationButton
onClick={handleGoToPrevious}
position="start"
/>
<NavigationButton onClick={handleGoToNext} position="end" />
</React.Fragment>
)}
</div>
</div>
{showFooter && (
<div className={cnGallery('footer')}>
{withNavigation && (
<div className={cnGallery('preview-list')}>
{items.map((item, index) => {
const handleClick = () => {
setActiveItemIndex(index);
};

const selected = activeItemIndex === index;

return (
<button
ref={itemRefs[index]}
type="button"
key={index}
onClick={handleClick}
className={cnGallery('preview-list-item', {
selected,
})}
>
{item.thumbnail}
</button>
);
})}
</div>
)}
</div>
)}
</div>
{showFooter && (
<div className={cnGallery('footer')}>
{withNavigation && (
<div className={cnGallery('preview-list')}>
{items.map((item, index) => {
const handleClick = () => {
setActiveItemIndex(index);
};

const selected = activeItemIndex === index;

return (
<button
ref={itemRefs[index]}
type="button"
key={index}
onClick={handleClick}
className={cnGallery('preview-list-item', {selected})}
>
{item.thumbnail}
</button>
);
})}
</div>
)}
</div>
)}
</div>
</GalleryImageRotationProvider>
</Modal>
);
};
36 changes: 36 additions & 0 deletions src/components/Gallery/__stories__/Gallery.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
getGalleryItemDocument,
getGalleryItemDownloadAction,
getGalleryItemImage,
getGalleryItemRotateLeftAction,
getGalleryItemRotateRightAction,
getGalleryItemVideo,
} from '../';
import type {GalleryProps} from '../';
Expand Down Expand Up @@ -373,3 +375,37 @@ const SmallImagesTemplate: StoryFn<GalleryProps> = () => {
};

export const SmallImages = SmallImagesTemplate.bind({});

const RotationGalleryTemplate: StoryFn<GalleryProps> = () => {
const [open, setOpen] = React.useState(false);

const handleToggle = React.useCallback(() => {
setOpen(false);
}, []);

const handleOpen = React.useCallback(() => {
setOpen(true);
}, []);

return (
<React.Fragment>
<Button onClick={handleOpen} view="action" size="l">
Open gallery with rotation
</Button>
<Gallery open={open} onOpenChange={handleToggle}>
{images.map((image, index) => (
<GalleryItem
key={index}
{...getGalleryItemImage({src: image.url, name: image.name})}
actions={[
getGalleryItemRotateLeftAction(),
getGalleryItemRotateRightAction(),
]}
/>
))}
</Gallery>
</React.Fragment>
);
};

export const RotationGallery = RotationGalleryTemplate.bind({});
26 changes: 18 additions & 8 deletions src/components/Gallery/components/views/ImageView/ImageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {Spin, useMobile} from '@gravity-ui/uikit';

import {block} from '../../../../utils/cn';
import {useGalleryContext} from '../../../contexts/GalleryContext';
import {useImageRotation} from '../../../hooks/useImageRotation';
import {useImageZoom} from '../../../hooks/useImageZoom';
import {GalleryFallbackText} from '../../FallbackText';

Expand All @@ -27,10 +28,16 @@ export const ImageView = ({className, src, alt = ''}: ImageViewProps) => {
const {imageHandlers, setImageSize, setContainerSize, resetZoom, imageStyles, isZooming} =
useImageZoom({onTap});

const {imageRotationStyles, rotation, setContainerDims} = useImageRotation();

React.useEffect(() => {
onViewInteractionChange(isZooming);
}, [isZooming, onViewInteractionChange]);

React.useEffect(() => {
resetZoom();
}, [src, rotation, resetZoom]);

const handleLoad = React.useCallback(() => {
setStatus('complete');
if (imageRef.current) {
Expand All @@ -45,7 +52,6 @@ export const ImageView = ({className, src, alt = ''}: ImageViewProps) => {
setStatus('error');
}, []);

// Track container dimensions and handle resize
React.useEffect(() => {
if (!containerRef.current) return undefined;

Expand All @@ -56,9 +62,9 @@ export const ImageView = ({className, src, alt = ''}: ImageViewProps) => {
height: containerRef.current.clientHeight,
};

// Only update if dimensions are valid
if (size.width > 0 && size.height > 0) {
setContainerSize(size);
setContainerDims(size);
}
}
};
Expand All @@ -80,16 +86,16 @@ export const ImageView = ({className, src, alt = ''}: ImageViewProps) => {
clearTimeout(timeoutId);
window.removeEventListener('resize', updateSize);
};
}, [setContainerSize]);

React.useEffect(() => {
resetZoom();
}, [src, resetZoom]);
}, [setContainerSize, setContainerDims]);

if (status === 'error') {
return <GalleryFallbackText />;
}

const mergedTransform = [imageStyles.transform, imageRotationStyles.transform]
.filter(Boolean)
.join(' ');

return (
<div ref={containerRef} className={cnImageView({mobile: isMobile}, className)}>
{status === 'loading' && <Spin className={cnImageView('spin')} size="xl" />}
Expand All @@ -101,7 +107,11 @@ export const ImageView = ({className, src, alt = ''}: ImageViewProps) => {
{...imageHandlers}
onLoad={handleLoad}
onError={handleError}
style={imageStyles}
style={{
...imageStyles,
...imageRotationStyles,
transform: mergedTransform || undefined,
}}
/>
</div>
);
Expand Down

@d3m1d0v d3m1d0v May 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GalleryContext already exists. Why not add GalleryImageRotationContext.tsx functionality to GalleryContext? There are reasons to create a separate context for image rotation?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, moved GalleryContext before modal comp. Now should be fine

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import * as React from 'react';

export type GalleryImageRotationContextValue = {
rotation: number;
rotateLeft: () => void;
rotateRight: () => void;
};

const GalleryImageRotationContext = React.createContext<GalleryImageRotationContextValue>({
rotation: 0,
rotateLeft: () => {},
rotateRight: () => {},
});

export type GalleryImageRotationProviderProps =
React.PropsWithChildren<GalleryImageRotationContextValue>;

export const GalleryImageRotationProvider: React.FunctionComponent<GalleryImageRotationProviderProps> =
function GalleryImageRotationProvider({children, rotation, rotateLeft, rotateRight}) {
const value = React.useMemo(
() => ({rotation, rotateLeft, rotateRight}),
[rotation, rotateLeft, rotateRight],
);
return (
<GalleryImageRotationContext.Provider value={value}>
{children}
</GalleryImageRotationContext.Provider>
);
};

export const useGalleryImageRotationContext = () => React.useContext(GalleryImageRotationContext);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './GalleryImageRotationContext';
2 changes: 2 additions & 0 deletions src/components/Gallery/hooks/useImageRotation/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const ROTATION_STEP = 90; // degrees per rotate-left / rotate-right action
export const FULL_ROTATION = 360; // degrees in a full rotation
1 change: 1 addition & 0 deletions src/components/Gallery/hooks/useImageRotation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useImageRotation';
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as React from 'react';

import {useGalleryImageRotationContext} from '../../contexts/GalleryImageRotationContext';

import {FULL_ROTATION, ROTATION_STEP} from './constants';

export type UseImageRotationReturn = {
/**
* Styles for the `<img>` element: the `rotate` transform part plus
* swapped max-width/max-height constraints for 90°/270° rotations.
*/
imageRotationStyles: React.CSSProperties;
rotation: number;
rotateLeft: () => void;
rotateRight: () => void;
setContainerDims: React.Dispatch<React.SetStateAction<{width: number; height: number}>>;
};

/** Hook for reading image rotation state from GalleryImageRotationContext. */
export function useImageRotation(): UseImageRotationReturn {
const {rotation, rotateLeft, rotateRight} = useGalleryImageRotationContext();
const [containerDims, setContainerDims] = React.useState({width: 0, height: 0});

const normalizedRotation = ((rotation % FULL_ROTATION) + FULL_ROTATION) % FULL_ROTATION;
const isHorizontalRotation =
normalizedRotation === ROTATION_STEP ||
normalizedRotation === FULL_ROTATION - ROTATION_STEP;

const imageRotationStyles = React.useMemo<React.CSSProperties>(
() => ({
...(rotation ? {transform: `rotate(${rotation}deg)`} : {}),
...(isHorizontalRotation && containerDims.width > 0
? {maxWidth: containerDims.height, maxHeight: containerDims.width}
: {}),
}),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should rotation occur if the image dimensions haven't been set yet? I don't think so. The useImageZoom* hooks also check for the dimensions before zooming the image.

[rotation, isHorizontalRotation, containerDims],
);

return {imageRotationStyles, rotation, rotateLeft, rotateRight, setContainerDims};
}
Loading