From 9f7b6cef6d2317e065700b3490bb573e85d84634 Mon Sep 17 00:00:00 2001 From: d3m1d0v Date: Fri, 28 Nov 2025 13:04:55 +0300 Subject: [PATCH 1/2] feat(Gallery): add image scaling --- src/components/Gallery/Gallery.tsx | 20 +- src/components/Gallery/README.md | 30 ++ .../Gallery/__stories__/Gallery.stories.tsx | 36 ++ .../components/views/ImageView/ImageView.scss | 24 +- .../components/views/ImageView/ImageView.tsx | 73 +++- .../GalleryContext/GalleryContext.tsx | 35 ++ .../Gallery/contexts/GalleryContext/README.md | 57 +++ .../Gallery/contexts/GalleryContext/index.ts | 1 + .../Gallery/hooks/useImageZoom/README.md | 80 +++++ .../Gallery/hooks/useImageZoom/constants.ts | 6 + .../Gallery/hooks/useImageZoom/index.ts | 1 + .../Gallery/hooks/useImageZoom/types.ts | 31 ++ .../hooks/useImageZoom/useImageZoom.ts | 129 +++++++ .../hooks/useImageZoom/useImageZoomDesktop.ts | 186 ++++++++++ .../hooks/useImageZoom/useImageZoomTouch.ts | 326 ++++++++++++++++++ .../Gallery/hooks/useImageZoom/utils.ts | 81 +++++ src/components/Gallery/hooks/useLatest.ts | 7 + .../useMobileGestures/useMobileGestures.ts | 20 +- src/components/Gallery/index.ts | 5 + 19 files changed, 1134 insertions(+), 14 deletions(-) create mode 100644 src/components/Gallery/contexts/GalleryContext/GalleryContext.tsx create mode 100644 src/components/Gallery/contexts/GalleryContext/README.md create mode 100644 src/components/Gallery/contexts/GalleryContext/index.ts create mode 100644 src/components/Gallery/hooks/useImageZoom/README.md create mode 100644 src/components/Gallery/hooks/useImageZoom/constants.ts create mode 100644 src/components/Gallery/hooks/useImageZoom/index.ts create mode 100644 src/components/Gallery/hooks/useImageZoom/types.ts create mode 100644 src/components/Gallery/hooks/useImageZoom/useImageZoom.ts create mode 100644 src/components/Gallery/hooks/useImageZoom/useImageZoomDesktop.ts create mode 100644 src/components/Gallery/hooks/useImageZoom/useImageZoomTouch.ts create mode 100644 src/components/Gallery/hooks/useImageZoom/utils.ts create mode 100644 src/components/Gallery/hooks/useLatest.ts diff --git a/src/components/Gallery/Gallery.tsx b/src/components/Gallery/Gallery.tsx index 560954e3..dd660bb3 100644 --- a/src/components/Gallery/Gallery.tsx +++ b/src/components/Gallery/Gallery.tsx @@ -8,6 +8,7 @@ import {GalleryFallbackText} from './components/FallbackText'; 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 {useFullScreen} from './hooks/useFullScreen'; import {useMobileGestures} from './hooks/useMobileGestures/useMobileGestures'; import type {UseNavigationProps} from './hooks/useNavigation'; @@ -46,6 +47,7 @@ export const Gallery = ({ ); const [hiddenHeader, setHiddenHeader] = React.useState(false); + const [isViewInteracting, setIsViewInteracting] = React.useState(false); React.useEffect(() => { setItemRefs(Array.from({length: itemsCount}, () => React.createRef())); @@ -58,6 +60,14 @@ export const Gallery = ({ }, ); + React.useEffect(() => { + setIsViewInteracting(false); + }, [activeItemIndex]); + + React.useEffect(() => { + if (isViewInteracting) setHiddenHeader(true); + }, [isViewInteracting]); + const {fullScreen, setFullScreen} = useFullScreen(); const handleBackClick = React.useCallback(() => { @@ -95,12 +105,13 @@ export const Gallery = ({ onSwipeLeft: handleGoToNext, onSwipeRight: handleGoToPrevious, onTap: handleTap, + disabled: isViewInteracting, }); const withNavigation = items.length > 1; const showNavigationButtons = - withNavigation && !isMobile && activeItem && !activeItem.interactive; + withNavigation && !isMobile && activeItem && !activeItem.interactive && !isViewInteracting; const showFooter = !fullScreen && !isMobile; const mode = getMode(isMobile, fullScreen); @@ -152,7 +163,12 @@ export const Gallery = ({ {emptyMessage ?? t('no-items')} )} - {activeItem?.view} + + {activeItem?.view} + {showNavigationButtons && ( diff --git a/src/components/Gallery/README.md b/src/components/Gallery/README.md index 46d36372..55c8e287 100644 --- a/src/components/Gallery/README.md +++ b/src/components/Gallery/README.md @@ -4,6 +4,14 @@ The base component for rendering galleries of any type of data. The component is responsible for the gallery navigation (keyboard arrows, body side click and header arrow click). The children of the Gallery should be an array of [GalleryItem with the required properties](#GalleryItem) for rendering the gallery item view. +### Features + +- **Navigation**: Keyboard arrows, body side click, and header arrow click +- **Image Zoom**: Built-in zoom and pan functionality for images (desktop and mobile) +- **Swipe Gestures**: Mobile swipe navigation (automatically disabled during zoom interaction) +- **Fullscreen Mode**: Toggle fullscreen view +- **Custom Actions**: Add custom action buttons for each gallery item + ### PropTypes | Property | Type | Required | Values | Default | Description | @@ -25,6 +33,28 @@ The children of the Gallery should be an array of [GalleryItem with the required | actions | `ReactNode[]` | | | | The array of the gallery item action buttons | | interactive | `boolean` | | | | Provide true if the gallery item is interactive and the navigation by body click should not work | +### Image Zoom + +Gallery includes built-in zoom functionality for images via the [`useImageZoom`](./hooks/useImageZoom/README.md) hook: + +**Desktop:** + +- Click to toggle 1x ↔ 2x zoom +- Drag to pan when zoomed + +**Mobile:** + +- Double tap to toggle 1x ↔ 3x zoom +- Pinch to zoom (1.0 - 3.0) +- Single finger drag to pan when zoomed +- Swipe gestures automatically disabled during zoom interaction + +See [`useImageZoom` documentation](./hooks/useImageZoom/README.md) for more details. + +### Gallery Context + +Gallery provides a context for child views to communicate interaction state. See [`GalleryContext` documentation](./contexts/README.md) for details. + ### Default gallery item props We export some utility functions for getting the gallery item props: diff --git a/src/components/Gallery/__stories__/Gallery.stories.tsx b/src/components/Gallery/__stories__/Gallery.stories.tsx index bc6ae8e1..0c5e94ee 100644 --- a/src/components/Gallery/__stories__/Gallery.stories.tsx +++ b/src/components/Gallery/__stories__/Gallery.stories.tsx @@ -337,3 +337,39 @@ const SingleItemGalleryTemplate: StoryFn = () => { }; export const SingleItemGallery = SingleItemGalleryTemplate.bind({}); + +const SmallImagesTemplate: StoryFn = () => { + const [open, setOpen] = React.useState(false); + + const handleToggle = React.useCallback(() => { + setOpen(false); + }, []); + + const handleOpen = React.useCallback(() => { + setOpen(true); + }, []); + + return ( + + + + + + + + ); +}; + +export const SmallImages = SmallImagesTemplate.bind({}); diff --git a/src/components/Gallery/components/views/ImageView/ImageView.scss b/src/components/Gallery/components/views/ImageView/ImageView.scss index 4a6c2696..e672d6f9 100644 --- a/src/components/Gallery/components/views/ImageView/ImageView.scss +++ b/src/components/Gallery/components/views/ImageView/ImageView.scss @@ -3,16 +3,32 @@ $block: '.#{variables.$ns}gallery-image-view'; #{$block} { - max-width: 100%; - max-height: 100%; + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + &__image { + max-width: 100%; + max-height: 100%; + transition: transform 0.3s ease-out; + transform-origin: center center; + user-select: none; + } &__spin { position: absolute; } - &_mobile { - user-select: none; + &_mobile #{$block}__image { + // Touch optimizations + touch-action: manipulation; -webkit-user-drag: none; -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; + will-change: transform; } } diff --git a/src/components/Gallery/components/views/ImageView/ImageView.tsx b/src/components/Gallery/components/views/ImageView/ImageView.tsx index 8f017b88..966ce776 100644 --- a/src/components/Gallery/components/views/ImageView/ImageView.tsx +++ b/src/components/Gallery/components/views/ImageView/ImageView.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; -import {Spin} from '@gravity-ui/uikit'; +import {Spin, useMobile} from '@gravity-ui/uikit'; import {block} from '../../../../utils/cn'; +import {useGalleryContext} from '../../../contexts/GalleryContext'; +import {useImageZoom} from '../../../hooks/useImageZoom'; import {GalleryFallbackText} from '../../FallbackText'; import './ImageView.scss'; @@ -17,29 +19,90 @@ export type ImageViewProps = { export const ImageView = ({className, src, alt = ''}: ImageViewProps) => { const [status, setStatus] = React.useState<'loading' | 'complete' | 'error'>('loading'); + const imageRef = React.useRef(null); + const containerRef = React.useRef(null); + const isMobile = useMobile(); + const {onTap, onViewInteractionChange} = useGalleryContext(); + + const {imageHandlers, setImageSize, setContainerSize, resetZoom, imageStyles, isZooming} = + useImageZoom({onTap}); + + React.useEffect(() => { + onViewInteractionChange(isZooming); + }, [isZooming, onViewInteractionChange]); const handleLoad = React.useCallback(() => { setStatus('complete'); - }, []); + if (imageRef.current) { + setImageSize({ + width: imageRef.current.naturalWidth, + height: imageRef.current.naturalHeight, + }); + } + }, [setImageSize]); const handleError = React.useCallback(() => { setStatus('error'); }, []); + // Track container dimensions and handle resize + React.useEffect(() => { + if (!containerRef.current) return undefined; + + const updateSize = () => { + if (containerRef.current) { + const size = { + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight, + }; + + // Only update if dimensions are valid + if (size.width > 0 && size.height > 0) { + setContainerSize(size); + } + } + }; + + const resizeObserver = new ResizeObserver(() => { + updateSize(); + }); + + resizeObserver.observe(containerRef.current); + + updateSize(); + + const timeoutId = setTimeout(updateSize, 100); + + window.addEventListener('resize', updateSize); + + return () => { + resizeObserver.disconnect(); + clearTimeout(timeoutId); + window.removeEventListener('resize', updateSize); + }; + }, [setContainerSize]); + + React.useEffect(() => { + resetZoom(); + }, [src, resetZoom]); + if (status === 'error') { return ; } return ( - +
{status === 'loading' && } {alt} - +
); }; diff --git a/src/components/Gallery/contexts/GalleryContext/GalleryContext.tsx b/src/components/Gallery/contexts/GalleryContext/GalleryContext.tsx new file mode 100644 index 00000000..55f3824e --- /dev/null +++ b/src/components/Gallery/contexts/GalleryContext/GalleryContext.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +export type GalleryContextValue = { + /** + * Tap handler for mobile views. + * Should be called by the view on single tap when view is in interactive state. + */ + onTap: React.TouchEventHandler; + /** Callback to notify Gallery about view interaction state changes. */ + onViewInteractionChange: (isInteracting: boolean) => void; +}; + +const GalleryContext = React.createContext({ + onTap: () => {}, + onViewInteractionChange: () => {}, +}); + +export const GalleryContextProvider: React.FunctionComponent< + React.PropsWithChildren +> = function GalleryContextProvider({children, onViewInteractionChange, onTap}) { + const value: GalleryContextValue = React.useMemo( + () => ({ + onTap, + onViewInteractionChange, + }), + [onTap, onViewInteractionChange], + ); + return {children}; +}; + +/** + * Context for communication between Gallery and its child views. + * Provides callbacks for view interaction events. + */ +export const useGalleryContext = () => React.useContext(GalleryContext); diff --git a/src/components/Gallery/contexts/GalleryContext/README.md b/src/components/Gallery/contexts/GalleryContext/README.md new file mode 100644 index 00000000..0b1e769a --- /dev/null +++ b/src/components/Gallery/contexts/GalleryContext/README.md @@ -0,0 +1,57 @@ +# GalleryContext + +React context for communication between Gallery and its child views (like ImageView). Provides callbacks for view interaction events. + +## Usage + +```typescript +import {useGalleryContext} from '@gravity-ui/components'; + +function ImageView() { + const {onViewInteractionChange, onTap} = useGalleryContext(); + + React.useEffect(() => { + onViewInteractionChange(isInteracting); + }, [isInteracting, onViewInteractionChange]); +} +``` + +## Context Value + +| Property | Type | Description | +| :---------------------- | :--------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| onViewInteractionChange | `(isInteracting: boolean) => void` | Callback to notify Gallery about view interaction state changes. Called when user starts/stops interacting with the current view (e.g., zooming an image). Gallery uses this to disable/enable swipe gestures. | +| onTap | `React.TouchEventHandler` | Tap handler for mobile views. Called on single tap when view is in interactive state (e.g., image is zoomed). Used to toggle UI visibility or perform custom actions. | + +## Example + +```typescript +import {useGalleryContext} from '@gravity-ui/components'; +import {useImageZoom} from '@gravity-ui/components'; + +function ImageView({src}) { + const {onViewInteractionChange, onTap} = useGalleryContext(); + const {imageHandlers, imageStyles, isZooming} = useImageZoom({onTap}); + + // Notify Gallery when zoom state changes + React.useEffect(() => { + onViewInteractionChange(isZooming); + }, [isZooming, onViewInteractionChange]); + + return ; +} +``` + +## Integration + +The context is automatically provided by the Gallery component. Child views can access it using `useGalleryContext()` hook. + +When `onViewInteractionChange(true)` is called: + +- Gallery disables swipe gestures for navigation +- User can interact with the view without triggering navigation + +When `onViewInteractionChange(false)` is called: + +- Gallery re-enables swipe gestures +- User can swipe to navigate between items diff --git a/src/components/Gallery/contexts/GalleryContext/index.ts b/src/components/Gallery/contexts/GalleryContext/index.ts new file mode 100644 index 00000000..be762a04 --- /dev/null +++ b/src/components/Gallery/contexts/GalleryContext/index.ts @@ -0,0 +1 @@ +export * from './GalleryContext'; diff --git a/src/components/Gallery/hooks/useImageZoom/README.md b/src/components/Gallery/hooks/useImageZoom/README.md new file mode 100644 index 00000000..52c859a2 --- /dev/null +++ b/src/components/Gallery/hooks/useImageZoom/README.md @@ -0,0 +1,80 @@ +# useImageZoom + +Hook for managing image zoom and pan functionality in Gallery. Automatically adapts to platform (desktop/mobile). + +## Usage + +```typescript +import {useImageZoom} from '@gravity-ui/components'; + +const {imageHandlers, setImageSize, setContainerSize, resetZoom, imageStyles, isZooming} = + useImageZoom({disabled: false}); +``` + +## Props + +| Property | Type | Required | Default | Description | +| :------- | :------------------------ | :------- | :------ | :-------------------------------------------------------------------------- | +| disabled | `boolean` | | `false` | Disables zoom functionality | +| onTap | `React.TouchEventHandler` | | | Custom tap handler for mobile. Called on single tap when image is zoomed in | + +## Return Value + +| Property | Type | Description | +| :--------------- | :------------------------------------------------ | :---------------------------------------------------------------------------------------------------------- | +| imageHandlers | `object` | Event handlers to spread on `` element. Platform-specific (mouse events for desktop, touch for mobile) | +| setImageSize | `(size: {width: number, height: number}) => void` | Set image natural dimensions (call after image load) | +| setContainerSize | `(size: {width: number, height: number}) => void` | Set container dimensions for pan constraints | +| resetZoom | `() => void` | Reset zoom to initial state (scale=1, position={0,0}) | +| imageStyles | `React.CSSProperties` | Styles object with cursor, transform, and transition | +| isZooming | `boolean` | `true` when user is interacting with zoom or image is zoomed (scale > 1) | + +## Behavior + +**Desktop:** + +- Click to toggle 1x ↔ 2x zoom +- Drag to pan when zoomed + +**Mobile:** + +- Double tap to toggle 1x ↔ 3x zoom +- Pinch to zoom (1.0 - 3.0) +- Single finger drag to pan when zoomed +- Single tap on zoomed image calls `onTap` handler (if provided) + +## Example + +```typescript +function ImageView({src}) { + const imageRef = React.useRef(null); + const containerRef = React.useRef(null); + + const {imageHandlers, setImageSize, setContainerSize, resetZoom, imageStyles} = + useImageZoom({}); + + React.useEffect(() => { + if (imageRef.current) { + setImageSize({ + width: imageRef.current.naturalWidth, + height: imageRef.current.naturalHeight, + }); + } + }, [src]); + + React.useEffect(() => { + if (containerRef.current) { + setContainerSize({ + width: containerRef.current.clientWidth, + height: containerRef.current.clientHeight, + }); + } + }, []); + + return ( +
+ +
+ ); +} +``` diff --git a/src/components/Gallery/hooks/useImageZoom/constants.ts b/src/components/Gallery/hooks/useImageZoom/constants.ts new file mode 100644 index 00000000..f667232e --- /dev/null +++ b/src/components/Gallery/hooks/useImageZoom/constants.ts @@ -0,0 +1,6 @@ +export const MIN_SCALE = 1; +export const MAX_SCALE_DESKTOP = 2; // Desktop max zoom +export const MAX_SCALE_TOUCH = 3; // Mobile max zoom +export const DRAG_THRESHOLD_PX = 3; // minimum movement to consider as drag +export const DOUBLE_TAP_DELAY_MS = 300; +export const DOUBLE_TAP_DISTANCE_PX = 30; diff --git a/src/components/Gallery/hooks/useImageZoom/index.ts b/src/components/Gallery/hooks/useImageZoom/index.ts new file mode 100644 index 00000000..b242e3f5 --- /dev/null +++ b/src/components/Gallery/hooks/useImageZoom/index.ts @@ -0,0 +1 @@ +export {useImageZoom, type UseImageZoomProps, type UseImageZoomReturn} from './useImageZoom'; diff --git a/src/components/Gallery/hooks/useImageZoom/types.ts b/src/components/Gallery/hooks/useImageZoom/types.ts new file mode 100644 index 00000000..7781a333 --- /dev/null +++ b/src/components/Gallery/hooks/useImageZoom/types.ts @@ -0,0 +1,31 @@ +export type Ref = {readonly current: T}; + +export type Position = { + readonly x: number; + readonly y: number; +}; + +export type Size = { + readonly width: number; + readonly height: number; +}; + +export type ZoomState = { + readonly scale: number; + readonly position: Position; +}; + +export type ZoomActions = { + setScale: (scale: number) => void; + setPosition: (position: Position) => void; + resetZoom: () => void; +}; + +export type ZoomConstraints = { + imageSize: Size; + containerSize: Size; + imageSizeRef: Ref; + containerSizeRef: Ref; + constrainPosition: (pos: Position, scale: number) => Position; + imageFitsContainer: boolean; +}; diff --git a/src/components/Gallery/hooks/useImageZoom/useImageZoom.ts b/src/components/Gallery/hooks/useImageZoom/useImageZoom.ts new file mode 100644 index 00000000..100ac5ad --- /dev/null +++ b/src/components/Gallery/hooks/useImageZoom/useImageZoom.ts @@ -0,0 +1,129 @@ +import * as React from 'react'; + +import {useMobile} from '@gravity-ui/uikit'; + +import {useLatest} from '../useLatest'; + +import {MIN_SCALE} from './constants'; +import type {Position, Size, ZoomActions, ZoomConstraints, ZoomState} from './types'; +import {useImageZoomDesktop} from './useImageZoomDesktop'; +import {useImageZoomTouch} from './useImageZoomTouch'; +import {checkImageFitsContainer, createConstrainPosition} from './utils'; + +export type UseImageZoomProps = { + /** + * Disables zoom functionality + * @default false */ + disabled?: boolean; + /** Tap handler for mobile. Called on single tap when image is zoomed. */ + onTap?: React.TouchEventHandler; +}; + +export type UseImageZoomReturn = { + /** Event handlers for `` element. */ + imageHandlers: { + onClick?: (event: React.MouseEvent) => void; + onMouseDown?: (event: React.MouseEvent) => void; + onTouchStart?: (event: React.TouchEvent) => void; + onTouchMove?: (event: React.TouchEvent) => void; + onTouchEnd?: (event: React.TouchEvent) => void; + }; + /** Set image natural dimensions. */ + setImageSize: (size: Size) => void; + /** Set container dimensions */ + setContainerSize: (size: Size) => void; + /** Reset zoom to initial state */ + resetZoom: () => void; + /** Styles for `` element. */ + imageStyles: React.CSSProperties; + /** Indicates if user is currently interacting with zoom or image is zoomed. */ + isZooming: boolean; +}; + +/** Hook for managing image zoom and pan functionality in Gallery */ +export function useImageZoom({disabled, onTap}: UseImageZoomProps): UseImageZoomReturn { + const isMobile = useMobile(); + + const [scale, setScale] = React.useState(1); + const [position, setPosition] = React.useState({x: 0, y: 0}); + const [imageSize, setImageSize] = React.useState({width: 0, height: 0}); + const [containerSize, setContainerSize] = React.useState({width: 0, height: 0}); + + const imageSizeRef = useLatest(imageSize); + const containerSizeRef = useLatest(containerSize); + + const constrainPosition = React.useMemo( + () => createConstrainPosition(imageSizeRef, containerSizeRef), + [imageSizeRef, containerSizeRef], + ); + + const imageFitsContainer = React.useMemo( + () => checkImageFitsContainer(imageSize, containerSize, scale), + [imageSize, containerSize, scale], + ); + + const resetZoom = React.useCallback(() => { + setScale(1); + setPosition({x: 0, y: 0}); + }, []); + + const zoomState: ZoomState = {scale, position}; + const zoomActions: ZoomActions = {setScale, setPosition, resetZoom}; + const constraints: ZoomConstraints = { + imageSize, + containerSize, + imageSizeRef, + containerSizeRef, + constrainPosition, + imageFitsContainer, + }; + + const desktop = useImageZoomDesktop({ + enabled: !isMobile && !disabled, + zoomState, + zoomActions, + constraints, + }); + + const touch = useImageZoomTouch({ + enabled: isMobile && !disabled, + zoomState, + zoomActions, + constraints, + onTap, + }); + + React.useEffect(() => { + if (scale > MIN_SCALE) { + const constrainedPosition = constrainPosition(position, scale); + if (constrainedPosition.x !== position.x || constrainedPosition.y !== position.y) { + setPosition(constrainedPosition); + } + } + }, [imageSize, containerSize, scale, position, constrainPosition]); + + return { + isZooming: desktop.isDragging || touch.isTouching || scale > MIN_SCALE, + imageHandlers: isMobile ? touch.handlers : desktop.handlers, + setImageSize, + setContainerSize, + resetZoom, + imageStyles: getImageStyles(), + }; + + function getImageStyles(): React.CSSProperties { + const isScaling = scale > MIN_SCALE; + const isInteracting = desktop.isDragging || touch.isTouching; + + const styles: React.CSSProperties = { + cursor: isScaling ? 'move' : 'zoom-in', + transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`, + transition: isInteracting ? 'none' : 'transform 0.3s ease-out', + }; + + // Show zoom-out cursor when zoomed but image fits in container + if (isScaling && imageFitsContainer) styles.cursor = 'zoom-out'; + + return styles; + } +} diff --git a/src/components/Gallery/hooks/useImageZoom/useImageZoomDesktop.ts b/src/components/Gallery/hooks/useImageZoom/useImageZoomDesktop.ts new file mode 100644 index 00000000..f2466ef0 --- /dev/null +++ b/src/components/Gallery/hooks/useImageZoom/useImageZoomDesktop.ts @@ -0,0 +1,186 @@ +import * as React from 'react'; + +import {useLatest} from '../useLatest'; + +import {DRAG_THRESHOLD_PX, MAX_SCALE_DESKTOP, MIN_SCALE} from './constants'; +import type {Position, ZoomActions, ZoomConstraints, ZoomState} from './types'; + +type UseImageZoomDesktopProps = { + enabled: boolean; + zoomState: ZoomState; + zoomActions: ZoomActions; + constraints: ZoomConstraints; +}; + +/** + * Desktop-specific zoom logic using mouse events + * + * Features: + * - Click to toggle between 1x and 2x zoom + * - Drag to pan when zoomed + * - Click vs drag detection (3px threshold) + * - Document-level mouse listeners for smooth drag + * + * @param props - Configuration with zoom state, actions, and constraints + * @returns Handlers and dragging state + */ +export function useImageZoomDesktop({ + enabled, + zoomState, + zoomActions, + constraints, +}: UseImageZoomDesktopProps) { + const {scale, position} = zoomState; + const {setScale, setPosition, resetZoom} = zoomActions; + const {imageSizeRef, containerSizeRef, constrainPosition, imageFitsContainer} = constraints; + + const imageFitsContainerRef = useLatest(imageFitsContainer); + + const [isDragging, setIsDragging] = React.useState(false); + const [dragStart, setDragStart] = React.useState(null); + const [hasMoved, setHasMoved] = React.useState(false); + + /** + * Handle image click - toggle zoom + * Only toggle if there was no drag movement + */ + const handleImageClick = React.useCallback( + (_event) => { + if (!enabled) { + return; + } + + // Don't toggle zoom if user was dragging + if (hasMoved) { + setHasMoved(false); + return; + } + + if (scale === 1) { + // Zoom in to 2x, centered + setScale(MAX_SCALE_DESKTOP); + setPosition({x: 0, y: 0}); + } else { + // Zoom out to 1x + resetZoom(); + } + }, + [enabled, scale, hasMoved, setScale, setPosition, resetZoom], + ); + + /** + * Handle mouse down - start drag operation + */ + const handleMouseDown = React.useCallback( + (event) => { + if (!enabled) { + return; + } + + // Only allow dragging when zoomed and image doesn't fit in container + if (scale <= MIN_SCALE || imageFitsContainer) { + return; + } + + // Don't start drag if dimensions are not yet available + if ( + imageSizeRef.current.width === 0 || + imageSizeRef.current.height === 0 || + containerSizeRef.current.width === 0 || + containerSizeRef.current.height === 0 + ) { + return; + } + + event.preventDefault(); // Prevent text selection + event.stopPropagation(); + + setIsDragging(true); + setDragStart({x: event.clientX, y: event.clientY}); + setHasMoved(false); + }, + [enabled, scale, imageFitsContainer, imageSizeRef, containerSizeRef], + ); + + /** + * Handle mouse move - update position during drag + */ + const handleMouseMove = React.useCallback( + (event: MouseEvent) => { + if (!isDragging || !dragStart) { + return; + } + + // Stop dragging if image now fits in container + if (imageFitsContainerRef.current) { + setIsDragging(false); + setDragStart(null); + return; + } + + // Calculate delta from drag start + const deltaX = event.clientX - dragStart.x; + const deltaY = event.clientY - dragStart.y; + + // Mark as moved if there's significant movement + if (Math.abs(deltaX) > DRAG_THRESHOLD_PX || Math.abs(deltaY) > DRAG_THRESHOLD_PX) { + setHasMoved(true); + } + + // Update position with constraints + const newPosition = constrainPosition( + { + x: position.x + deltaX, + y: position.y + deltaY, + }, + scale, + ); + + setPosition(newPosition); + setDragStart({x: event.clientX, y: event.clientY}); + }, + [ + isDragging, + dragStart, + position, + scale, + constrainPosition, + setPosition, + imageFitsContainerRef, + ], + ); + + /** + * Handle mouse up - end drag operation + */ + const handleMouseUp = React.useCallback(() => { + setIsDragging(false); + setDragStart(null); + // Note: hasMoved is reset in handleImageClick or on next mousedown + }, []); + + // Add/remove document-level mouse event listeners for drag + React.useEffect(() => { + if (!enabled || !isDragging) { + return undefined; + } + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [enabled, isDragging, handleMouseMove, handleMouseUp]); + + return { + handlers: enabled + ? { + onClick: handleImageClick, + onMouseDown: handleMouseDown, + } + : {}, + isDragging, + }; +} diff --git a/src/components/Gallery/hooks/useImageZoom/useImageZoomTouch.ts b/src/components/Gallery/hooks/useImageZoom/useImageZoomTouch.ts new file mode 100644 index 00000000..ec5f4f46 --- /dev/null +++ b/src/components/Gallery/hooks/useImageZoom/useImageZoomTouch.ts @@ -0,0 +1,326 @@ +import * as React from 'react'; + +import {useLatest} from '../useLatest'; + +import { + DOUBLE_TAP_DELAY_MS, + DOUBLE_TAP_DISTANCE_PX, + DRAG_THRESHOLD_PX, + MAX_SCALE_TOUCH, + MIN_SCALE, +} from './constants'; +import type {Position, ZoomActions, ZoomConstraints, ZoomState} from './types'; + +type TouchState = { + lastTapTime: number; + lastTapPosition: Position | null; + initialDistance: number | null; + initialScale: number; + touchStartPosition: Position | null; + initialPosition: Position; + isTouching: boolean; + touchCount: number; +}; + +type UseImageZoomTouchProps = { + enabled: boolean; + zoomState: ZoomState; + zoomActions: ZoomActions; + constraints: ZoomConstraints; + onTap?: React.TouchEventHandler; +}; + +/** + * Touch-specific zoom logic for mobile devices + * + * Features: + * - Double tap to toggle zoom (1x ↔ 2x) + * - Pinch-to-zoom with continuous scale (1.0 - 2.0) + * - Single finger drag to pan when zoomed + * - Conditional event propagation (allows gallery swipe when not zoomed) + * + * @param props - Configuration with zoom state, actions, and constraints + * @returns Touch handlers and touching state + */ +export function useImageZoomTouch({ + enabled, + zoomState, + zoomActions, + constraints, + onTap, +}: UseImageZoomTouchProps) { + const {scale, position} = zoomState; + const {setScale, setPosition} = zoomActions; + const {constrainPosition} = constraints; + + const scaleRef = useLatest(scale); + const positionRef = useLatest(position); + const onTapRef = useLatest(onTap); + + const [touchState, setTouchState] = React.useState({ + lastTapTime: 0, + lastTapPosition: null, + initialDistance: null, + initialScale: 1, + touchStartPosition: null, + initialPosition: {x: 0, y: 0}, + isTouching: false, + touchCount: 0, + }); + + /** + * Helper: Calculate distance between two touch points + */ + const getTouchDistance = React.useCallback( + (touch1: React.Touch, touch2: React.Touch): number => { + const dx = touch1.clientX - touch2.clientX; + const dy = touch1.clientY - touch2.clientY; + return Math.sqrt(dx * dx + dy * dy); + }, + [], + ); + + /** + * Helper: Check if current tap is a double tap + */ + const isDoubleTap = React.useCallback( + (currentTime: number, currentPos: Position): boolean => { + const {lastTapTime, lastTapPosition} = touchState; + + if (!lastTapPosition) { + return false; + } + + const timeDiff = currentTime - lastTapTime; + const dx = currentPos.x - lastTapPosition.x; + const dy = currentPos.y - lastTapPosition.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + return timeDiff < DOUBLE_TAP_DELAY_MS && distance < DOUBLE_TAP_DISTANCE_PX; + }, + [touchState], + ); + + /** + * Handle touch start - detect gesture type + * + * IMPORTANT: Only stop propagation when we need to handle the gesture ourselves. + * When image is not zoomed and it's a single touch, let the event propagate + * so gallery swipe can work. + */ + const handleTouchStart = React.useCallback( + (event: React.TouchEvent) => { + if (!enabled) { + return; + } + + const touchCount = event.touches.length; + + if (touchCount === 1) { + // Single touch - could be tap or drag + const touch = event.touches[0]; + const touchPos = {x: touch.clientX, y: touch.clientY}; + + const isZoomed = scaleRef.current > MIN_SCALE; + + setTouchState((prev) => ({ + ...prev, + touchStartPosition: touchPos, + initialPosition: positionRef.current, + // Only set isTouching if image is zoomed (we'll handle drag) + isTouching: isZoomed, + touchCount: 1, + })); + + // Only stop propagation if image is zoomed (we need to handle drag) + // Otherwise let gallery swipe work + if (isZoomed) { + event.stopPropagation(); + } + } else if (touchCount === 2) { + // Two fingers - always handle pinch, stop propagation + event.stopPropagation(); + + const touch1 = event.touches[0]; + const touch2 = event.touches[1]; + const distance = getTouchDistance(touch1, touch2); + + setTouchState((prev) => ({ + ...prev, + initialDistance: distance, + initialScale: scaleRef.current, + initialPosition: positionRef.current, + isTouching: true, + touchCount: 2, + })); + } + }, + [enabled, getTouchDistance, positionRef, scaleRef], + ); + + /** + * Handle touch move - process pinch or drag + */ + const handleTouchMove = React.useCallback( + (event: React.TouchEvent) => { + if (!enabled) { + return; + } + + const touchCount = event.touches.length; + + if (touchCount === 2 && touchState.initialDistance !== null) { + event.stopPropagation(); + + // Pinch-to-zoom + const touch1 = event.touches[0]; + const touch2 = event.touches[1]; + const currentDistance = getTouchDistance(touch1, touch2); + const distanceRatio = currentDistance / touchState.initialDistance; + + // Calculate new scale + let newScale = touchState.initialScale * distanceRatio; + newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE_TOUCH, newScale)); + + setScale(newScale); + + // Constrain position for new scale + const constrainedPosition = constrainPosition(touchState.initialPosition, newScale); + setPosition(constrainedPosition); + } else if ( + touchCount === 1 && + touchState.touchStartPosition && + scaleRef.current > MIN_SCALE + ) { + event.stopPropagation(); + + // Single finger drag when zoomed + const touch = event.touches[0]; + const deltaX = touch.clientX - touchState.touchStartPosition.x; + const deltaY = touch.clientY - touchState.touchStartPosition.y; + + // Update position with constraints + const newPosition = constrainPosition( + { + x: touchState.initialPosition.x + deltaX, + y: touchState.initialPosition.y + deltaY, + }, + scaleRef.current, + ); + + setPosition(newPosition); + } + }, + [enabled, touchState, getTouchDistance, constrainPosition, scaleRef, setScale, setPosition], + ); + + /** + * Handle touch end - finalize gesture, detect double tap + * + * IMPORTANT: Only stop propagation when we're handling zoom-related gestures. + * For single taps when not zoomed, let the event propagate for gallery navigation. + */ + const handleTouchEnd = React.useCallback( + (event: React.TouchEvent) => { + if (!enabled) { + return; + } + + const currentTime = Date.now(); + const touchCount = event.changedTouches.length; + + if (touchCount === 1 && touchState.touchCount === 1) { + // Single touch ended - check for double tap + const touch = event.changedTouches[0]; + const touchPos = {x: touch.clientX, y: touch.clientY}; + + // Check if this was a tap (not a drag) + if (touchState.touchStartPosition) { + const isZoomed = scaleRef.current > MIN_SCALE; + + const dx = touchPos.x - touchState.touchStartPosition.x; + const dy = touchPos.y - touchState.touchStartPosition.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < DRAG_THRESHOLD_PX) { + // This was a tap, check for double tap + if (isDoubleTap(currentTime, touchPos)) { + // Double tap detected - toggle zoom + // Always stop propagation for double tap + event.stopPropagation(); + + if (isZoomed) { + setScale(MIN_SCALE); + setPosition({x: 0, y: 0}); + } else { + setScale(MAX_SCALE_TOUCH); + setPosition({x: 0, y: 0}); + } + + // Reset tap tracking + setTouchState((prev) => ({ + ...prev, + lastTapTime: 0, + lastTapPosition: null, + touchStartPosition: null, + isTouching: false, + touchCount: 0, + })); + } else { + setTouchState((prev) => ({ + ...prev, + lastTapTime: currentTime, + lastTapPosition: touchPos, + touchStartPosition: null, + isTouching: false, + touchCount: 0, + })); + if (isZoomed) { + onTapRef.current?.(event); + } + } + } else { + // This was a drag + // Stop propagation only if image was zoomed (we handled the drag) + if (scaleRef.current > MIN_SCALE) { + event.stopPropagation(); + } + + setTouchState((prev) => ({ + ...prev, + touchStartPosition: null, + isTouching: false, + touchCount: 0, + })); + } + } + } else { + // Multi-touch gesture ended (pinch) + // Stop propagation only if we were actually handling pinch + if (touchState.initialDistance !== null) { + event.stopPropagation(); + } + + setTouchState((prev) => ({ + ...prev, + initialDistance: null, + touchStartPosition: null, + isTouching: false, + touchCount: 0, + })); + } + }, + [enabled, touchState, isDoubleTap, scaleRef, setScale, setPosition, onTapRef], + ); + + return { + handlers: enabled + ? { + onTouchStart: handleTouchStart, + onTouchMove: handleTouchMove, + onTouchEnd: handleTouchEnd, + } + : {}, + isTouching: touchState.isTouching, + }; +} diff --git a/src/components/Gallery/hooks/useImageZoom/utils.ts b/src/components/Gallery/hooks/useImageZoom/utils.ts new file mode 100644 index 00000000..cf0abbea --- /dev/null +++ b/src/components/Gallery/hooks/useImageZoom/utils.ts @@ -0,0 +1,81 @@ +import type {Position, Ref, Size} from './types'; + +export function checkImageFitsContainer( + imageSize: Size, + containerSize: Size, + scale: number, +): boolean { + if ( + imageSize.width === 0 || + imageSize.height === 0 || + containerSize.width === 0 || + containerSize.height === 0 + ) { + return true; + } + + const scaledWidth = imageSize.width * scale; + const scaledHeight = imageSize.height * scale; + + return scaledWidth <= containerSize.width && scaledHeight <= containerSize.height; +} + +/** + * Creates a function that constrains pan position to keep image within visible bounds + * + * Logic: + * 1. Calculate displayed image dimensions (how image fits in container) + * 2. Calculate scaled image dimensions (displaySize * scale) + * 3. Calculate maximum allowed offset: maxOffset = (scaledSize - displaySize) / 2 + * 4. Clamp position to [-maxOffset, +maxOffset] + * + * @param imageSizeRef - Reference to current image size + * @param containerSizeRef - Reference to current container size + * @returns Function that constrains position based on scale + */ +export function createConstrainPosition( + imageSizeRef: Ref, + containerSizeRef: Ref, +): (pos: Position, currentScale: number) => Position { + return (pos: Position, currentScale: number): Position => { + if ( + imageSizeRef.current.width === 0 || + imageSizeRef.current.height === 0 || + containerSizeRef.current.width === 0 || + containerSizeRef.current.height === 0 + ) { + return {x: 0, y: 0}; + } + + // Calculate display dimensions (how image fits in container) + const imageAspect = imageSizeRef.current.width / imageSizeRef.current.height; + const containerAspect = containerSizeRef.current.width / containerSizeRef.current.height; + + let displayWidth: number; + let displayHeight: number; + + if (imageAspect > containerAspect) { + // Image is wider - constrained by width + displayWidth = containerSizeRef.current.width; + displayHeight = containerSizeRef.current.width / imageAspect; + } else { + // Image is taller - constrained by height + displayHeight = containerSizeRef.current.height; + displayWidth = containerSizeRef.current.height * imageAspect; + } + + // Calculate scaled dimensions + const scaledWidth = displayWidth * currentScale; + const scaledHeight = displayHeight * currentScale; + + // Calculate max offset (how far we can pan) + const maxOffsetX = Math.max(0, (scaledWidth - displayWidth) / 2); + const maxOffsetY = Math.max(0, (scaledHeight - displayHeight) / 2); + + // Clamp position + return { + x: Math.max(-maxOffsetX, Math.min(maxOffsetX, pos.x)), + y: Math.max(-maxOffsetY, Math.min(maxOffsetY, pos.y)), + }; + }; +} diff --git a/src/components/Gallery/hooks/useLatest.ts b/src/components/Gallery/hooks/useLatest.ts new file mode 100644 index 00000000..6574dc08 --- /dev/null +++ b/src/components/Gallery/hooks/useLatest.ts @@ -0,0 +1,7 @@ +import * as React from 'react'; + +export function useLatest(value: T): {readonly current: T} { + const ref = React.useRef(value); + ref.current = value; + return ref; +} diff --git a/src/components/Gallery/hooks/useMobileGestures/useMobileGestures.ts b/src/components/Gallery/hooks/useMobileGestures/useMobileGestures.ts index 0dcd4e6c..3ce7153f 100644 --- a/src/components/Gallery/hooks/useMobileGestures/useMobileGestures.ts +++ b/src/components/Gallery/hooks/useMobileGestures/useMobileGestures.ts @@ -1,5 +1,7 @@ import * as React from 'react'; +import {useLatest} from '../useLatest'; + import {MAX_TAP_DURATION, MIN_SWIPE_DISTANCE} from './constants'; import {isTouchOnGalleryContent, swipeWithSwithingAnimation} from './utils'; @@ -8,9 +10,15 @@ export type UseMobileGesturesProps = { onSwipeRight?: () => void; onTap?: () => void; enableSwitchAnimation?: boolean; + disabled?: boolean; }; -export function useMobileGestures({onSwipeLeft, onSwipeRight, onTap}: UseMobileGesturesProps = {}) { +export function useMobileGestures({ + onSwipeLeft, + onSwipeRight, + onTap, + disabled, +}: UseMobileGesturesProps = {}) { const [isSwitching, setIsSwitching] = React.useState(false); const [startPosition, setStartPosition] = React.useState<{x: number; y: number} | null>(null); const [startDistance, setStartDistance] = React.useState(null); @@ -18,6 +26,7 @@ export function useMobileGestures({onSwipeLeft, onSwipeRight, onTap}: UseMobileG const [hasMoved, setHasMoved] = React.useState(false); const [touchStartTarget, setTouchStartTarget] = React.useState(null); const [pendingSwipe, setPendingSwipe] = React.useState<'left' | 'right' | null>(null); + const disabledRef = useLatest(disabled); const handleTouchStart = React.useCallback((e: React.TouchEvent) => { if (e.touches.length === 1) { @@ -31,6 +40,8 @@ export function useMobileGestures({onSwipeLeft, onSwipeRight, onTap}: UseMobileG const handleTouchMove = React.useCallback( (e: React.TouchEvent) => { + if (disabledRef.current) return; + if (e.touches.length === 1 && startPosition) { const currentX = e.touches[0].clientX; const currentY = e.touches[0].clientY; @@ -52,15 +63,16 @@ export function useMobileGestures({onSwipeLeft, onSwipeRight, onTap}: UseMobileG } } }, - [startPosition, onSwipeRight, onSwipeLeft], + [disabledRef, startPosition, onSwipeRight, onSwipeLeft], ); const handleTouchEnd = React.useCallback(() => { + const disabled = disabledRef.current; const touchEndTime = Date.now(); const touchDuration = touchStartTime ? touchEndTime - touchStartTime : 0; // Execute pending swipe if detected - if (pendingSwipe) { + if (!disabled && pendingSwipe) { if (pendingSwipe === 'right' && onSwipeRight) { swipeWithSwithingAnimation({ swipeAction: onSwipeRight, @@ -80,6 +92,7 @@ export function useMobileGestures({onSwipeLeft, onSwipeRight, onTap}: UseMobileG // - No significant movement occurred // - Touch is on gallery content (not on overlay elements) else if ( + !disabled && onTap && touchStartTime && touchDuration < MAX_TAP_DURATION && @@ -97,6 +110,7 @@ export function useMobileGestures({onSwipeLeft, onSwipeRight, onTap}: UseMobileG setTouchStartTarget(null); setPendingSwipe(null); }, [ + disabledRef, touchStartTime, hasMoved, startDistance, diff --git a/src/components/Gallery/index.ts b/src/components/Gallery/index.ts index a19b7218..9bda4b1b 100644 --- a/src/components/Gallery/index.ts +++ b/src/components/Gallery/index.ts @@ -2,6 +2,11 @@ export * from './Gallery'; export * from './GalleryItem'; export * from './components/FallbackText'; export * from './components/GalleryItemName'; +export { + useImageZoom as useGalleryImageZoom, + type UseImageZoomProps as UseGalleryImageZoomProps, +} from './hooks/useImageZoom'; +export {type GalleryContextValue, useGalleryContext} from './contexts/GalleryContext'; export {getGalleryItemVideo} from './utils/getGalleryItemVideo'; export {getGalleryItemImage} from './utils/getGalleryItemImage'; export {getGalleryItemDocument} from './utils/getGalleryItemDocument'; From bfd1905cc3767c223849ad456345c0d631276b7e Mon Sep 17 00:00:00 2001 From: Yuriy Demidov Date: Fri, 28 Nov 2025 15:00:24 +0300 Subject: [PATCH 2/2] fix(Gallery): do not call useTranslation() hook in gallery action builders --- src/components/Gallery/GalleryItem.tsx | 8 +- .../DesktopGalleryHeader.tsx | 37 ++++++--- .../MobileGalleryActions.tsx | 79 ++++++++++++------- src/components/Gallery/i18n/index.ts | 7 ++ .../utils/getGalleryItemCopyLinkAction.tsx | 8 +- .../utils/getGalleryItemDownloadAction.tsx | 6 +- 6 files changed, 97 insertions(+), 48 deletions(-) diff --git a/src/components/Gallery/GalleryItem.tsx b/src/components/Gallery/GalleryItem.tsx index d1db1852..7ed9f6f2 100644 --- a/src/components/Gallery/GalleryItem.tsx +++ b/src/components/Gallery/GalleryItem.tsx @@ -1,15 +1,21 @@ -import * as React from 'react'; +import type * as React from 'react'; import {ButtonProps} from '@gravity-ui/uikit'; +import type {TProps, WithTFn} from './i18n'; + export type GalleryItemAction = { id: string; title: string; + /** @internal */ + __titleT?: WithTFn; hotkey?: string; onClick?: () => void; href?: string; icon: React.ReactNode; render?: (props: ButtonProps) => React.ReactNode; + /** @internal */ + __renderT?: (props: ButtonProps, tProps: TProps) => React.ReactNode; }; export type GalleryItemProps = { diff --git a/src/components/Gallery/components/DesktopGalleryHeader/DesktopGalleryHeader.tsx b/src/components/Gallery/components/DesktopGalleryHeader/DesktopGalleryHeader.tsx index 4ca88445..7f517b0a 100644 --- a/src/components/Gallery/components/DesktopGalleryHeader/DesktopGalleryHeader.tsx +++ b/src/components/Gallery/components/DesktopGalleryHeader/DesktopGalleryHeader.tsx @@ -63,6 +63,7 @@ export const DesktopGalleryHeader = ({ )}
{actions?.map((action) => { + const title = action.__titleT ? action.__titleT({t}) : action.title; const buttonProps: ButtonProps = { type: 'button', size: 'l', @@ -70,18 +71,36 @@ export const DesktopGalleryHeader = ({ onClick: action.onClick, href: action.href, target: '__blank', - 'aria-label': action.title, + 'aria-label': title, children: action.icon, }; - return action.render ? ( - - {action.render(buttonProps)} - - ) : ( - -