{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)}
-
- ) : (
-
-
-
+ const render = () => {
+ if (action.__renderT) {
+ return (
+
+ {action.__renderT(buttonProps, {t})}
+
+ );
+ }
+
+ if (action.render) {
+ return (
+
+ {action.render(buttonProps)}
+
+ );
+ }
+
+ return null;
+ };
+
+ return (
+ render() ?? (
+
+
+
+ )
);
})}
{
- const renderListItem = React.useCallback((item: GalleryItemAction) => {
- const buttonProps: ButtonProps = {
- type: 'button',
- size: 'xl',
- view: 'flat',
- onClick: item.onClick,
- href: item.href,
- target: '__blank',
- 'aria-label': item.title,
- className: cnMobileGalleryActions('list-item'),
- width: 'max',
- children: (
-
- {item.icon}
- {item.title}
-
- ),
- };
-
- return (
-
- {item.render ? (
- {item.render(buttonProps)}
- ) : (
-
- )}
-
- );
- }, []);
+ const {t} = i18n.useTranslation();
+
+ const renderListItem = React.useCallback(
+ (item: GalleryItemAction) => {
+ const title = item.__titleT ? item.__titleT({t}) : item.title;
+ const buttonProps: ButtonProps = {
+ type: 'button',
+ size: 'xl',
+ view: 'flat',
+ onClick: item.onClick,
+ href: item.href,
+ target: '__blank',
+ 'aria-label': title,
+ className: cnMobileGalleryActions('list-item'),
+ width: 'max',
+ children: (
+
+ {item.icon}
+ {title}
+
+ ),
+ };
+
+ const render = () => {
+ if (item.__renderT) {
+ return (
+
+ {item.__renderT(buttonProps, {t})}
+
+ );
+ }
+
+ if (item.render) {
+ return (
+ {item.render(buttonProps)}
+ );
+ }
+
+ return null;
+ };
+
+ return render() ?? ;
+ },
+ [t],
+ );
return (
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' &&
}

-
+
);
};
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/i18n/index.ts b/src/components/Gallery/i18n/index.ts
index cdcec43f..dff46c5d 100644
--- a/src/components/Gallery/i18n/index.ts
+++ b/src/components/Gallery/i18n/index.ts
@@ -6,3 +6,10 @@ import en from './en.json';
import ru from './ru.json';
export const i18n = addComponentKeysets({en, ru}, `${NAMESPACE}gallery`);
+
+/** @internal */
+export type TFn = ReturnType<(typeof i18n)['useTranslation']>['t'];
+/** @internal */
+export type TProps = {t: TFn};
+/** @internal */
+export type WithTFn = (props: TProps) => string;
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';
diff --git a/src/components/Gallery/utils/getGalleryItemCopyLinkAction.tsx b/src/components/Gallery/utils/getGalleryItemCopyLinkAction.tsx
index aef97bf7..b7e13749 100644
--- a/src/components/Gallery/utils/getGalleryItemCopyLinkAction.tsx
+++ b/src/components/Gallery/utils/getGalleryItemCopyLinkAction.tsx
@@ -2,7 +2,6 @@ import {Link} from '@gravity-ui/icons';
import {ActionTooltip, Button, CopyToClipboard, Icon} from '@gravity-ui/uikit';
import {GalleryItemAction} from '../GalleryItem';
-import {i18n} from '../i18n';
export type GetGalleryItemCopyLinkActionArgs = {
copyUrl: string;
@@ -13,13 +12,12 @@ export function getGalleryItemCopyLinkAction({
copyUrl,
onCopy,
}: GetGalleryItemCopyLinkActionArgs): GalleryItemAction {
- const {t} = i18n.useTranslation();
-
return {
id: 'copy-url',
- title: t('copy-link'),
+ title: 'copy-url',
+ __titleT: ({t}) => t('copy-link'),
icon: ,
- render: (props) => (
+ __renderT: (props, {t}) => (
{() => (
diff --git a/src/components/Gallery/utils/getGalleryItemDownloadAction.tsx b/src/components/Gallery/utils/getGalleryItemDownloadAction.tsx
index 1b525eb8..48ed7f19 100644
--- a/src/components/Gallery/utils/getGalleryItemDownloadAction.tsx
+++ b/src/components/Gallery/utils/getGalleryItemDownloadAction.tsx
@@ -2,7 +2,6 @@ import {ArrowDownToLine} from '@gravity-ui/icons';
import {Icon} from '@gravity-ui/uikit';
import {GalleryItemAction} from '../GalleryItem';
-import {i18n} from '../i18n';
export type GetGalleryItemDownloadActionArgs = {
downloadUrl: string;
@@ -13,8 +12,6 @@ export function getGalleryItemDownloadAction({
downloadUrl,
onClick,
}: GetGalleryItemDownloadActionArgs): GalleryItemAction {
- const {t} = i18n.useTranslation();
-
const handleClick = (event?: MouseEvent) => {
event?.stopPropagation();
onClick?.();
@@ -22,7 +19,8 @@ export function getGalleryItemDownloadAction({
return {
id: 'download',
- title: t('download'),
+ title: 'download',
+ __titleT: ({t}) => t('download'),
icon: ,
href: downloadUrl,
onClick: handleClick,