From 3455e23d6647a11dd6e5b047710a31a3b404ec2d Mon Sep 17 00:00:00 2001 From: Aleksandr what3vergl Nokhrin Date: Mon, 20 Apr 2026 17:10:46 +0300 Subject: [PATCH 1/4] feat(Gallery): add built-in image rotation support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add rotate-left/rotate-right actions for Gallery images with 90° increments via CSS transform. - Add GalleryImageRotationContext to share rotation state between header actions and image view - Add useImageRotation hook with useMemo-based styles, container dimension tracking, and max-width/max-height swap for 90/270 rotations to prevent cropping - Use unbounded rotation values (no modulo) so CSS transitions animate the correct direction - Merge zoom and rotation transforms in ImageView; reset zoom when rotation changes - Add getGalleryItemRotateLeftAction and getGalleryItemRotateRightAction utilities using __renderT for context-aware rendering - Add rotate-left/rotate-right i18n keys (en + ru) - Export new hook, context, and action utilities from package index --- src/components/Gallery/Gallery.tsx | 161 ++++++++++-------- .../Gallery/__stories__/Gallery.stories.tsx | 34 ++++ .../components/views/ImageView/ImageView.tsx | 26 ++- .../GalleryImageRotationContext.tsx | 31 ++++ .../GalleryImageRotationContext/index.ts | 1 + .../hooks/useImageRotation/constants.ts | 2 + .../Gallery/hooks/useImageRotation/index.ts | 1 + .../useImageRotation/useImageRotation.ts | 40 +++++ src/components/Gallery/i18n/en.json | 4 +- src/components/Gallery/i18n/ru.json | 4 +- src/components/Gallery/index.ts | 15 +- .../utils/getGalleryItemRotateLeftAction.tsx | 45 +++++ .../utils/getGalleryItemRotateRightAction.tsx | 45 +++++ 13 files changed, 328 insertions(+), 81 deletions(-) create mode 100644 src/components/Gallery/contexts/GalleryImageRotationContext/GalleryImageRotationContext.tsx create mode 100644 src/components/Gallery/contexts/GalleryImageRotationContext/index.ts create mode 100644 src/components/Gallery/hooks/useImageRotation/constants.ts create mode 100644 src/components/Gallery/hooks/useImageRotation/index.ts create mode 100644 src/components/Gallery/hooks/useImageRotation/useImageRotation.ts create mode 100644 src/components/Gallery/utils/getGalleryItemRotateLeftAction.tsx create mode 100644 src/components/Gallery/utils/getGalleryItemRotateRightAction.tsx diff --git a/src/components/Gallery/Gallery.tsx b/src/components/Gallery/Gallery.tsx index dd660bb3..aaa9fa9d 100644 --- a/src/components/Gallery/Gallery.tsx +++ b/src/components/Gallery/Gallery.tsx @@ -9,7 +9,9 @@ import {GalleryHeader} from './components/GalleryHeader/GalleryHeader'; import {NavigationButton} from './components/NavigationButton/NavigationButton'; import {BODY_CONTENT_CLASS_NAME, cnGallery} from './constants'; import {GalleryContextProvider} from './contexts/GalleryContext'; +import {GalleryImageRotationProvider} from './contexts/GalleryImageRotationContext'; import {useFullScreen} from './hooks/useFullScreen'; +import {ROTATION_STEP} from './hooks/useImageRotation/constants'; import {useMobileGestures} from './hooks/useMobileGestures/useMobileGestures'; import type {UseNavigationProps} from './hooks/useNavigation'; import {useNavigation} from './hooks/useNavigation'; @@ -70,6 +72,14 @@ export const Gallery = ({ const {fullScreen, setFullScreen} = useFullScreen(); + const [rotation, setRotation] = React.useState(0); + const rotateLeft = React.useCallback(() => setRotation((r) => r - ROTATION_STEP), []); + const rotateRight = React.useCallback(() => setRotation((r) => r + ROTATION_STEP), []); + + React.useEffect(() => { + setRotation(0); + }, [activeItemIndex]); + const handleBackClick = React.useCallback(() => { onOpenChange?.(false); }, [onOpenChange]); @@ -131,80 +141,91 @@ export const Gallery = ({ overflow: mode === 'default' ? 'auto' : 'hidden', }} > -
-
- + ); }; diff --git a/src/components/Gallery/contexts/GalleryContext/GalleryContext.tsx b/src/components/Gallery/contexts/GalleryContext/GalleryContext.tsx index 55f3824e..05e08241 100644 --- a/src/components/Gallery/contexts/GalleryContext/GalleryContext.tsx +++ b/src/components/Gallery/contexts/GalleryContext/GalleryContext.tsx @@ -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({ onTap: () => {}, onViewInteractionChange: () => {}, + rotation: 0, + rotateLeft: () => {}, + rotateRight: () => {}, }); export const GalleryContextProvider: React.FunctionComponent< React.PropsWithChildren -> = 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 {children}; }; /** * 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); diff --git a/src/components/Gallery/contexts/GalleryImageRotationContext/GalleryImageRotationContext.tsx b/src/components/Gallery/contexts/GalleryImageRotationContext/GalleryImageRotationContext.tsx deleted file mode 100644 index 344de4aa..00000000 --- a/src/components/Gallery/contexts/GalleryImageRotationContext/GalleryImageRotationContext.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import * as React from 'react'; - -export type GalleryImageRotationContextValue = { - rotation: number; - rotateLeft: () => void; - rotateRight: () => void; -}; - -const GalleryImageRotationContext = React.createContext({ - rotation: 0, - rotateLeft: () => {}, - rotateRight: () => {}, -}); - -export type GalleryImageRotationProviderProps = - React.PropsWithChildren; - -export const GalleryImageRotationProvider: React.FunctionComponent = - function GalleryImageRotationProvider({children, rotation, rotateLeft, rotateRight}) { - const value = React.useMemo( - () => ({rotation, rotateLeft, rotateRight}), - [rotation, rotateLeft, rotateRight], - ); - return ( - - {children} - - ); - }; - -export const useGalleryImageRotationContext = () => React.useContext(GalleryImageRotationContext); diff --git a/src/components/Gallery/contexts/GalleryImageRotationContext/index.ts b/src/components/Gallery/contexts/GalleryImageRotationContext/index.ts deleted file mode 100644 index c01805e0..00000000 --- a/src/components/Gallery/contexts/GalleryImageRotationContext/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './GalleryImageRotationContext'; diff --git a/src/components/Gallery/hooks/useImageRotation/constants.ts b/src/components/Gallery/hooks/useImageRotation/constants.ts index 2a1d15cd..d683ebdd 100644 --- a/src/components/Gallery/hooks/useImageRotation/constants.ts +++ b/src/components/Gallery/hooks/useImageRotation/constants.ts @@ -1,2 +1,3 @@ export const ROTATION_STEP = 90; // degrees per rotate-left / rotate-right action export const FULL_ROTATION = 360; // degrees in a full rotation +export const INITIAL_ROTATION = 0; // degrees at the start diff --git a/src/components/Gallery/hooks/useImageRotation/useImageRotation.ts b/src/components/Gallery/hooks/useImageRotation/useImageRotation.ts index f1eef852..97545ab2 100644 --- a/src/components/Gallery/hooks/useImageRotation/useImageRotation.ts +++ b/src/components/Gallery/hooks/useImageRotation/useImageRotation.ts @@ -1,6 +1,6 @@ import * as React from 'react'; -import {useGalleryImageRotationContext} from '../../contexts/GalleryImageRotationContext'; +import {useGalleryContext} from '../../contexts/GalleryContext'; import {FULL_ROTATION, ROTATION_STEP} from './constants'; @@ -8,6 +8,7 @@ export type UseImageRotationReturn = { /** * Styles for the `` element: the `rotate` transform part plus * swapped max-width/max-height constraints for 90°/270° rotations. + * Returns an empty object until the container has been measured. */ imageRotationStyles: React.CSSProperties; rotation: number; @@ -16,9 +17,13 @@ export type UseImageRotationReturn = { setContainerDims: React.Dispatch>; }; -/** Hook for reading image rotation state from GalleryImageRotationContext. */ +/** + * Hook for reading image rotation state from GalleryContext. + * + * @returns Rotation state, actions, computed styles, and a setter for container dimensions. + */ export function useImageRotation(): UseImageRotationReturn { - const {rotation, rotateLeft, rotateRight} = useGalleryImageRotationContext(); + const {rotation, rotateLeft, rotateRight} = useGalleryContext(); const [containerDims, setContainerDims] = React.useState({width: 0, height: 0}); const normalizedRotation = ((rotation % FULL_ROTATION) + FULL_ROTATION) % FULL_ROTATION; @@ -26,15 +31,18 @@ export function useImageRotation(): UseImageRotationReturn { normalizedRotation === ROTATION_STEP || normalizedRotation === FULL_ROTATION - ROTATION_STEP; - const imageRotationStyles = React.useMemo( - () => ({ + const imageRotationStyles = React.useMemo(() => { + if (containerDims.width <= 0 || containerDims.height <= 0) { + return {}; + } + + return { ...(rotation ? {transform: `rotate(${rotation}deg)`} : {}), - ...(isHorizontalRotation && containerDims.width > 0 + ...(isHorizontalRotation ? {maxWidth: containerDims.height, maxHeight: containerDims.width} : {}), - }), - [rotation, isHorizontalRotation, containerDims], - ); + }; + }, [rotation, isHorizontalRotation, containerDims]); return {imageRotationStyles, rotation, rotateLeft, rotateRight, setContainerDims}; } diff --git a/src/components/Gallery/hooks/useImageRotationState/index.ts b/src/components/Gallery/hooks/useImageRotationState/index.ts new file mode 100644 index 00000000..89c2b553 --- /dev/null +++ b/src/components/Gallery/hooks/useImageRotationState/index.ts @@ -0,0 +1 @@ +export * from './useImageRotationState'; diff --git a/src/components/Gallery/hooks/useImageRotationState/useImageRotationState.ts b/src/components/Gallery/hooks/useImageRotationState/useImageRotationState.ts new file mode 100644 index 00000000..d866d351 --- /dev/null +++ b/src/components/Gallery/hooks/useImageRotationState/useImageRotationState.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; + +import {INITIAL_ROTATION, ROTATION_STEP} from '../useImageRotation/constants'; + +export type UseImageRotationStateReturn = { + rotation: number; + rotateLeft: VoidFunction; + rotateRight: VoidFunction; + resetRotation: VoidFunction; +}; + +/** + * Hook that owns image rotation state and exposes rotation actions. + * + * @returns Current rotation and actions to rotate left/right or reset. + */ +export function useImageRotationState(): UseImageRotationStateReturn { + const [rotation, setRotation] = React.useState(INITIAL_ROTATION); + + const rotateLeft = React.useCallback(() => setRotation((r) => r - ROTATION_STEP), []); + const rotateRight = React.useCallback(() => setRotation((r) => r + ROTATION_STEP), []); + const resetRotation = React.useCallback(() => setRotation(INITIAL_ROTATION), []); + + return {rotation, rotateLeft, rotateRight, resetRotation}; +} diff --git a/src/components/Gallery/index.ts b/src/components/Gallery/index.ts index 71032778..892839a6 100644 --- a/src/components/Gallery/index.ts +++ b/src/components/Gallery/index.ts @@ -10,11 +10,11 @@ export { useImageRotation as useGalleryImageRotation, type UseImageRotationReturn as UseGalleryImageRotationReturn, } from './hooks/useImageRotation'; -export {type GalleryContextValue, useGalleryContext} from './contexts/GalleryContext'; export { - type GalleryImageRotationContextValue, - useGalleryImageRotationContext, -} from './contexts/GalleryImageRotationContext'; + useImageRotationState as useGalleryImageRotationState, + type UseImageRotationStateReturn as UseGalleryImageRotationStateReturn, +} from './hooks/useImageRotationState'; +export {type GalleryContextValue, useGalleryContext} from './contexts/GalleryContext'; export {getGalleryItemVideo} from './utils/getGalleryItemVideo'; export { getGalleryItemImage, diff --git a/src/components/Gallery/utils/getGalleryItemRotateLeftAction.tsx b/src/components/Gallery/utils/getGalleryItemRotateLeftAction.tsx index 02330fe4..787b6818 100644 --- a/src/components/Gallery/utils/getGalleryItemRotateLeftAction.tsx +++ b/src/components/Gallery/utils/getGalleryItemRotateLeftAction.tsx @@ -5,7 +5,7 @@ import {ActionTooltip, Button, Icon} from '@gravity-ui/uikit'; import type {ButtonProps} from '@gravity-ui/uikit'; import type {GalleryItemAction} from '../GalleryItem'; -import {useGalleryImageRotationContext} from '../contexts/GalleryImageRotationContext'; +import {useGalleryContext} from '../contexts/GalleryContext'; import type {TProps} from '../i18n'; type GetGalleryItemRotateLeftActionArgs = { @@ -20,7 +20,7 @@ type RotateLeftButtonProps = { }; const RotateLeftButton = ({buttonProps, icon, title}: RotateLeftButtonProps) => { - const {rotateLeft} = useGalleryImageRotationContext(); + const {rotateLeft} = useGalleryContext(); return (