Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
153 changes: 84 additions & 69 deletions src/components/Gallery/Gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {NavigationButton} from './components/NavigationButton/NavigationButton';
import {BODY_CONTENT_CLASS_NAME, cnGallery} from './constants';
import {GalleryContextProvider} from './contexts/GalleryContext';
import {useFullScreen} from './hooks/useFullScreen';
import {useImageRotationState} from './hooks/useImageRotationState';
import {useMobileGestures} from './hooks/useMobileGestures/useMobileGestures';
import type {UseNavigationProps} from './hooks/useNavigation';
import {useNavigation} from './hooks/useNavigation';
Expand Down Expand Up @@ -70,6 +71,12 @@ export const Gallery = ({

const {fullScreen, setFullScreen} = useFullScreen();

const {rotation, rotateLeft, rotateRight, resetRotation} = useImageRotationState();

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

const handleBackClick = React.useCallback(() => {
onOpenChange?.(false);
}, [onOpenChange]);
Expand Down Expand Up @@ -131,80 +138,88 @@ export const Gallery = ({
overflow: mode === 'default' ? 'auto' : 'hidden',
}}
>
<div
className={cnGallery('content')}
onTouchStart={isMobile ? handleTouchStart : undefined}
onTouchMove={isMobile ? handleTouchMove : undefined}
onTouchEnd={isMobile ? handleTouchEnd : undefined}
<GalleryContextProvider
onTap={handleTap}
onViewInteractionChange={setIsViewInteracting}
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,
})}
>
{!items.length && (
<GalleryFallbackText>
{emptyMessage ?? t('no-items')}
</GalleryFallbackText>
)}
{activeItem?.view}
</GalleryContextProvider>
{showNavigationButtons && (
<React.Fragment>
<NavigationButton onClick={handleGoToPrevious} position="start" />
<NavigationButton onClick={handleGoToNext} position="end" />
</React.Fragment>
)}
{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>
</GalleryContextProvider>
</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
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,49 @@ export type GalleryContextValue = {
onTap: React.TouchEventHandler;
/** Callback to notify Gallery about view interaction state changes. */
onViewInteractionChange: (isInteracting: boolean) => void;
/** Current image rotation in degrees. */
rotation: number;
/** Rotate the active image counter-clockwise by one step. */
rotateLeft: () => void;
/** Rotate the active image clockwise by one step. */
rotateRight: () => void;
};

const GalleryContext = React.createContext<GalleryContextValue>({
onTap: () => {},
onViewInteractionChange: () => {},
rotation: 0,
rotateLeft: () => {},
rotateRight: () => {},
});

export const GalleryContextProvider: React.FunctionComponent<
React.PropsWithChildren<GalleryContextValue>
> = function GalleryContextProvider({children, onViewInteractionChange, onTap}) {
> = function GalleryContextProvider({
children,
onViewInteractionChange,
onTap,
rotation,
rotateLeft,
rotateRight,
}) {
const value: GalleryContextValue = React.useMemo(
() => ({
onTap,
onViewInteractionChange,
rotation,
rotateLeft,
rotateRight,
}),
[onTap, onViewInteractionChange],
[onTap, onViewInteractionChange, rotation, rotateLeft, rotateRight],
);
return <GalleryContext.Provider value={value}>{children}</GalleryContext.Provider>;
};

/**
* Context for communication between Gallery and its child views.
* Provides callbacks for view interaction events.
* Provides callbacks for view interaction events and image rotation state.
*
* @returns Current GalleryContext value.
*/
export const useGalleryContext = () => React.useContext(GalleryContext);
Loading