From e84a26b5cf96ac1ecf79e938d80ed88fdf47b4c8 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Thu, 28 May 2026 13:10:27 +0530 Subject: [PATCH] Fix production zoom scroll behavior --- .../src/components/Media/ZoomableImage.tsx | 367 ++++++++---------- .../Media/__tests__/ZoomableImage.test.tsx | 128 ++++++ 2 files changed, 295 insertions(+), 200 deletions(-) create mode 100644 frontend/src/components/Media/__tests__/ZoomableImage.test.tsx diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index 7b9b04845..21f35b987 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -51,6 +51,7 @@ export interface ZoomableImageRef { export const ZoomableImage = forwardRef( ({ imagePath, alt, rotation, resetSignal }, ref) => { const transformRef = useRef(null); + const wheelAreaRef = useRef(null); const imageRef = useRef(null); const [isOverflowing, setIsOverflowing] = useState(false); const rotationRef = useRef(rotation); @@ -94,16 +95,20 @@ export const ZoomableImage = forwardRef( [getEffectiveDimensions], ); + const getViewportElement = useCallback( + () => + transformRef.current?.instance?.wrapperComponent ?? + wheelAreaRef.current, + [], + ); + const handleReset = useCallback( (duration = 200, animationType: AnimationType = 'easeOut') => { - if ( - !transformRef.current?.instance?.wrapperComponent || - !imageRef.current - ) + const viewportElement = getViewportElement(); + if (!transformRef.current || !viewportElement || !imageRef.current) return; - const wrapperRect = - transformRef.current.instance.wrapperComponent.getBoundingClientRect(); + const wrapperRect = viewportElement.getBoundingClientRect(); const img = imageRef.current; const scale = 1; @@ -137,7 +142,7 @@ export const ZoomableImage = forwardRef( ); setIsOverflowing(false); }, - [getEffectiveDimensions], + [getEffectiveDimensions, getViewportElement], ); useImperativeHandle(ref, () => ({ @@ -151,19 +156,16 @@ export const ZoomableImage = forwardRef( }, [resetSignal, handleReset]); useEffect(() => { - if ( - !transformRef.current?.instance?.wrapperComponent || - !imageRef.current - ) + const viewportElement = getViewportElement(); + if (!transformRef.current || !viewportElement || !imageRef.current) return; - const wrapper = transformRef.current.instance.wrapperComponent; const scale = transformRef.current.instance.transformState.scale; - const rect = wrapper.getBoundingClientRect(); + const rect = viewportElement.getBoundingClientRect(); const overflow = getOverflowState(scale, rect.width, rect.height); setIsOverflowing(overflow.width || overflow.height); - }, [rotation, getOverflowState]); + }, [rotation, getOverflowState, getViewportElement]); useEffect(() => { setIsOverflowing(false); @@ -184,7 +186,7 @@ export const ZoomableImage = forwardRef( }, [imagePath, handleReset]); useEffect(() => { - const wrapperElement = transformRef.current?.instance?.wrapperComponent; + const wrapperElement = wheelAreaRef.current; if (!wrapperElement) return; let cachedWrapperRect = wrapperElement.getBoundingClientRect(); @@ -200,6 +202,8 @@ export const ZoomableImage = forwardRef( const transformState = transformRef.current.instance.transformState; + cachedWrapperRect = wrapperElement.getBoundingClientRect(); + const wrapperRect = cachedWrapperRect; const imageRect = imageRef.current.getBoundingClientRect(); const mouseX = e.clientX - imageRect.left; const mouseY = e.clientY - imageRect.top; @@ -222,83 +226,38 @@ export const ZoomableImage = forwardRef( Math.min(MAX_SCALE, currentScale + zoomChange), ); - const wrapperRect = cachedWrapperRect; + const baseW = imageRef.current.clientWidth; + const baseH = imageRef.current.clientHeight; + const effectiveDims = getEffectiveDimensions(baseW, baseH); + const nextW = effectiveDims.width * newScale; + const nextH = effectiveDims.height * newScale; + const newOverflow = getOverflowState( newScale, wrapperRect.width, wrapperRect.height, ); - if (zoomChange < 0) { - e.preventDefault(); - e.stopPropagation(); - - setIsOverflowing(newOverflow.width || newOverflow.height); - - const baseW = imageRect.width / currentScale; - const baseH = imageRect.height / currentScale; - - const effectiveDims = getEffectiveDimensions(baseW, baseH); - const finalTargetX = (wrapperRect.width - effectiveDims.width) / 2; - const finalTargetY = (wrapperRect.height - effectiveDims.height) / 2; - - let targetX = finalTargetX; - let targetY = finalTargetY; - - if (currentScale > 1) { - const ratio = (newScale - 1) / (currentScale - 1); - const safeRatio = - isNaN(ratio) || !isFinite(ratio) || ratio < 0 ? 0 : ratio; - - targetX = - transformState.positionX * safeRatio + - finalTargetX * (1 - safeRatio); - targetY = - transformState.positionY * safeRatio + - finalTargetY * (1 - safeRatio); - } - - transformRef.current.setTransform(targetX, targetY, newScale, 0); - return; - } - - if (!newOverflow.width && !newOverflow.height) { - e.preventDefault(); - e.stopPropagation(); - - const baseW = imageRect.width / currentScale; - const baseH = imageRect.height / currentScale; - - const effectiveDims = getEffectiveDimensions(baseW, baseH); - const nextW = effectiveDims.width * newScale; - const nextH = effectiveDims.height * newScale; - - const targetX = (wrapperRect.width - nextW) / 2; - const targetY = (wrapperRect.height - nextH) / 2; - - transformRef.current.setTransform(targetX, targetY, newScale, 0); - return; - } - - if (!isOverImage) { - e.preventDefault(); - e.stopPropagation(); - - const centerX = wrapperRect.width / 2; - const centerY = wrapperRect.height / 2; - - if (currentScale > 0) { - const ratio = newScale / currentScale; - - const targetX = - centerX - (centerX - transformState.positionX) * ratio; - const targetY = - centerY - (centerY - transformState.positionY) * ratio; - - transformRef.current.setTransform(targetX, targetY, newScale, 0); - } - return; - } + const centeredX = (wrapperRect.width - nextW) / 2; + const centeredY = (wrapperRect.height - nextH) / 2; + const ratio = currentScale > 0 ? newScale / currentScale : 1; + const mouseViewportX = e.clientX - wrapperRect.left; + const mouseViewportY = e.clientY - wrapperRect.top; + const anchoredX = + mouseViewportX - (mouseViewportX - transformState.positionX) * ratio; + const anchoredY = + mouseViewportY - (mouseViewportY - transformState.positionY) * ratio; + + const targetX = + isOverImage && newOverflow.width ? anchoredX : centeredX; + const targetY = + isOverImage && newOverflow.height ? anchoredY : centeredY; + + e.preventDefault(); + e.stopPropagation(); + + setIsOverflowing(newOverflow.width || newOverflow.height); + transformRef.current.setTransform(targetX, targetY, newScale, 0); }; wrapperElement.addEventListener('wheel', handleWheelInterceptor, { @@ -317,126 +276,134 @@ export const ZoomableImage = forwardRef( }, [getEffectiveDimensions, getOverflowState]); return ( - { - const scale = ref.state.scale; - const wrapper = ref.instance.wrapperComponent; - if (!wrapper) return; - - const rect = wrapper.getBoundingClientRect(); - const overflow = getOverflowState(scale, rect.width, rect.height); - setIsOverflowing(overflow.width || overflow.height); - }} - onZoom={(ref) => { - const scale = ref.state.scale; - const wrapper = ref.instance.wrapperComponent; - if (!wrapper) return; - - const rect = wrapper.getBoundingClientRect(); - const overflow = getOverflowState(scale, rect.width, rect.height); - setIsOverflowing(overflow.width || overflow.height); - }} - onPanning={(ref) => { - const scale = ref.state.scale; - const wrapper = ref.instance.wrapperComponent; - if (!wrapper || !imageRef.current) return; - - const rect = wrapper.getBoundingClientRect(); - const overflow = getOverflowState(scale, rect.width, rect.height); - setIsOverflowing(overflow.width || overflow.height); - - const positionX = ref.state.positionX; - const positionY = ref.state.positionY; - const viewW = wrapper.clientWidth; - const viewH = wrapper.clientHeight; - const imgW = imageRef.current.clientWidth; - const imgH = imageRef.current.clientHeight; - - const effectiveDims = getEffectiveDimensions(imgW, imgH); - const scaledW = effectiveDims.width * scale; - const scaledH = effectiveDims.height * scale; - - const limitLeft = -scaledW + PAN_PADDING; - const limitRight = viewW - PAN_PADDING; - const limitTop = -scaledH + PAN_PADDING; - const limitBottom = viewH - PAN_PADDING; - - let finalX = positionX; - let finalY = positionY; - let clamped = false; - - if (positionX < limitLeft) { - finalX = limitLeft; - clamped = true; - } else if (positionX > limitRight) { - finalX = limitRight; - clamped = true; - } - - if (positionY < limitTop) { - finalY = limitTop; - clamped = true; - } else if (positionY > limitBottom) { - finalY = limitBottom; - clamped = true; - } - - if (clamped) { - ref.setTransform(finalX, finalY, scale, 0); - } - }} - > - + { + const scale = ref.state.scale; + const wrapper = getViewportElement(); + if (!wrapper) return; + + const rect = wrapper.getBoundingClientRect(); + const overflow = getOverflowState(scale, rect.width, rect.height); + setIsOverflowing(overflow.width || overflow.height); + }} + onZoom={(ref) => { + const scale = ref.state.scale; + const wrapper = getViewportElement(); + if (!wrapper) return; + + const rect = wrapper.getBoundingClientRect(); + const overflow = getOverflowState(scale, rect.width, rect.height); + setIsOverflowing(overflow.width || overflow.height); + }} + onPanning={(ref) => { + const scale = ref.state.scale; + const wrapper = getViewportElement(); + if (!wrapper || !imageRef.current) return; + + const rect = wrapper.getBoundingClientRect(); + const overflow = getOverflowState(scale, rect.width, rect.height); + setIsOverflowing(overflow.width || overflow.height); + + const positionX = ref.state.positionX; + const positionY = ref.state.positionY; + const viewW = wrapper.clientWidth; + const viewH = wrapper.clientHeight; + const imgW = imageRef.current.clientWidth; + const imgH = imageRef.current.clientHeight; + + const effectiveDims = getEffectiveDimensions(imgW, imgH); + const scaledW = effectiveDims.width * scale; + const scaledH = effectiveDims.height * scale; + + const limitLeft = -scaledW + PAN_PADDING; + const limitRight = viewW - PAN_PADDING; + const limitTop = -scaledH + PAN_PADDING; + const limitBottom = viewH - PAN_PADDING; + const centeredX = (viewW - scaledW) / 2; + const centeredY = (viewH - scaledH) / 2; + + let finalX = overflow.width ? positionX : centeredX; + let finalY = overflow.height ? positionY : centeredY; + let clamped = + (!overflow.width && positionX !== centeredX) || + (!overflow.height && positionY !== centeredY); + + if (overflow.width) { + if (positionX < limitLeft) { + finalX = limitLeft; + clamped = true; + } else if (positionX > limitRight) { + finalX = limitRight; + clamped = true; + } + } + + if (overflow.height) { + if (positionY < limitTop) { + finalY = limitTop; + clamped = true; + } else if (positionY > limitBottom) { + finalY = limitBottom; + clamped = true; + } + } + + if (clamped) { + ref.setTransform(finalX, finalY, scale, 0); + } }} > - {alt} { - const img = e.target as HTMLImageElement; - img.onerror = null; - img.src = '/placeholder.svg'; + - - + > + {alt} { + const img = e.target as HTMLImageElement; + img.onerror = null; + img.src = '/placeholder.svg'; + }} + style={{ + maxWidth: '100vw', + maxHeight: '100vh', + objectFit: 'contain', + zIndex: 50, + transform: `rotate(${rotation}deg)`, + cursor: isOverflowing ? 'move' : 'default', + }} + /> + + + ); }, ); diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx new file mode 100644 index 000000000..887c2728e --- /dev/null +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -0,0 +1,128 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode, Ref } from 'react'; +import { ZoomableImage } from '../ZoomableImage'; + +const mockSetTransform = jest.fn(); +const mockZoomIn = jest.fn(); +const mockZoomOut = jest.fn(); +const mockTransformState = { + scale: 1, + positionX: 0, + positionY: 0, +}; + +jest.mock('@tauri-apps/api/core', () => ({ + convertFileSrc: (path: string) => path, +})); + +jest.mock('react-zoom-pan-pinch', () => { + const React = require('react'); + + const TransformWrapper = React.forwardRef( + ({ children }: { children: ReactNode }, ref: Ref) => { + React.useImperativeHandle(ref, () => ({ + instance: { + wrapperComponent: null, + transformState: mockTransformState, + }, + setTransform: mockSetTransform, + zoomIn: mockZoomIn, + zoomOut: mockZoomOut, + })); + + return React.createElement( + 'div', + { 'data-testid': 'transform-wrapper' }, + children, + ); + }, + ); + + const TransformComponent = ({ children }: { children: ReactNode }) => + React.createElement('div', null, children); + + return { + TransformWrapper, + TransformComponent, + }; +}); + +const mockElementRect = ( + element: Element, + rect: Partial, + dimensions?: { clientWidth?: number; clientHeight?: number }, +) => { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => + ({ + left: 0, + top: 0, + right: rect.width ?? 0, + bottom: rect.height ?? 0, + x: 0, + y: 0, + width: 0, + height: 0, + toJSON: () => undefined, + ...rect, + }) as DOMRect, + }); + + if (dimensions?.clientWidth !== undefined) { + Object.defineProperty(element, 'clientWidth', { + configurable: true, + value: dimensions.clientWidth, + }); + } + + if (dimensions?.clientHeight !== undefined) { + Object.defineProperty(element, 'clientHeight', { + configurable: true, + value: dimensions.clientHeight, + }); + } +}; + +describe('ZoomableImage wheel behavior', () => { + beforeEach(() => { + mockSetTransform.mockClear(); + mockZoomIn.mockClear(); + mockZoomOut.mockClear(); + mockTransformState.scale = 1; + mockTransformState.positionX = 0; + mockTransformState.positionY = 0; + }); + + test('intercepts wheel zoom from the stable container when the transform wrapper is not ready', () => { + const { container } = render( + , + ); + + const wheelArea = container.firstElementChild as HTMLElement; + const image = screen.getByAltText('test image'); + + mockElementRect( + wheelArea, + { width: 800, height: 600, left: 0, top: 0 }, + { clientWidth: 800, clientHeight: 600 }, + ); + mockElementRect( + image, + { width: 200, height: 100, left: 100, top: 100 }, + { clientWidth: 200, clientHeight: 100 }, + ); + + fireEvent.wheel(wheelArea, { + deltaY: -100, + clientX: 750, + clientY: 550, + }); + + expect(mockSetTransform).toHaveBeenCalledWith(290, 245, 1.1, 0); + }); +});