From e84a26b5cf96ac1ecf79e938d80ed88fdf47b4c8 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Thu, 28 May 2026 13:10:27 +0530 Subject: [PATCH 01/16] 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); + }); +}); From 5b8af3353c407e6dc88162147fa3f0d2b64b849f Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sun, 31 May 2026 11:32:49 +0530 Subject: [PATCH 02/16] Tighten media viewer scroll zoom bounds --- .../src/components/Media/ZoomableImage.tsx | 211 +++++++++++------- .../Media/__tests__/ZoomableImage.test.tsx | 166 +++++++++++++- 2 files changed, 288 insertions(+), 89 deletions(-) diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index 21f35b987..60bd60496 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -17,7 +17,16 @@ const ZOOM_FACTOR = 0.001; const LINE_HEIGHT_MULTIPLIER = 33; const MAX_SCALE = 8; const MIN_SCALE = 1; -const PAN_PADDING = 20; + +type Size = { + width: number; + height: number; +}; + +type OverflowState = { + width: boolean; + height: boolean; +}; type AnimationType = | 'easeOut' @@ -48,6 +57,36 @@ export interface ZoomableImageRef { reset: () => void; } +const getCenteredAxisPosition = (viewportSize: number, scaledSize: number) => + (viewportSize - scaledSize) / 2; + +const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +const clampOverflowAxisPosition = ( + position: number, + viewportSize: number, + scaledSize: number, +) => { + const minPosition = viewportSize - scaledSize; + const maxPosition = 0; + + return clamp(position, minPosition, maxPosition); +}; + +const getAxisPosition = ( + anchoredPosition: number, + viewportSize: number, + scaledSize: number, + isOverflowingAxis: boolean, +) => { + const centeredPosition = getCenteredAxisPosition(viewportSize, scaledSize); + + return isOverflowingAxis + ? clampOverflowAxisPosition(anchoredPosition, viewportSize, scaledSize) + : centeredPosition; +}; + export const ZoomableImage = forwardRef( ({ imagePath, alt, rotation, resetSignal }, ref) => { const transformRef = useRef(null); @@ -72,29 +111,46 @@ export const ZoomableImage = forwardRef( [], ); - const getOverflowState = useCallback( - (scale: number, viewportWidth: number, viewportHeight: number) => { - if (!imageRef.current) return { width: false, height: false }; + const getScaledDimensions = useCallback( + (scale: number): Size | null => { + if (!imageRef.current) return null; + + const renderedWidth = imageRef.current.clientWidth; + const renderedHeight = imageRef.current.clientHeight; - const imgElement = imageRef.current; - const renderedWidth = imgElement.clientWidth; - const renderedHeight = imgElement.clientHeight; + if (!renderedWidth || !renderedHeight) return null; const effectiveDims = getEffectiveDimensions( renderedWidth, renderedHeight, ); - const scaledWidth = effectiveDims.width * scale; - const scaledHeight = effectiveDims.height * scale; return { - width: scaledWidth > viewportWidth, - height: scaledHeight > viewportHeight, + width: effectiveDims.width * scale, + height: effectiveDims.height * scale, }; }, [getEffectiveDimensions], ); + const getOverflowState = useCallback( + ( + scale: number, + viewportWidth: number, + viewportHeight: number, + ): OverflowState => { + const scaledDimensions = getScaledDimensions(scale); + + if (!scaledDimensions) return { width: false, height: false }; + + return { + width: scaledDimensions.width > viewportWidth, + height: scaledDimensions.height > viewportHeight, + }; + }, + [getScaledDimensions], + ); + const getViewportElement = useCallback( () => transformRef.current?.instance?.wrapperComponent ?? @@ -130,8 +186,8 @@ export const ZoomableImage = forwardRef( renderedW = renderedH * imgAspect; } - const centerX = (wrapperRect.width - renderedW) / 2; - const centerY = (wrapperRect.height - renderedH) / 2; + const centerX = getCenteredAxisPosition(wrapperRect.width, renderedW); + const centerY = getCenteredAxisPosition(wrapperRect.height, renderedH); transformRef.current.setTransform( centerX, @@ -186,24 +242,33 @@ export const ZoomableImage = forwardRef( }, [imagePath, handleReset]); useEffect(() => { - const wrapperElement = wheelAreaRef.current; - if (!wrapperElement) return; + const wheelElement = wheelAreaRef.current; + if (!wheelElement) return; - let cachedWrapperRect = wrapperElement.getBoundingClientRect(); + let cachedViewportRect = + getViewportElement()?.getBoundingClientRect() ?? + wheelElement.getBoundingClientRect(); const resizeObserver = new ResizeObserver(() => { - cachedWrapperRect = wrapperElement.getBoundingClientRect(); + cachedViewportRect = + getViewportElement()?.getBoundingClientRect() ?? + wheelElement.getBoundingClientRect(); }); - resizeObserver.observe(wrapperElement); + resizeObserver.observe(wheelElement); const handleWheelInterceptor = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + if (!imageRef.current || !transformRef.current) return; const transformState = transformRef.current.instance.transformState; + const viewportElement = getViewportElement() ?? wheelElement; - cachedWrapperRect = wrapperElement.getBoundingClientRect(); - const wrapperRect = cachedWrapperRect; + cachedViewportRect = viewportElement.getBoundingClientRect(); + const viewportRect = cachedViewportRect; const imageRect = imageRef.current.getBoundingClientRect(); const mouseX = e.clientX - imageRect.left; const mouseY = e.clientY - imageRect.top; @@ -226,54 +291,65 @@ export const ZoomableImage = forwardRef( Math.min(MAX_SCALE, currentScale + zoomChange), ); - 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 scaledDimensions = getScaledDimensions(newScale); + if (!scaledDimensions) return; const newOverflow = getOverflowState( newScale, - wrapperRect.width, - wrapperRect.height, + viewportRect.width, + viewportRect.height, ); - const centeredX = (wrapperRect.width - nextW) / 2; - const centeredY = (wrapperRect.height - nextH) / 2; + const shouldRecenter = + newScale === MIN_SCALE || (!newOverflow.width && !newOverflow.height); + + const centeredX = getCenteredAxisPosition( + viewportRect.width, + scaledDimensions.width, + ); + const centeredY = getCenteredAxisPosition( + viewportRect.height, + scaledDimensions.height, + ); const ratio = currentScale > 0 ? newScale / currentScale : 1; - const mouseViewportX = e.clientX - wrapperRect.left; - const mouseViewportY = e.clientY - wrapperRect.top; + const mouseViewportX = e.clientX - viewportRect.left; + const mouseViewportY = e.clientY - viewportRect.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(); + const targetX = shouldRecenter + ? centeredX + : getAxisPosition( + isOverImage ? anchoredX : centeredX, + viewportRect.width, + scaledDimensions.width, + newOverflow.width, + ); + const targetY = shouldRecenter + ? centeredY + : getAxisPosition( + isOverImage ? anchoredY : centeredY, + viewportRect.height, + scaledDimensions.height, + newOverflow.height, + ); setIsOverflowing(newOverflow.width || newOverflow.height); transformRef.current.setTransform(targetX, targetY, newScale, 0); }; - wrapperElement.addEventListener('wheel', handleWheelInterceptor, { + wheelElement.addEventListener('wheel', handleWheelInterceptor, { passive: false, capture: true, }); return () => { resizeObserver.disconnect(); - wrapperElement.removeEventListener( - 'wheel', - handleWheelInterceptor, - true, - ); + wheelElement.removeEventListener('wheel', handleWheelInterceptor, true); }; - }, [getEffectiveDimensions, getOverflowState]); + }, [getOverflowState, getScaledDimensions, getViewportElement]); return (
@@ -330,38 +406,19 @@ export const ZoomableImage = forwardRef( 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; - } - } + const finalX = getAxisPosition( + positionX, + viewW, + scaledW, + overflow.width, + ); + const finalY = getAxisPosition( + positionY, + viewH, + scaledH, + overflow.height, + ); + const clamped = positionX !== finalX || positionY !== finalY; if (clamped) { ref.setTransform(finalX, finalY, scale, 0); diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index 887c2728e..b777b8b11 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -10,6 +10,7 @@ const mockTransformState = { positionX: 0, positionY: 0, }; +let mockWrapperComponent: HTMLDivElement | null = null; jest.mock('@tauri-apps/api/core', () => ({ convertFileSrc: (path: string) => path, @@ -22,7 +23,9 @@ jest.mock('react-zoom-pan-pinch', () => { ({ children }: { children: ReactNode }, ref: Ref) => { React.useImperativeHandle(ref, () => ({ instance: { - wrapperComponent: null, + get wrapperComponent() { + return mockWrapperComponent; + }, transformState: mockTransformState, }, setTransform: mockSetTransform, @@ -58,8 +61,8 @@ const mockElementRect = ( ({ left: 0, top: 0, - right: rect.width ?? 0, - bottom: rect.height ?? 0, + right: (rect.left ?? 0) + (rect.width ?? 0), + bottom: (rect.top ?? 0) + (rect.height ?? 0), x: 0, y: 0, width: 0, @@ -84,24 +87,77 @@ const mockElementRect = ( } }; +const renderZoomableImage = () => + render( + , + ); + +const setupWheelScene = ({ + wrapperSize, + imageSize, + imageOffset = { left: 0, top: 0 }, +}: { + wrapperSize: { width: number; height: number }; + imageSize: { width: number; height: number }; + imageOffset?: { left: number; top: number }; +}) => { + const { container } = renderZoomableImage(); + const wheelArea = container.firstElementChild as HTMLElement; + const transformWrapper = screen.getByTestId('transform-wrapper'); + const image = screen.getByAltText('test image'); + + mockWrapperComponent = transformWrapper as HTMLDivElement; + + mockElementRect( + wheelArea, + { ...wrapperSize, left: 0, top: 0 }, + { clientWidth: wrapperSize.width, clientHeight: wrapperSize.height }, + ); + mockElementRect( + transformWrapper, + { ...wrapperSize, left: 0, top: 0 }, + { clientWidth: wrapperSize.width, clientHeight: wrapperSize.height }, + ); + mockElementRect( + image, + { + ...imageSize, + left: imageOffset.left, + top: imageOffset.top, + }, + { clientWidth: imageSize.width, clientHeight: imageSize.height }, + ); + + return { wheelArea, image }; +}; + +const expectLatestTransform = ( + expectedX: number, + expectedY: number, + expectedScale: number, +) => { + const lastCall = mockSetTransform.mock.calls.at(-1); + + expect(lastCall).toBeDefined(); + expect(lastCall?.[0]).toBeCloseTo(expectedX); + expect(lastCall?.[1]).toBeCloseTo(expectedY); + expect(lastCall?.[2]).toBeCloseTo(expectedScale); + expect(lastCall?.[3]).toBe(0); +}; + describe('ZoomableImage wheel behavior', () => { beforeEach(() => { mockSetTransform.mockClear(); mockZoomIn.mockClear(); mockZoomOut.mockClear(); + mockWrapperComponent = null; 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( - , - ); + test('keeps zoom centered while the image still fits in the viewport', () => { + const { container } = renderZoomableImage(); const wheelArea = container.firstElementChild as HTMLElement; const image = screen.getByAltText('test image'); @@ -123,6 +179,92 @@ describe('ZoomableImage wheel behavior', () => { clientY: 550, }); - expect(mockSetTransform).toHaveBeenCalledWith(290, 245, 1.1, 0); + expectLatestTransform(290, 245, 1.1); + }); + + test('anchors horizontally and keeps the vertical axis centered when only width overflows', () => { + const { wheelArea } = setupWheelScene({ + wrapperSize: { width: 800, height: 600 }, + imageSize: { width: 760, height: 300 }, + }); + + fireEvent.wheel(wheelArea, { + deltaY: -100, + clientX: 750, + clientY: 300, + }); + + expectLatestTransform(-36, 135, 1.1); + }); + + test('anchors vertically and keeps the horizontal axis centered when only height overflows', () => { + const { wheelArea } = setupWheelScene({ + wrapperSize: { width: 800, height: 600 }, + imageSize: { width: 300, height: 560 }, + }); + + fireEvent.wheel(wheelArea, { + deltaY: -100, + clientX: 300, + clientY: 550, + }); + + expectLatestTransform(235, -16, 1.1); + }); + + test('anchors both axes when width and height overflow', () => { + mockTransformState.positionX = -50; + mockTransformState.positionY = -40; + + const { wheelArea } = setupWheelScene({ + wrapperSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + + fireEvent.wheel(wheelArea, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); + + expectLatestTransform(-125, -94, 1.1); + }); + + test('recenters the image when zooming back to minimum scale', () => { + mockTransformState.scale = 1.05; + mockTransformState.positionX = -200; + mockTransformState.positionY = -150; + + const { wheelArea } = setupWheelScene({ + wrapperSize: { width: 800, height: 600 }, + imageSize: { width: 400, height: 300 }, + }); + + fireEvent.wheel(wheelArea, { + deltaY: 100, + clientX: 700, + clientY: 500, + }); + + expectLatestTransform(200, 150, 1); + }); + + test('clamps wheel zoom targets so the image cannot be pulled offscreen', () => { + mockTransformState.scale = 2; + mockTransformState.positionX = 2000; + mockTransformState.positionY = 2000; + + const { wheelArea } = setupWheelScene({ + wrapperSize: { width: 800, height: 600 }, + imageSize: { width: 1000, height: 800 }, + }); + + fireEvent.wheel(wheelArea, { + deltaY: -100, + clientX: 790, + clientY: 590, + }); + + expectLatestTransform(0, 0, 2.1); }); }); From cd4a72700ffae5691b954f57d6e0c45da74f84b6 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sun, 31 May 2026 12:02:29 +0530 Subject: [PATCH 03/16] Delay scroll zoom anchoring until image fills viewport --- .../src/components/Media/ZoomableImage.tsx | 96 +++++++++++++++---- .../Media/__tests__/ZoomableImage.test.tsx | 31 +++++- 2 files changed, 106 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index 60bd60496..83aa0cee1 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -17,6 +17,7 @@ const ZOOM_FACTOR = 0.001; const LINE_HEIGHT_MULTIPLIER = 33; const MAX_SCALE = 8; const MIN_SCALE = 1; +const SCALE_EPSILON = 0.0001; type Size = { width: number; @@ -87,6 +88,23 @@ const getAxisPosition = ( : centeredPosition; }; +const axisTouchesViewport = (scaledSize: number, viewportSize: number) => + scaledSize >= viewportSize - SCALE_EPSILON; + +const getFirstViewportEdgeScale = ( + baseDimensions: Size, + viewportWidth: number, + viewportHeight: number, +) => + Math.max( + MIN_SCALE, + Math.min( + MAX_SCALE, + viewportWidth / baseDimensions.width, + viewportHeight / baseDimensions.height, + ), + ); + export const ZoomableImage = forwardRef( ({ imagePath, alt, rotation, resetSignal }, ref) => { const transformRef = useRef(null); @@ -111,26 +129,29 @@ export const ZoomableImage = forwardRef( [], ); - const getScaledDimensions = useCallback( - (scale: number): Size | null => { - if (!imageRef.current) return null; + const getBaseDimensions = useCallback((): Size | null => { + if (!imageRef.current) return null; - const renderedWidth = imageRef.current.clientWidth; - const renderedHeight = imageRef.current.clientHeight; + const renderedWidth = imageRef.current.clientWidth; + const renderedHeight = imageRef.current.clientHeight; - if (!renderedWidth || !renderedHeight) return null; + if (!renderedWidth || !renderedHeight) return null; - const effectiveDims = getEffectiveDimensions( - renderedWidth, - renderedHeight, - ); + return getEffectiveDimensions(renderedWidth, renderedHeight); + }, [getEffectiveDimensions]); + + const getScaledDimensions = useCallback( + (scale: number): Size | null => { + const baseDimensions = getBaseDimensions(); + + if (!baseDimensions) return null; return { - width: effectiveDims.width * scale, - height: effectiveDims.height * scale, + width: baseDimensions.width * scale, + height: baseDimensions.height * scale, }; }, - [getEffectiveDimensions], + [getBaseDimensions], ); const getOverflowState = useCallback( @@ -286,10 +307,42 @@ export const ZoomableImage = forwardRef( const zoomChange = -e.deltaY * multiplier * factor; const currentScale = transformState.scale; - const newScale = Math.max( + const desiredScale = Math.max( MIN_SCALE, Math.min(MAX_SCALE, currentScale + zoomChange), ); + const baseDimensions = getBaseDimensions(); + if (!baseDimensions) return; + + const currentDimensions = { + width: baseDimensions.width * currentScale, + height: baseDimensions.height * currentScale, + }; + const currentTouchesViewport = { + width: axisTouchesViewport( + currentDimensions.width, + viewportRect.width, + ), + height: axisTouchesViewport( + currentDimensions.height, + viewportRect.height, + ), + }; + const isZoomingIn = desiredScale > currentScale; + + const shouldFitFirst = + isZoomingIn && + !currentTouchesViewport.width && + !currentTouchesViewport.height; + const firstViewportEdgeScale = getFirstViewportEdgeScale( + baseDimensions, + viewportRect.width, + viewportRect.height, + ); + const newScale = + shouldFitFirst && desiredScale > firstViewportEdgeScale + ? firstViewportEdgeScale + : desiredScale; const scaledDimensions = getScaledDimensions(newScale); if (!scaledDimensions) return; @@ -318,11 +371,15 @@ export const ZoomableImage = forwardRef( mouseViewportX - (mouseViewportX - transformState.positionX) * ratio; const anchoredY = mouseViewportY - (mouseViewportY - transformState.positionY) * ratio; + const shouldAnchorX = + isOverImage && currentTouchesViewport.width && newOverflow.width; + const shouldAnchorY = + isOverImage && currentTouchesViewport.height && newOverflow.height; const targetX = shouldRecenter ? centeredX : getAxisPosition( - isOverImage ? anchoredX : centeredX, + shouldAnchorX ? anchoredX : centeredX, viewportRect.width, scaledDimensions.width, newOverflow.width, @@ -330,7 +387,7 @@ export const ZoomableImage = forwardRef( const targetY = shouldRecenter ? centeredY : getAxisPosition( - isOverImage ? anchoredY : centeredY, + shouldAnchorY ? anchoredY : centeredY, viewportRect.height, scaledDimensions.height, newOverflow.height, @@ -349,7 +406,12 @@ export const ZoomableImage = forwardRef( resizeObserver.disconnect(); wheelElement.removeEventListener('wheel', handleWheelInterceptor, true); }; - }, [getOverflowState, getScaledDimensions, getViewportElement]); + }, [ + getBaseDimensions, + getOverflowState, + getScaledDimensions, + getViewportElement, + ]); return (
diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index b777b8b11..e29044d13 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -182,7 +182,7 @@ describe('ZoomableImage wheel behavior', () => { expectLatestTransform(290, 245, 1.1); }); - test('anchors horizontally and keeps the vertical axis centered when only width overflows', () => { + test('centers and stops at the viewport edge before mouse anchoring begins', () => { const { wheelArea } = setupWheelScene({ wrapperSize: { width: 800, height: 600 }, imageSize: { width: 760, height: 300 }, @@ -194,10 +194,33 @@ describe('ZoomableImage wheel behavior', () => { clientY: 300, }); - expectLatestTransform(-36, 135, 1.1); + expectLatestTransform(0, 142.10526315789474, 1.0526315789473684); }); - test('anchors vertically and keeps the horizontal axis centered when only height overflows', () => { + test('anchors horizontally after the width has reached the viewport edge', () => { + mockTransformState.scale = 1.1; + mockTransformState.positionX = -18; + mockTransformState.positionY = 135; + + const { wheelArea } = setupWheelScene({ + wrapperSize: { width: 800, height: 600 }, + imageSize: { width: 760, height: 300 }, + }); + + fireEvent.wheel(wheelArea, { + deltaY: -100, + clientX: 750, + clientY: 300, + }); + + expectLatestTransform(-87.81818181818176, 120, 1.2000000000000002); + }); + + test('anchors vertically after the height has reached the viewport edge', () => { + mockTransformState.scale = 1.1; + mockTransformState.positionX = 235; + mockTransformState.positionY = -8; + const { wheelArea } = setupWheelScene({ wrapperSize: { width: 800, height: 600 }, imageSize: { width: 300, height: 560 }, @@ -209,7 +232,7 @@ describe('ZoomableImage wheel behavior', () => { clientY: 550, }); - expectLatestTransform(235, -16, 1.1); + expectLatestTransform(220, -58.72727272727275, 1.2000000000000002); }); test('anchors both axes when width and height overflow', () => { From cda6bcceba96f36c5ade5fde14674da82cb8cf22 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sun, 31 May 2026 12:27:52 +0530 Subject: [PATCH 04/16] Derive media zoom minimum from viewer size --- .../src/components/Media/ZoomableImage.tsx | 146 ++++++++++++------ .../Media/__tests__/ZoomableImage.test.tsx | 19 +++ 2 files changed, 120 insertions(+), 45 deletions(-) diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index 83aa0cee1..afea34963 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -91,17 +91,40 @@ const getAxisPosition = ( const axisTouchesViewport = (scaledSize: number, viewportSize: number) => scaledSize >= viewportSize - SCALE_EPSILON; +const getMinimumScale = ( + baseDimensions: Size, + viewportWidth: number, + viewportHeight: number, +) => { + if ( + !baseDimensions.width || + !baseDimensions.height || + !viewportWidth || + !viewportHeight + ) { + return MIN_SCALE; + } + + return Math.min( + MIN_SCALE, + viewportWidth / baseDimensions.width, + viewportHeight / baseDimensions.height, + ); +}; + const getFirstViewportEdgeScale = ( baseDimensions: Size, viewportWidth: number, viewportHeight: number, ) => - Math.max( - MIN_SCALE, - Math.min( - MAX_SCALE, - viewportWidth / baseDimensions.width, - viewportHeight / baseDimensions.height, + Math.min( + MAX_SCALE, + Math.max( + getMinimumScale(baseDimensions, viewportWidth, viewportHeight), + Math.min( + viewportWidth / baseDimensions.width, + viewportHeight / baseDimensions.height, + ), ), ); @@ -111,8 +134,15 @@ export const ZoomableImage = forwardRef( const wheelAreaRef = useRef(null); const imageRef = useRef(null); const [isOverflowing, setIsOverflowing] = useState(false); + const [minScale, setMinScale] = useState(MIN_SCALE); const rotationRef = useRef(rotation); + const setMinimumScale = useCallback((scale: number) => { + setMinScale((currentScale) => + Math.abs(currentScale - scale) < SCALE_EPSILON ? currentScale : scale, + ); + }, []); + useEffect(() => { rotationRef.current = rotation; }, [rotation]); @@ -132,8 +162,10 @@ export const ZoomableImage = forwardRef( const getBaseDimensions = useCallback((): Size | null => { if (!imageRef.current) return null; - const renderedWidth = imageRef.current.clientWidth; - const renderedHeight = imageRef.current.clientHeight; + const renderedWidth = + imageRef.current.naturalWidth || imageRef.current.clientWidth; + const renderedHeight = + imageRef.current.naturalHeight || imageRef.current.clientHeight; if (!renderedWidth || !renderedHeight) return null; @@ -186,30 +218,21 @@ export const ZoomableImage = forwardRef( return; const wrapperRect = viewportElement.getBoundingClientRect(); - const img = imageRef.current; - - const scale = 1; - const baseW = img.naturalWidth || img.clientWidth; - const baseH = img.naturalHeight || img.clientHeight; - - if (!baseW || !baseH) return; - - const effectiveDims = getEffectiveDimensions(baseW, baseH); - const imgAspect = effectiveDims.width / effectiveDims.height; - const viewAspect = wrapperRect.width / wrapperRect.height; + const baseDimensions = getBaseDimensions(); - let renderedW, renderedH; - if (imgAspect > viewAspect) { - renderedW = Math.min(effectiveDims.width, wrapperRect.width); - renderedH = renderedW / imgAspect; - } else { - renderedH = Math.min(effectiveDims.height, wrapperRect.height); - renderedW = renderedH * imgAspect; - } + if (!baseDimensions) return; + const scale = getMinimumScale( + baseDimensions, + wrapperRect.width, + wrapperRect.height, + ); + const renderedW = baseDimensions.width * scale; + const renderedH = baseDimensions.height * scale; const centerX = getCenteredAxisPosition(wrapperRect.width, renderedW); const centerY = getCenteredAxisPosition(wrapperRect.height, renderedH); + setMinimumScale(scale); transformRef.current.setTransform( centerX, centerY, @@ -219,7 +242,7 @@ export const ZoomableImage = forwardRef( ); setIsOverflowing(false); }, - [getEffectiveDimensions, getViewportElement], + [getBaseDimensions, getViewportElement, setMinimumScale], ); useImperativeHandle(ref, () => ({ @@ -239,10 +262,23 @@ export const ZoomableImage = forwardRef( const scale = transformRef.current.instance.transformState.scale; const rect = viewportElement.getBoundingClientRect(); + const baseDimensions = getBaseDimensions(); + + if (baseDimensions) { + setMinimumScale( + getMinimumScale(baseDimensions, rect.width, rect.height), + ); + } const overflow = getOverflowState(scale, rect.width, rect.height); setIsOverflowing(overflow.width || overflow.height); - }, [rotation, getOverflowState, getViewportElement]); + }, [ + rotation, + getBaseDimensions, + getOverflowState, + getViewportElement, + setMinimumScale, + ]); useEffect(() => { setIsOverflowing(false); @@ -271,9 +307,20 @@ export const ZoomableImage = forwardRef( wheelElement.getBoundingClientRect(); const resizeObserver = new ResizeObserver(() => { - cachedViewportRect = - getViewportElement()?.getBoundingClientRect() ?? - wheelElement.getBoundingClientRect(); + const viewportElement = getViewportElement() ?? wheelElement; + + cachedViewportRect = viewportElement.getBoundingClientRect(); + + const baseDimensions = getBaseDimensions(); + if (baseDimensions) { + setMinimumScale( + getMinimumScale( + baseDimensions, + cachedViewportRect.width, + cachedViewportRect.height, + ), + ); + } }); resizeObserver.observe(wheelElement); @@ -306,13 +353,20 @@ export const ZoomableImage = forwardRef( const zoomChange = -e.deltaY * multiplier * factor; + const baseDimensions = getBaseDimensions(); + if (!baseDimensions) return; + + const minimumScale = getMinimumScale( + baseDimensions, + viewportRect.width, + viewportRect.height, + ); const currentScale = transformState.scale; const desiredScale = Math.max( - MIN_SCALE, + minimumScale, Math.min(MAX_SCALE, currentScale + zoomChange), ); - const baseDimensions = getBaseDimensions(); - if (!baseDimensions) return; + setMinimumScale(minimumScale); const currentDimensions = { width: baseDimensions.width * currentScale, @@ -354,7 +408,8 @@ export const ZoomableImage = forwardRef( ); const shouldRecenter = - newScale === MIN_SCALE || (!newOverflow.width && !newOverflow.height); + newScale <= minimumScale + SCALE_EPSILON || + (!newOverflow.width && !newOverflow.height); const centeredX = getCenteredAxisPosition( viewportRect.width, @@ -411,14 +466,15 @@ export const ZoomableImage = forwardRef( getOverflowState, getScaledDimensions, getViewportElement, + setMinimumScale, ]); return (
( const positionY = ref.state.positionY; const viewW = wrapper.clientWidth; const viewH = wrapper.clientHeight; - const imgW = imageRef.current.clientWidth; - const imgH = imageRef.current.clientHeight; + const baseDimensions = getBaseDimensions(); + + if (!baseDimensions) return; - const effectiveDims = getEffectiveDimensions(imgW, imgH); - const scaledW = effectiveDims.width * scale; - const scaledH = effectiveDims.height * scale; + const scaledW = baseDimensions.width * scale; + const scaledH = baseDimensions.height * scale; const finalX = getAxisPosition( positionX, @@ -512,8 +568,8 @@ export const ZoomableImage = forwardRef( img.src = '/placeholder.svg'; }} style={{ - maxWidth: '100vw', - maxHeight: '100vh', + maxWidth: 'none', + maxHeight: 'none', objectFit: 'contain', zIndex: 50, transform: `rotate(${rotation}deg)`, diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index e29044d13..d882ef3bc 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -272,6 +272,25 @@ describe('ZoomableImage wheel behavior', () => { expectLatestTransform(200, 150, 1); }); + test('uses a fit-to-view minimum scale when the image is larger than the measured viewer', () => { + mockTransformState.scale = 0.55; + mockTransformState.positionX = -80; + mockTransformState.positionY = -60; + + const { wheelArea } = setupWheelScene({ + wrapperSize: { width: 500, height: 400 }, + imageSize: { width: 1000, height: 800 }, + }); + + fireEvent.wheel(wheelArea, { + deltaY: 100, + clientX: 450, + clientY: 350, + }); + + expectLatestTransform(0, 0, 0.5); + }); + test('clamps wheel zoom targets so the image cannot be pulled offscreen', () => { mockTransformState.scale = 2; mockTransformState.positionX = 2000; From 43412d43322ed49b85d81c0a9ce4fb5e2461f718 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sun, 31 May 2026 12:41:58 +0530 Subject: [PATCH 05/16] Document zoom wrapper fallback test --- frontend/src/components/Media/__tests__/ZoomableImage.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index d882ef3bc..10d8b207e 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -150,6 +150,8 @@ describe('ZoomableImage wheel behavior', () => { mockSetTransform.mockClear(); mockZoomIn.mockClear(); mockZoomOut.mockClear(); + // Keep the internal wrapper unavailable by default so tests cover the + // production fallback path where the wheel area owns scroll handling. mockWrapperComponent = null; mockTransformState.scale = 1; mockTransformState.positionX = 0; From 9200adbad92e3d3e888cd5925bad72c4310bb1bd Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sun, 31 May 2026 15:01:15 +0530 Subject: [PATCH 06/16] Fix production zoom fit initialization --- .../src/components/Media/ZoomableImage.tsx | 170 +++++++++++++++--- .../Media/__tests__/ZoomableImage.test.tsx | 19 ++ 2 files changed, 162 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index afea34963..751dd02eb 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -29,6 +29,12 @@ type OverflowState = { height: boolean; }; +type FitTransform = { + positionX: number; + positionY: number; + scale: number; +}; + type AnimationType = | 'easeOut' | 'linear' @@ -128,11 +134,33 @@ const getFirstViewportEdgeScale = ( ), ); +const getFitTransform = ( + baseDimensions: Size, + viewportWidth: number, + viewportHeight: number, +): FitTransform | null => { + if (!viewportWidth || !viewportHeight) return null; + + const scale = getMinimumScale(baseDimensions, viewportWidth, viewportHeight); + const scaledWidth = baseDimensions.width * scale; + const scaledHeight = baseDimensions.height * scale; + + return { + positionX: getCenteredAxisPosition(viewportWidth, scaledWidth), + positionY: getCenteredAxisPosition(viewportHeight, scaledHeight), + scale, + }; +}; + export const ZoomableImage = forwardRef( ({ imagePath, alt, rotation, resetSignal }, ref) => { const transformRef = useRef(null); const wheelAreaRef = useRef(null); const imageRef = useRef(null); + const isFitInitializedRef = useRef(false); + const hasUserInteractedRef = useRef(false); + const fitTransformRef = useRef(null); + const fitFrameRef = useRef(null); const [isOverflowing, setIsOverflowing] = useState(false); const [minScale, setMinScale] = useState(MIN_SCALE); const rotationRef = useRef(rotation); @@ -143,10 +171,6 @@ export const ZoomableImage = forwardRef( ); }, []); - useEffect(() => { - rotationRef.current = rotation; - }, [rotation]); - const getEffectiveDimensions = useCallback( (width: number, height: number) => { const normalizedRotation = ((rotationRef.current % 360) + 360) % 360; @@ -211,7 +235,18 @@ export const ZoomableImage = forwardRef( [], ); - const handleReset = useCallback( + const clearScheduledFit = useCallback(() => { + if (fitFrameRef.current === null) return; + + if (typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(fitFrameRef.current); + } else { + window.clearTimeout(fitFrameRef.current); + } + fitFrameRef.current = null; + }, []); + + const applyFitTransform = useCallback( (duration = 200, animationType: AnimationType = 'easeOut') => { const viewportElement = getViewportElement(); if (!transformRef.current || !viewportElement || !imageRef.current) @@ -222,21 +257,22 @@ export const ZoomableImage = forwardRef( if (!baseDimensions) return; - const scale = getMinimumScale( + const fitTransform = getFitTransform( baseDimensions, wrapperRect.width, wrapperRect.height, ); - const renderedW = baseDimensions.width * scale; - const renderedH = baseDimensions.height * scale; - const centerX = getCenteredAxisPosition(wrapperRect.width, renderedW); - const centerY = getCenteredAxisPosition(wrapperRect.height, renderedH); - setMinimumScale(scale); + if (!fitTransform) return; + + fitTransformRef.current = fitTransform; + isFitInitializedRef.current = true; + hasUserInteractedRef.current = false; + setMinimumScale(fitTransform.scale); transformRef.current.setTransform( - centerX, - centerY, - scale, + fitTransform.positionX, + fitTransform.positionY, + fitTransform.scale, duration, animationType, ); @@ -245,9 +281,42 @@ export const ZoomableImage = forwardRef( [getBaseDimensions, getViewportElement, setMinimumScale], ); + const scheduleFitTransform = useCallback( + (duration = 200, animationType: AnimationType = 'easeOut') => { + clearScheduledFit(); + + const scheduleFrame: (callback: FrameRequestCallback) => number = + typeof window.requestAnimationFrame === 'function' + ? window.requestAnimationFrame.bind(window) + : (callback) => + window.setTimeout(() => callback(performance.now()), 0); + + fitFrameRef.current = scheduleFrame(() => { + fitFrameRef.current = null; + applyFitTransform(duration, animationType); + }); + }, + [applyFitTransform, clearScheduledFit], + ); + + const handleReset = useCallback( + (duration = 200, animationType: AnimationType = 'easeOut') => { + isFitInitializedRef.current = false; + hasUserInteractedRef.current = false; + scheduleFitTransform(duration, animationType); + }, + [scheduleFitTransform], + ); + useImperativeHandle(ref, () => ({ - zoomIn: () => transformRef.current?.zoomIn(), - zoomOut: () => transformRef.current?.zoomOut(), + zoomIn: () => { + hasUserInteractedRef.current = true; + transformRef.current?.zoomIn(); + }, + zoomOut: () => { + hasUserInteractedRef.current = true; + transformRef.current?.zoomOut(); + }, reset: () => handleReset(), })); @@ -255,6 +324,13 @@ export const ZoomableImage = forwardRef( handleReset(); }, [resetSignal, handleReset]); + useEffect(() => { + rotationRef.current = rotation; + isFitInitializedRef.current = false; + hasUserInteractedRef.current = false; + scheduleFitTransform(0); + }, [rotation, scheduleFitTransform]); + useEffect(() => { const viewportElement = getViewportElement(); if (!transformRef.current || !viewportElement || !imageRef.current) @@ -282,12 +358,15 @@ export const ZoomableImage = forwardRef( useEffect(() => { setIsOverflowing(false); + isFitInitializedRef.current = false; + hasUserInteractedRef.current = false; + fitTransformRef.current = null; const img = imageRef.current; if (!img) return; const handleImageLoad = () => { - handleReset(0); + scheduleFitTransform(0); }; if (img.complete && img.naturalWidth > 0) { @@ -296,7 +375,7 @@ export const ZoomableImage = forwardRef( img.addEventListener('load', handleImageLoad); return () => img.removeEventListener('load', handleImageLoad); } - }, [imagePath, handleReset]); + }, [imagePath, scheduleFitTransform]); useEffect(() => { const wheelElement = wheelAreaRef.current; @@ -313,13 +392,20 @@ export const ZoomableImage = forwardRef( const baseDimensions = getBaseDimensions(); if (baseDimensions) { - setMinimumScale( - getMinimumScale( - baseDimensions, - cachedViewportRect.width, - cachedViewportRect.height, - ), + const fitTransform = getFitTransform( + baseDimensions, + cachedViewportRect.width, + cachedViewportRect.height, ); + + if (fitTransform) { + fitTransformRef.current = fitTransform; + setMinimumScale(fitTransform.scale); + + if (!hasUserInteractedRef.current) { + scheduleFitTransform(0); + } + } } }); @@ -329,6 +415,7 @@ export const ZoomableImage = forwardRef( e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); + clearScheduledFit(); if (!imageRef.current || !transformRef.current) return; @@ -356,12 +443,35 @@ export const ZoomableImage = forwardRef( const baseDimensions = getBaseDimensions(); if (!baseDimensions) return; + const fitTransform = getFitTransform( + baseDimensions, + viewportRect.width, + viewportRect.height, + ); + if (!fitTransform) return; + const minimumScale = getMinimumScale( baseDimensions, viewportRect.width, viewportRect.height, ); - const currentScale = transformState.scale; + fitTransformRef.current = fitTransform; + + const shouldUseFitTransform = + !isFitInitializedRef.current && + fitTransform.scale < MIN_SCALE - SCALE_EPSILON && + transformState.scale > fitTransform.scale + SCALE_EPSILON && + Math.abs(transformState.positionX) < SCALE_EPSILON && + Math.abs(transformState.positionY) < SCALE_EPSILON; + const currentScale = shouldUseFitTransform + ? fitTransform.scale + : transformState.scale; + const currentPositionX = shouldUseFitTransform + ? fitTransform.positionX + : transformState.positionX; + const currentPositionY = shouldUseFitTransform + ? fitTransform.positionY + : transformState.positionY; const desiredScale = Math.max( minimumScale, Math.min(MAX_SCALE, currentScale + zoomChange), @@ -423,9 +533,9 @@ export const ZoomableImage = forwardRef( const mouseViewportX = e.clientX - viewportRect.left; const mouseViewportY = e.clientY - viewportRect.top; const anchoredX = - mouseViewportX - (mouseViewportX - transformState.positionX) * ratio; + mouseViewportX - (mouseViewportX - currentPositionX) * ratio; const anchoredY = - mouseViewportY - (mouseViewportY - transformState.positionY) * ratio; + mouseViewportY - (mouseViewportY - currentPositionY) * ratio; const shouldAnchorX = isOverImage && currentTouchesViewport.width && newOverflow.width; const shouldAnchorY = @@ -449,6 +559,8 @@ export const ZoomableImage = forwardRef( ); setIsOverflowing(newOverflow.width || newOverflow.height); + isFitInitializedRef.current = true; + hasUserInteractedRef.current = true; transformRef.current.setTransform(targetX, targetY, newScale, 0); }; @@ -458,14 +570,17 @@ export const ZoomableImage = forwardRef( }); return () => { + clearScheduledFit(); resizeObserver.disconnect(); wheelElement.removeEventListener('wheel', handleWheelInterceptor, true); }; }, [ + clearScheduledFit, getBaseDimensions, getOverflowState, getScaledDimensions, getViewportElement, + scheduleFitTransform, setMinimumScale, ]); @@ -505,6 +620,7 @@ export const ZoomableImage = forwardRef( setIsOverflowing(overflow.width || overflow.height); }} onPanning={(ref) => { + hasUserInteractedRef.current = true; const scale = ref.state.scale; const wrapper = getViewportElement(); if (!wrapper || !imageRef.current) return; diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index 10d8b207e..1484d170b 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -293,6 +293,25 @@ describe('ZoomableImage wheel behavior', () => { expectLatestTransform(0, 0, 0.5); }); + test('starts a production first wheel from fit-to-view when transform state is stale', () => { + mockTransformState.scale = 1; + mockTransformState.positionX = 0; + mockTransformState.positionY = 0; + + const { wheelArea } = setupWheelScene({ + wrapperSize: { width: 500, height: 400 }, + imageSize: { width: 1000, height: 800 }, + }); + + fireEvent.wheel(wheelArea, { + deltaY: -100, + clientX: 450, + clientY: 350, + }); + + expectLatestTransform(-90, -70, 0.6); + }); + test('clamps wheel zoom targets so the image cannot be pulled offscreen', () => { mockTransformState.scale = 2; mockTransformState.positionX = 2000; From df7f302163297abca45b7de8605d439deea48c7e Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sun, 31 May 2026 20:42:18 +0530 Subject: [PATCH 07/16] Reset zoom state when changing media --- frontend/src/components/Media/MediaView.tsx | 28 ++-- .../src/components/Media/ZoomableImage.tsx | 49 +++--- .../Media/__tests__/ZoomableImage.test.tsx | 148 +++++++++++++++--- 3 files changed, 166 insertions(+), 59 deletions(-) diff --git a/frontend/src/components/Media/MediaView.tsx b/frontend/src/components/Media/MediaView.tsx index 7530c8bf4..4a0a79cf5 100644 --- a/frontend/src/components/Media/MediaView.tsx +++ b/frontend/src/components/Media/MediaView.tsx @@ -51,20 +51,25 @@ export function MediaView({ // Custom hooks const { viewState, handlers } = useImageViewControls(); + const resetViewerState = useCallback(() => { + handlers.resetZoom(); + setResetSignal((s) => s + 1); + }, [handlers]); + // Navigation handlers const handleNextImage = useCallback(() => { if (currentViewIndex < images.length - 1) { dispatch(setCurrentViewIndex(currentViewIndex + 1)); - handlers.resetZoom(); + resetViewerState(); } - }, [dispatch, handlers, currentViewIndex, images.length]); + }, [dispatch, resetViewerState, currentViewIndex, images.length]); const handlePreviousImage = useCallback(() => { if (currentViewIndex > 0) { dispatch(setCurrentViewIndex(currentViewIndex - 1)); - handlers.resetZoom(); + resetViewerState(); } - }, [dispatch, handlers, currentViewIndex]); + }, [dispatch, resetViewerState, currentViewIndex]); const handleClose = useCallback(() => { dispatch(closeImageView()); @@ -74,9 +79,9 @@ export function MediaView({ const handleThumbnailClick = useCallback( (index: number) => { dispatch(setCurrentViewIndex(index)); - handlers.resetZoom(); + resetViewerState(); }, - [dispatch, handlers], + [dispatch, resetViewerState], ); const location = useLocation(); @@ -85,8 +90,8 @@ export function MediaView({ // Loop to first image handler for slideshow const handleLoopToStart = useCallback(() => { dispatch(setCurrentViewIndex(0)); - handlers.resetZoom(); - }, [dispatch, handlers]); + resetViewerState(); + }, [dispatch, resetViewerState]); // Slideshow functionality const { isSlideshowActive, toggleSlideshow } = useSlideshow( @@ -142,9 +147,8 @@ export function MediaView({ const handleResetZoom = useCallback(() => { imageViewerRef.current?.reset(); - handlers.resetZoom(); - setResetSignal((s) => s + 1); - }, [handlers]); + resetViewerState(); + }, [resetViewerState]); // Keyboard navigation useKeyboardNavigation({ @@ -164,6 +168,7 @@ export function MediaView({ // Safe variables const currentImagePath = currentImage.path; + const currentImageKey = currentImage.id || currentImage.path; // console.log(currentImage); const currentImageAlt = `image-${currentViewIndex}`; return ( @@ -190,6 +195,7 @@ export function MediaView({ > {type === 'image' && ( ( const imageRef = useRef(null); const isFitInitializedRef = useRef(false); const hasUserInteractedRef = useRef(false); + const hasPendingFitRef = useRef(true); const fitTransformRef = useRef(null); const fitFrameRef = useRef(null); const [isOverflowing, setIsOverflowing] = useState(false); @@ -246,6 +247,13 @@ export const ZoomableImage = forwardRef( fitFrameRef.current = null; }, []); + const markFitPending = useCallback(() => { + isFitInitializedRef.current = false; + hasUserInteractedRef.current = false; + hasPendingFitRef.current = true; + fitTransformRef.current = null; + }, []); + const applyFitTransform = useCallback( (duration = 200, animationType: AnimationType = 'easeOut') => { const viewportElement = getViewportElement(); @@ -268,6 +276,7 @@ export const ZoomableImage = forwardRef( fitTransformRef.current = fitTransform; isFitInitializedRef.current = true; hasUserInteractedRef.current = false; + hasPendingFitRef.current = false; setMinimumScale(fitTransform.scale); transformRef.current.setTransform( fitTransform.positionX, @@ -301,11 +310,10 @@ export const ZoomableImage = forwardRef( const handleReset = useCallback( (duration = 200, animationType: AnimationType = 'easeOut') => { - isFitInitializedRef.current = false; - hasUserInteractedRef.current = false; + markFitPending(); scheduleFitTransform(duration, animationType); }, - [scheduleFitTransform], + [markFitPending, scheduleFitTransform], ); useImperativeHandle(ref, () => ({ @@ -326,10 +334,9 @@ export const ZoomableImage = forwardRef( useEffect(() => { rotationRef.current = rotation; - isFitInitializedRef.current = false; - hasUserInteractedRef.current = false; + markFitPending(); scheduleFitTransform(0); - }, [rotation, scheduleFitTransform]); + }, [rotation, markFitPending, scheduleFitTransform]); useEffect(() => { const viewportElement = getViewportElement(); @@ -358,24 +365,20 @@ export const ZoomableImage = forwardRef( useEffect(() => { setIsOverflowing(false); - isFitInitializedRef.current = false; - hasUserInteractedRef.current = false; - fitTransformRef.current = null; + markFitPending(); const img = imageRef.current; if (!img) return; - const handleImageLoad = () => { - scheduleFitTransform(0); - }; - if (img.complete && img.naturalWidth > 0) { - handleImageLoad(); - } else { - img.addEventListener('load', handleImageLoad); - return () => img.removeEventListener('load', handleImageLoad); + scheduleFitTransform(0); } - }, [imagePath, scheduleFitTransform]); + }, [imagePath, markFitPending, scheduleFitTransform]); + + const handleImageLoad = useCallback(() => { + markFitPending(); + scheduleFitTransform(0); + }, [markFitPending, scheduleFitTransform]); useEffect(() => { const wheelElement = wheelAreaRef.current; @@ -458,11 +461,7 @@ export const ZoomableImage = forwardRef( fitTransformRef.current = fitTransform; const shouldUseFitTransform = - !isFitInitializedRef.current && - fitTransform.scale < MIN_SCALE - SCALE_EPSILON && - transformState.scale > fitTransform.scale + SCALE_EPSILON && - Math.abs(transformState.positionX) < SCALE_EPSILON && - Math.abs(transformState.positionY) < SCALE_EPSILON; + hasPendingFitRef.current || !isFitInitializedRef.current; const currentScale = shouldUseFitTransform ? fitTransform.scale : transformState.scale; @@ -560,6 +559,7 @@ export const ZoomableImage = forwardRef( setIsOverflowing(newOverflow.width || newOverflow.height); isFitInitializedRef.current = true; + hasPendingFitRef.current = false; hasUserInteractedRef.current = true; transformRef.current.setTransform(targetX, targetY, newScale, 0); }; @@ -587,6 +587,7 @@ export const ZoomableImage = forwardRef( return (
( }} > {alt} { const img = e.target as HTMLImageElement; img.onerror = null; diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index 1484d170b..78638c85c 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -2,7 +2,6 @@ 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 = { @@ -10,6 +9,13 @@ const mockTransformState = { positionX: 0, positionY: 0, }; +const mockSetTransform = jest.fn( + (positionX: number, positionY: number, scale: number, _duration?: number) => { + mockTransformState.positionX = positionX; + mockTransformState.positionY = positionY; + mockTransformState.scale = scale; + }, +); let mockWrapperComponent: HTMLDivElement | null = null; jest.mock('@tauri-apps/api/core', () => ({ @@ -87,9 +93,21 @@ const mockElementRect = ( } }; -const renderZoomableImage = () => +const renderZoomableImage = ( + props: Partial<{ + imagePath: string; + alt: string; + rotation: number; + resetSignal: number; + }> = {}, +) => render( - , + , ); const setupWheelScene = ({ @@ -131,6 +149,15 @@ const setupWheelScene = ({ return { wheelArea, image }; }; +const markSceneInitialized = (wheelArea: HTMLElement) => { + fireEvent.wheel(wheelArea, { + deltaY: 0, + clientX: 0, + clientY: 0, + }); + mockSetTransform.mockClear(); +}; + const expectLatestTransform = ( expectedX: number, expectedY: number, @@ -200,15 +227,16 @@ describe('ZoomableImage wheel behavior', () => { }); test('anchors horizontally after the width has reached the viewport edge', () => { - mockTransformState.scale = 1.1; - mockTransformState.positionX = -18; - mockTransformState.positionY = 135; - const { wheelArea } = setupWheelScene({ wrapperSize: { width: 800, height: 600 }, imageSize: { width: 760, height: 300 }, }); + markSceneInitialized(wheelArea); + mockTransformState.scale = 1.1; + mockTransformState.positionX = -18; + mockTransformState.positionY = 135; + fireEvent.wheel(wheelArea, { deltaY: -100, clientX: 750, @@ -219,15 +247,16 @@ describe('ZoomableImage wheel behavior', () => { }); test('anchors vertically after the height has reached the viewport edge', () => { - mockTransformState.scale = 1.1; - mockTransformState.positionX = 235; - mockTransformState.positionY = -8; - const { wheelArea } = setupWheelScene({ wrapperSize: { width: 800, height: 600 }, imageSize: { width: 300, height: 560 }, }); + markSceneInitialized(wheelArea); + mockTransformState.scale = 1.1; + mockTransformState.positionX = 235; + mockTransformState.positionY = -8; + fireEvent.wheel(wheelArea, { deltaY: -100, clientX: 300, @@ -238,14 +267,16 @@ describe('ZoomableImage wheel behavior', () => { }); test('anchors both axes when width and height overflow', () => { - mockTransformState.positionX = -50; - mockTransformState.positionY = -40; - const { wheelArea } = setupWheelScene({ wrapperSize: { width: 800, height: 600 }, imageSize: { width: 900, height: 700 }, }); + markSceneInitialized(wheelArea); + mockTransformState.scale = 1; + mockTransformState.positionX = -50; + mockTransformState.positionY = -40; + fireEvent.wheel(wheelArea, { deltaY: -100, clientX: 700, @@ -256,15 +287,16 @@ describe('ZoomableImage wheel behavior', () => { }); test('recenters the image when zooming back to minimum scale', () => { - mockTransformState.scale = 1.05; - mockTransformState.positionX = -200; - mockTransformState.positionY = -150; - const { wheelArea } = setupWheelScene({ wrapperSize: { width: 800, height: 600 }, imageSize: { width: 400, height: 300 }, }); + markSceneInitialized(wheelArea); + mockTransformState.scale = 1.05; + mockTransformState.positionX = -200; + mockTransformState.positionY = -150; + fireEvent.wheel(wheelArea, { deltaY: 100, clientX: 700, @@ -275,15 +307,16 @@ describe('ZoomableImage wheel behavior', () => { }); test('uses a fit-to-view minimum scale when the image is larger than the measured viewer', () => { - mockTransformState.scale = 0.55; - mockTransformState.positionX = -80; - mockTransformState.positionY = -60; - const { wheelArea } = setupWheelScene({ wrapperSize: { width: 500, height: 400 }, imageSize: { width: 1000, height: 800 }, }); + markSceneInitialized(wheelArea); + mockTransformState.scale = 0.55; + mockTransformState.positionX = -80; + mockTransformState.positionY = -60; + fireEvent.wheel(wheelArea, { deltaY: 100, clientX: 450, @@ -312,16 +345,81 @@ describe('ZoomableImage wheel behavior', () => { expectLatestTransform(-90, -70, 0.6); }); - test('clamps wheel zoom targets so the image cannot be pulled offscreen', () => { + test('starts from the new image fit transform after switching images', () => { + const { container, rerender } = renderZoomableImage({ + imagePath: '/tmp/photo-a.jpg', + }); + + const wheelArea = container.firstElementChild as HTMLElement; + let transformWrapper = screen.getByTestId('transform-wrapper'); + let image = screen.getByAltText('test image'); + + mockWrapperComponent = transformWrapper as HTMLDivElement; + mockElementRect( + wheelArea, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + mockElementRect( + transformWrapper, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + mockElementRect( + image, + { width: 1000, height: 800, left: 0, top: 0 }, + { clientWidth: 1000, clientHeight: 800 }, + ); + mockTransformState.scale = 2; - mockTransformState.positionX = 2000; - mockTransformState.positionY = 2000; + mockTransformState.positionX = -300; + mockTransformState.positionY = -250; + + rerender( + , + ); + + transformWrapper = screen.getByTestId('transform-wrapper'); + image = screen.getByAltText('test image'); + mockWrapperComponent = transformWrapper as HTMLDivElement; + mockElementRect( + transformWrapper, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + mockElementRect( + image, + { width: 1000, height: 800, left: 0, top: 0 }, + { clientWidth: 1000, clientHeight: 800 }, + ); + + fireEvent.load(image); + mockSetTransform.mockClear(); + + fireEvent.wheel(wheelArea, { + deltaY: -100, + clientX: 450, + clientY: 350, + }); + + expectLatestTransform(-90, -70, 0.6); + }); + test('clamps wheel zoom targets so the image cannot be pulled offscreen', () => { const { wheelArea } = setupWheelScene({ wrapperSize: { width: 800, height: 600 }, imageSize: { width: 1000, height: 800 }, }); + markSceneInitialized(wheelArea); + mockTransformState.scale = 2; + mockTransformState.positionX = 2000; + mockTransformState.positionY = 2000; + fireEvent.wheel(wheelArea, { deltaY: -100, clientX: 790, From 69fc975f846c0817787b11db6874c9fbc5bb774e Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sun, 31 May 2026 21:29:18 +0530 Subject: [PATCH 08/16] Retry zoom fit until transform content mounts --- .../src/components/Media/ZoomableImage.tsx | 37 ++++++++-- .../Media/__tests__/ZoomableImage.test.tsx | 70 ++++++++++++++++++- 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index 9333aa780..4e6becc21 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -18,6 +18,7 @@ const LINE_HEIGHT_MULTIPLIER = 33; const MAX_SCALE = 8; const MIN_SCALE = 1; const SCALE_EPSILON = 0.0001; +const MAX_FIT_RETRY_FRAMES = 12; type Size = { width: number; @@ -162,6 +163,7 @@ export const ZoomableImage = forwardRef( const hasPendingFitRef = useRef(true); const fitTransformRef = useRef(null); const fitFrameRef = useRef(null); + const fitRetryCountRef = useRef(0); const [isOverflowing, setIsOverflowing] = useState(false); const [minScale, setMinScale] = useState(MIN_SCALE); const rotationRef = useRef(rotation); @@ -252,18 +254,23 @@ export const ZoomableImage = forwardRef( hasUserInteractedRef.current = false; hasPendingFitRef.current = true; fitTransformRef.current = null; + fitRetryCountRef.current = 0; }, []); const applyFitTransform = useCallback( (duration = 200, animationType: AnimationType = 'easeOut') => { + const transform = transformRef.current; + const contentElement = transform?.instance?.contentComponent; const viewportElement = getViewportElement(); - if (!transformRef.current || !viewportElement || !imageRef.current) - return; + if (!transform || !contentElement || !viewportElement || !imageRef.current) + return false; const wrapperRect = viewportElement.getBoundingClientRect(); + if (!wrapperRect.width || !wrapperRect.height) return false; + const baseDimensions = getBaseDimensions(); - if (!baseDimensions) return; + if (!baseDimensions) return false; const fitTransform = getFitTransform( baseDimensions, @@ -271,14 +278,15 @@ export const ZoomableImage = forwardRef( wrapperRect.height, ); - if (!fitTransform) return; + if (!fitTransform) return false; fitTransformRef.current = fitTransform; isFitInitializedRef.current = true; hasUserInteractedRef.current = false; hasPendingFitRef.current = false; + fitRetryCountRef.current = 0; setMinimumScale(fitTransform.scale); - transformRef.current.setTransform( + transform.setTransform( fitTransform.positionX, fitTransform.positionY, fitTransform.scale, @@ -286,6 +294,7 @@ export const ZoomableImage = forwardRef( animationType, ); setIsOverflowing(false); + return true; }, [getBaseDimensions, getViewportElement, setMinimumScale], ); @@ -302,7 +311,16 @@ export const ZoomableImage = forwardRef( fitFrameRef.current = scheduleFrame(() => { fitFrameRef.current = null; - applyFitTransform(duration, animationType); + const applied = applyFitTransform(duration, animationType); + + if ( + !applied && + hasPendingFitRef.current && + fitRetryCountRef.current < MAX_FIT_RETRY_FRAMES + ) { + fitRetryCountRef.current += 1; + scheduleFitTransform(duration, animationType); + } }); }, [applyFitTransform, clearScheduledFit], @@ -418,9 +436,14 @@ export const ZoomableImage = forwardRef( e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); - clearScheduledFit(); if (!imageRef.current || !transformRef.current) return; + if (!transformRef.current.instance.contentComponent) { + scheduleFitTransform(0); + return; + } + + clearScheduledFit(); const transformState = transformRef.current.instance.transformState; const viewportElement = getViewportElement() ?? wheelElement; diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index 78638c85c..1a0424f8a 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import type { ReactNode, Ref } from 'react'; import { ZoomableImage } from '../ZoomableImage'; @@ -17,6 +17,8 @@ const mockSetTransform = jest.fn( }, ); let mockWrapperComponent: HTMLDivElement | null = null; +let mockContentComponent: HTMLDivElement | null = null; +let mockShouldExposeContentComponent = true; jest.mock('@tauri-apps/api/core', () => ({ convertFileSrc: (path: string) => path, @@ -32,6 +34,11 @@ jest.mock('react-zoom-pan-pinch', () => { get wrapperComponent() { return mockWrapperComponent; }, + get contentComponent() { + return mockShouldExposeContentComponent + ? mockContentComponent + : null; + }, transformState: mockTransformState, }, setTransform: mockSetTransform, @@ -48,7 +55,16 @@ jest.mock('react-zoom-pan-pinch', () => { ); const TransformComponent = ({ children }: { children: ReactNode }) => - React.createElement('div', null, children); + React.createElement( + 'div', + { + 'data-testid': 'transform-content', + ref: (node: HTMLDivElement | null) => { + mockContentComponent = node; + }, + }, + children, + ); return { TransformWrapper, @@ -122,9 +138,11 @@ const setupWheelScene = ({ const { container } = renderZoomableImage(); const wheelArea = container.firstElementChild as HTMLElement; const transformWrapper = screen.getByTestId('transform-wrapper'); + const transformContent = screen.getByTestId('transform-content'); const image = screen.getByAltText('test image'); mockWrapperComponent = transformWrapper as HTMLDivElement; + mockContentComponent = transformContent as HTMLDivElement; mockElementRect( wheelArea, @@ -180,6 +198,8 @@ describe('ZoomableImage wheel behavior', () => { // Keep the internal wrapper unavailable by default so tests cover the // production fallback path where the wheel area owns scroll handling. mockWrapperComponent = null; + mockContentComponent = null; + mockShouldExposeContentComponent = true; mockTransformState.scale = 1; mockTransformState.positionX = 0; mockTransformState.positionY = 0; @@ -345,6 +365,52 @@ describe('ZoomableImage wheel behavior', () => { expectLatestTransform(-90, -70, 0.6); }); + test('retries initial fit until the transform content is ready', () => { + jest.useFakeTimers(); + mockShouldExposeContentComponent = false; + + const { container } = renderZoomableImage(); + + const wheelArea = container.firstElementChild as HTMLElement; + const transformWrapper = screen.getByTestId('transform-wrapper'); + const transformContent = screen.getByTestId('transform-content'); + const image = screen.getByAltText('test image'); + + mockWrapperComponent = transformWrapper as HTMLDivElement; + mockElementRect( + wheelArea, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + mockElementRect( + transformWrapper, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + mockElementRect( + image, + { width: 1000, height: 800, left: 0, top: 0 }, + { clientWidth: 1000, clientHeight: 800 }, + ); + + fireEvent.load(image); + + act(() => { + jest.runOnlyPendingTimers(); + }); + expect(mockSetTransform).not.toHaveBeenCalled(); + + mockShouldExposeContentComponent = true; + mockContentComponent = transformContent as HTMLDivElement; + + act(() => { + jest.runOnlyPendingTimers(); + }); + + expectLatestTransform(0, 0, 0.5); + jest.useRealTimers(); + }); + test('starts from the new image fit transform after switching images', () => { const { container, rerender } = renderZoomableImage({ imagePath: '/tmp/photo-a.jpg', From f81111e163e15f6f2b955f039fcee13726339e08 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Mon, 1 Jun 2026 00:27:17 +0530 Subject: [PATCH 09/16] Format zoomable image component --- frontend/src/components/Media/ZoomableImage.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index 4e6becc21..25062fb12 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -262,7 +262,12 @@ export const ZoomableImage = forwardRef( const transform = transformRef.current; const contentElement = transform?.instance?.contentComponent; const viewportElement = getViewportElement(); - if (!transform || !contentElement || !viewportElement || !imageRef.current) + if ( + !transform || + !contentElement || + !viewportElement || + !imageRef.current + ) return false; const wrapperRect = viewportElement.getBoundingClientRect(); From 4b754ee2b06ad8bd254f61004220304c0e5949bf Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Mon, 1 Jun 2026 00:42:07 +0530 Subject: [PATCH 10/16] Format backend image helpers --- backend/app/database/images.py | 9 +++++++-- backend/app/routes/images.py | 13 +++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/backend/app/database/images.py b/backend/app/database/images.py index 15c1d374d..76149202b 100644 --- a/backend/app/database/images.py +++ b/backend/app/database/images.py @@ -457,6 +457,7 @@ def db_toggle_image_favourite_status(image_id: str) -> bool: finally: conn.close() + def db_get_image_by_id(image_id: str) -> Optional[dict]: """ Get a single image by ID with its favorite status. @@ -464,11 +465,14 @@ def db_get_image_by_id(image_id: str) -> Optional[dict]: conn = _connect() cursor = conn.cursor() try: - cursor.execute(""" + cursor.execute( + """ SELECT id, path, folder_id, thumbnailPath, metadata, isTagged, isFavourite FROM images WHERE id = ? - """, (image_id,)) + """, + (image_id,), + ) row = cursor.fetchone() if not row: return None @@ -488,6 +492,7 @@ def db_get_image_by_id(image_id: str) -> Optional[dict]: finally: conn.close() + # ============================================================================ # MEMORIES FEATURE - Location and Time-based Queries # ============================================================================ diff --git a/backend/app/routes/images.py b/backend/app/routes/images.py index f00b0a941..3741e13f6 100644 --- a/backend/app/routes/images.py +++ b/backend/app/routes/images.py @@ -106,15 +106,15 @@ def toggle_favourite(req: ToggleFavouriteRequest): success = db_toggle_image_favourite_status(image_id) if not success: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Image not found or failed to toggle" + status_code=status.HTTP_404_NOT_FOUND, + detail="Image not found or failed to toggle", ) # Fetch updated status to return image = db_get_image_by_id(image_id) if not image: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Image not found after toggle" + status_code=status.HTTP_404_NOT_FOUND, + detail="Image not found after toggle", ) return { "success": True, @@ -126,10 +126,11 @@ def toggle_favourite(req: ToggleFavouriteRequest): except Exception as e: logger.error(f"error in /toggle-favourite route: {e}") raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Internal server error: {e}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Internal server error: {e}", ) + class ImageInfoResponse(BaseModel): id: str path: str From 3950d16d4abe9f32d8860d965923999ac05bfe77 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Mon, 1 Jun 2026 09:35:17 +0530 Subject: [PATCH 11/16] Use controlled media zoom transform --- .../src/components/Media/ZoomableImage.tsx | 1056 +++++++++-------- .../Media/__tests__/ZoomableImage.test.tsx | 551 ++++----- 2 files changed, 789 insertions(+), 818 deletions(-) diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index 25062fb12..2a93e5749 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -5,12 +5,9 @@ import { useEffect, useState, useCallback, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, } from 'react'; -import { - TransformWrapper, - TransformComponent, - ReactZoomPanPinchRef, -} from 'react-zoom-pan-pinch'; import { convertFileSrc } from '@tauri-apps/api/core'; const ZOOM_FACTOR = 0.001; @@ -19,6 +16,7 @@ const MAX_SCALE = 8; const MIN_SCALE = 1; const SCALE_EPSILON = 0.0001; const MAX_FIT_RETRY_FRAMES = 12; +const CONTROL_BUTTON_ZOOM_STEP = 0.5; type Size = { width: number; @@ -30,27 +28,19 @@ type OverflowState = { height: boolean; }; -type FitTransform = { +type TransformState = { positionX: number; positionY: number; scale: number; }; -type AnimationType = - | 'easeOut' - | 'linear' - | 'easeInQuad' - | 'easeOutQuad' - | 'easeInOutQuad' - | 'easeInCubic' - | 'easeOutCubic' - | 'easeInOutCubic' - | 'easeInQuart' - | 'easeOutQuart' - | 'easeInOutQuart' - | 'easeInQuint' - | 'easeOutQuint' - | 'easeInOutQuint'; +type Geometry = { + viewportWidth: number; + viewportHeight: number; + rawDimensions: Size; + baseDimensions: Size; + minScale: number; +}; interface ZoomableImageProps { imagePath: string; @@ -98,6 +88,20 @@ const getAxisPosition = ( const axisTouchesViewport = (scaledSize: number, viewportSize: number) => scaledSize >= viewportSize - SCALE_EPSILON; +const getEffectiveDimensions = ( + width: number, + height: number, + rotation: number, +): Size => { + const normalizedRotation = ((rotation % 360) + 360) % 360; + const isRotated90or270 = + normalizedRotation === 90 || normalizedRotation === 270; + + return isRotated90or270 + ? { width: height, height: width } + : { width, height }; +}; + const getMinimumScale = ( baseDimensions: Size, viewportWidth: number, @@ -119,124 +123,230 @@ const getMinimumScale = ( ); }; -const getFirstViewportEdgeScale = ( +const getNextViewportEdgeScale = ( baseDimensions: Size, viewportWidth: number, viewportHeight: number, -) => - Math.min( + currentTouchesViewport: OverflowState, +) => { + const edgeScales = []; + + if (!currentTouchesViewport.width) { + edgeScales.push(viewportWidth / baseDimensions.width); + } + + if (!currentTouchesViewport.height) { + edgeScales.push(viewportHeight / baseDimensions.height); + } + + if (!edgeScales.length) return null; + + return Math.min( MAX_SCALE, Math.max( getMinimumScale(baseDimensions, viewportWidth, viewportHeight), - Math.min( - viewportWidth / baseDimensions.width, - viewportHeight / baseDimensions.height, - ), + Math.min(...edgeScales), ), ); +}; + +const getScaledDimensions = (baseDimensions: Size, scale: number): Size => ({ + width: baseDimensions.width * scale, + height: baseDimensions.height * scale, +}); -const getFitTransform = ( +const getOverflowState = ( baseDimensions: Size, + scale: number, viewportWidth: number, viewportHeight: number, -): FitTransform | null => { - if (!viewportWidth || !viewportHeight) return null; +): OverflowState => { + const scaledDimensions = getScaledDimensions(baseDimensions, scale); + + return { + width: scaledDimensions.width > viewportWidth, + height: scaledDimensions.height > viewportHeight, + }; +}; + +const getFitTransform = ({ + baseDimensions, + viewportWidth, + viewportHeight, + minScale, +}: Geometry): TransformState => { + const scaledDimensions = getScaledDimensions(baseDimensions, minScale); + + return { + positionX: getCenteredAxisPosition(viewportWidth, scaledDimensions.width), + positionY: getCenteredAxisPosition(viewportHeight, scaledDimensions.height), + scale: minScale, + }; +}; - const scale = getMinimumScale(baseDimensions, viewportWidth, viewportHeight); - const scaledWidth = baseDimensions.width * scale; - const scaledHeight = baseDimensions.height * scale; +const getElementSize = (element: HTMLElement) => { + const rect = element.getBoundingClientRect(); return { - positionX: getCenteredAxisPosition(viewportWidth, scaledWidth), - positionY: getCenteredAxisPosition(viewportHeight, scaledHeight), - scale, + width: rect.width || element.clientWidth, + height: rect.height || element.clientHeight, + left: rect.left, + top: rect.top, }; }; export const ZoomableImage = forwardRef( ({ imagePath, alt, rotation, resetSignal }, ref) => { - const transformRef = useRef(null); - const wheelAreaRef = useRef(null); + const viewportRef = useRef(null); const imageRef = useRef(null); + const transformStateRef = useRef({ + positionX: 0, + positionY: 0, + scale: MIN_SCALE, + }); + const rawDimensionsRef = useRef(null); const isFitInitializedRef = useRef(false); const hasUserInteractedRef = useRef(false); - const hasPendingFitRef = useRef(true); - const fitTransformRef = useRef(null); const fitFrameRef = useRef(null); const fitRetryCountRef = useRef(0); - const [isOverflowing, setIsOverflowing] = useState(false); - const [minScale, setMinScale] = useState(MIN_SCALE); + const dragStateRef = useRef<{ + pointerId: number | 'mouse'; + startClientX: number; + startClientY: number; + startTransform: TransformState; + } | null>(null); const rotationRef = useRef(rotation); + const [transformState, setTransformState] = useState( + transformStateRef.current, + ); + const [rawDimensions, setRawDimensions] = useState(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const [isPanning, setIsPanning] = useState(false); - const setMinimumScale = useCallback((scale: number) => { - setMinScale((currentScale) => - Math.abs(currentScale - scale) < SCALE_EPSILON ? currentScale : scale, - ); + const setRawImageDimensions = useCallback((dimensions: Size | null) => { + rawDimensionsRef.current = dimensions; + setRawDimensions(dimensions); }, []); - const getEffectiveDimensions = useCallback( - (width: number, height: number) => { - const normalizedRotation = ((rotationRef.current % 360) + 360) % 360; - const isRotated90or270 = - normalizedRotation === 90 || normalizedRotation === 270; - return isRotated90or270 - ? { width: height, height: width } - : { width, height }; - }, - [], - ); + const readRawDimensions = useCallback((): Size | null => { + const img = imageRef.current; + const fallbackDimensions = rawDimensionsRef.current; + + const width = + img?.naturalWidth || img?.clientWidth || fallbackDimensions?.width || 0; + const height = + img?.naturalHeight || + img?.clientHeight || + fallbackDimensions?.height || + 0; - const getBaseDimensions = useCallback((): Size | null => { - if (!imageRef.current) return null; + if (!width || !height) return null; - const renderedWidth = - imageRef.current.naturalWidth || imageRef.current.clientWidth; - const renderedHeight = - imageRef.current.naturalHeight || imageRef.current.clientHeight; + return { width, height }; + }, []); - if (!renderedWidth || !renderedHeight) return null; + const getGeometry = useCallback((): Geometry | null => { + const viewport = viewportRef.current; + const rawImageDimensions = readRawDimensions(); - return getEffectiveDimensions(renderedWidth, renderedHeight); - }, [getEffectiveDimensions]); + if (!viewport || !rawImageDimensions) return null; - const getScaledDimensions = useCallback( - (scale: number): Size | null => { - const baseDimensions = getBaseDimensions(); + const viewportSize = getElementSize(viewport); - if (!baseDimensions) return null; + if (!viewportSize.width || !viewportSize.height) return null; - return { - width: baseDimensions.width * scale, - height: baseDimensions.height * scale, - }; + const baseDimensions = getEffectiveDimensions( + rawImageDimensions.width, + rawImageDimensions.height, + rotationRef.current, + ); + const minScale = getMinimumScale( + baseDimensions, + viewportSize.width, + viewportSize.height, + ); + + return { + viewportWidth: viewportSize.width, + viewportHeight: viewportSize.height, + rawDimensions: rawImageDimensions, + baseDimensions, + minScale, + }; + }, [readRawDimensions]); + + const setControlledTransform = useCallback( + (nextTransform: TransformState) => { + transformStateRef.current = nextTransform; + setTransformState(nextTransform); }, - [getBaseDimensions], + [], ); - const getOverflowState = useCallback( - ( - scale: number, - viewportWidth: number, - viewportHeight: number, - ): OverflowState => { - const scaledDimensions = getScaledDimensions(scale); + const applyTransform = useCallback( + (nextTransform: TransformState, geometry = getGeometry()) => { + if (!geometry) return false; - if (!scaledDimensions) return { width: false, height: false }; + const scale = clamp(nextTransform.scale, geometry.minScale, MAX_SCALE); + const scaledDimensions = getScaledDimensions( + geometry.baseDimensions, + scale, + ); + const overflow = getOverflowState( + geometry.baseDimensions, + scale, + geometry.viewportWidth, + geometry.viewportHeight, + ); + const shouldRecenter = + scale <= geometry.minScale + SCALE_EPSILON || + (!overflow.width && !overflow.height); - return { - width: scaledDimensions.width > viewportWidth, - height: scaledDimensions.height > viewportHeight, - }; + const positionX = shouldRecenter + ? getCenteredAxisPosition( + geometry.viewportWidth, + scaledDimensions.width, + ) + : getAxisPosition( + nextTransform.positionX, + geometry.viewportWidth, + scaledDimensions.width, + overflow.width, + ); + const positionY = shouldRecenter + ? getCenteredAxisPosition( + geometry.viewportHeight, + scaledDimensions.height, + ) + : getAxisPosition( + nextTransform.positionY, + geometry.viewportHeight, + scaledDimensions.height, + overflow.height, + ); + + setIsOverflowing(overflow.width || overflow.height); + setControlledTransform({ positionX, positionY, scale }); + return true; }, - [getScaledDimensions], + [getGeometry, setControlledTransform], ); - const getViewportElement = useCallback( - () => - transformRef.current?.instance?.wrapperComponent ?? - wheelAreaRef.current, - [], - ); + const applyFitTransform = useCallback(() => { + const geometry = getGeometry(); + + if (!geometry) return false; + + const didApply = applyTransform(getFitTransform(geometry), geometry); + + if (didApply) { + isFitInitializedRef.current = true; + hasUserInteractedRef.current = false; + fitRetryCountRef.current = 0; + } + + return didApply; + }, [applyTransform, getGeometry]); const clearScheduledFit = useCallback(() => { if (fitFrameRef.current === null) return; @@ -249,482 +359,418 @@ export const ZoomableImage = forwardRef( fitFrameRef.current = null; }, []); - const markFitPending = useCallback(() => { - isFitInitializedRef.current = false; - hasUserInteractedRef.current = false; - hasPendingFitRef.current = true; - fitTransformRef.current = null; - fitRetryCountRef.current = 0; - }, []); - - const applyFitTransform = useCallback( - (duration = 200, animationType: AnimationType = 'easeOut') => { - const transform = transformRef.current; - const contentElement = transform?.instance?.contentComponent; - const viewportElement = getViewportElement(); - if ( - !transform || - !contentElement || - !viewportElement || - !imageRef.current - ) - return false; + const scheduleFitTransform = useCallback(() => { + clearScheduledFit(); - const wrapperRect = viewportElement.getBoundingClientRect(); - if (!wrapperRect.width || !wrapperRect.height) return false; + const scheduleFrame: (callback: FrameRequestCallback) => number = + typeof window.requestAnimationFrame === 'function' + ? window.requestAnimationFrame.bind(window) + : (callback) => + window.setTimeout(() => callback(performance.now()), 0); - const baseDimensions = getBaseDimensions(); + fitFrameRef.current = scheduleFrame(() => { + fitFrameRef.current = null; + const applied = applyFitTransform(); - if (!baseDimensions) return false; + if (!applied && fitRetryCountRef.current < MAX_FIT_RETRY_FRAMES) { + fitRetryCountRef.current += 1; + scheduleFitTransform(); + } + }); + }, [applyFitTransform, clearScheduledFit]); - const fitTransform = getFitTransform( - baseDimensions, - wrapperRect.width, - wrapperRect.height, + const resetToFit = useCallback(() => { + isFitInitializedRef.current = false; + hasUserInteractedRef.current = false; + fitRetryCountRef.current = 0; + scheduleFitTransform(); + }, [scheduleFitTransform]); + + const zoomBy = useCallback( + (zoomChange: number, clientX?: number, clientY?: number) => { + const geometry = getGeometry(); + const viewport = viewportRef.current; + + if (!geometry || !viewport) return false; + + const viewportSize = getElementSize(viewport); + const fitTransform = getFitTransform(geometry); + const currentTransform = isFitInitializedRef.current + ? transformStateRef.current + : fitTransform; + const desiredScale = clamp( + currentTransform.scale + zoomChange, + geometry.minScale, + MAX_SCALE, ); - - if (!fitTransform) return false; - - fitTransformRef.current = fitTransform; - isFitInitializedRef.current = true; - hasUserInteractedRef.current = false; - hasPendingFitRef.current = false; - fitRetryCountRef.current = 0; - setMinimumScale(fitTransform.scale); - transform.setTransform( - fitTransform.positionX, - fitTransform.positionY, - fitTransform.scale, - duration, - animationType, + const currentDimensions = getScaledDimensions( + geometry.baseDimensions, + currentTransform.scale, ); - setIsOverflowing(false); - return true; - }, - [getBaseDimensions, getViewportElement, setMinimumScale], - ); + const currentTouchesViewport = { + width: axisTouchesViewport( + currentDimensions.width, + geometry.viewportWidth, + ), + height: axisTouchesViewport( + currentDimensions.height, + geometry.viewportHeight, + ), + }; + const isZoomingIn = desiredScale > currentTransform.scale; + const nextViewportEdgeScale = getNextViewportEdgeScale( + geometry.baseDimensions, + geometry.viewportWidth, + geometry.viewportHeight, + currentTouchesViewport, + ); + const scale = + isZoomingIn && + nextViewportEdgeScale !== null && + desiredScale > nextViewportEdgeScale + ? nextViewportEdgeScale + : desiredScale; + const scaledDimensions = getScaledDimensions( + geometry.baseDimensions, + scale, + ); + const newOverflow = getOverflowState( + geometry.baseDimensions, + scale, + geometry.viewportWidth, + geometry.viewportHeight, + ); + const mouseViewportX = + clientX === undefined + ? geometry.viewportWidth / 2 + : clientX - viewportSize.left; + const mouseViewportY = + clientY === undefined + ? geometry.viewportHeight / 2 + : clientY - viewportSize.top; + const isOverImage = + mouseViewportX >= currentTransform.positionX && + mouseViewportX <= + currentTransform.positionX + currentDimensions.width && + mouseViewportY >= currentTransform.positionY && + mouseViewportY <= + currentTransform.positionY + currentDimensions.height; + const ratio = + currentTransform.scale > 0 ? scale / currentTransform.scale : 1; + const anchoredX = + mouseViewportX - + (mouseViewportX - currentTransform.positionX) * ratio; + const anchoredY = + mouseViewportY - + (mouseViewportY - currentTransform.positionY) * ratio; + const centeredX = getCenteredAxisPosition( + geometry.viewportWidth, + scaledDimensions.width, + ); + const centeredY = getCenteredAxisPosition( + geometry.viewportHeight, + scaledDimensions.height, + ); + const shouldAnchorX = + isOverImage && currentTouchesViewport.width && newOverflow.width; + const shouldAnchorY = + isOverImage && currentTouchesViewport.height && newOverflow.height; - const scheduleFitTransform = useCallback( - (duration = 200, animationType: AnimationType = 'easeOut') => { - clearScheduledFit(); + const didApply = applyTransform( + { + positionX: shouldAnchorX ? anchoredX : centeredX, + positionY: shouldAnchorY ? anchoredY : centeredY, + scale, + }, + geometry, + ); - const scheduleFrame: (callback: FrameRequestCallback) => number = - typeof window.requestAnimationFrame === 'function' - ? window.requestAnimationFrame.bind(window) - : (callback) => - window.setTimeout(() => callback(performance.now()), 0); - - fitFrameRef.current = scheduleFrame(() => { - fitFrameRef.current = null; - const applied = applyFitTransform(duration, animationType); - - if ( - !applied && - hasPendingFitRef.current && - fitRetryCountRef.current < MAX_FIT_RETRY_FRAMES - ) { - fitRetryCountRef.current += 1; - scheduleFitTransform(duration, animationType); - } - }); - }, - [applyFitTransform, clearScheduledFit], - ); + if (didApply) { + isFitInitializedRef.current = true; + hasUserInteractedRef.current = true; + } - const handleReset = useCallback( - (duration = 200, animationType: AnimationType = 'easeOut') => { - markFitPending(); - scheduleFitTransform(duration, animationType); + return didApply; }, - [markFitPending, scheduleFitTransform], + [applyTransform, getGeometry], ); useImperativeHandle(ref, () => ({ zoomIn: () => { - hasUserInteractedRef.current = true; - transformRef.current?.zoomIn(); + zoomBy(CONTROL_BUTTON_ZOOM_STEP); }, zoomOut: () => { - hasUserInteractedRef.current = true; - transformRef.current?.zoomOut(); + zoomBy(-CONTROL_BUTTON_ZOOM_STEP); }, - reset: () => handleReset(), + reset: () => resetToFit(), })); - useEffect(() => { - handleReset(); - }, [resetSignal, handleReset]); - useEffect(() => { rotationRef.current = rotation; - markFitPending(); - scheduleFitTransform(0); - }, [rotation, markFitPending, scheduleFitTransform]); + resetToFit(); + }, [rotation, resetToFit]); useEffect(() => { - const viewportElement = getViewportElement(); - if (!transformRef.current || !viewportElement || !imageRef.current) - return; - - const scale = transformRef.current.instance.transformState.scale; - const rect = viewportElement.getBoundingClientRect(); - const baseDimensions = getBaseDimensions(); - - if (baseDimensions) { - setMinimumScale( - getMinimumScale(baseDimensions, rect.width, rect.height), - ); - } - - const overflow = getOverflowState(scale, rect.width, rect.height); - setIsOverflowing(overflow.width || overflow.height); - }, [ - rotation, - getBaseDimensions, - getOverflowState, - getViewportElement, - setMinimumScale, - ]); + resetToFit(); + }, [resetSignal, resetToFit]); useEffect(() => { setIsOverflowing(false); - markFitPending(); + setRawImageDimensions(null); + resetToFit(); + }, [imagePath, resetToFit, setRawImageDimensions]); - const img = imageRef.current; - if (!img) return; + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const handleResize = () => { + if (hasUserInteractedRef.current) { + applyTransform(transformStateRef.current); + } else { + resetToFit(); + } + }; + + let resizeObserver: ResizeObserver | null = null; - if (img.complete && img.naturalWidth > 0) { - scheduleFitTransform(0); + if (typeof ResizeObserver === 'function') { + resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(viewport); } - }, [imagePath, markFitPending, scheduleFitTransform]); - const handleImageLoad = useCallback(() => { - markFitPending(); - scheduleFitTransform(0); - }, [markFitPending, scheduleFitTransform]); + window.addEventListener('resize', handleResize); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', handleResize); + }; + }, [applyTransform, resetToFit]); useEffect(() => { - const wheelElement = wheelAreaRef.current; - if (!wheelElement) return; + const viewport = viewportRef.current; + if (!viewport) return; - let cachedViewportRect = - getViewportElement()?.getBoundingClientRect() ?? - wheelElement.getBoundingClientRect(); + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); - const resizeObserver = new ResizeObserver(() => { - const viewportElement = getViewportElement() ?? wheelElement; + const isLineMode = e.deltaMode === 1; + const multiplier = isLineMode ? LINE_HEIGHT_MULTIPLIER : 1; + const zoomChange = -e.deltaY * multiplier * ZOOM_FACTOR; - cachedViewportRect = viewportElement.getBoundingClientRect(); + zoomBy(zoomChange, e.clientX, e.clientY); + }; - const baseDimensions = getBaseDimensions(); - if (baseDimensions) { - const fitTransform = getFitTransform( - baseDimensions, - cachedViewportRect.width, - cachedViewportRect.height, - ); + viewport.addEventListener('wheel', handleWheel, { + passive: false, + capture: true, + }); - if (fitTransform) { - fitTransformRef.current = fitTransform; - setMinimumScale(fitTransform.scale); + return () => { + viewport.removeEventListener('wheel', handleWheel, true); + }; + }, [zoomBy]); - if (!hasUserInteractedRef.current) { - scheduleFitTransform(0); - } - } - } - }); + useEffect( + () => () => { + clearScheduledFit(); + }, + [clearScheduledFit], + ); - resizeObserver.observe(wheelElement); + const handleImageLoad = useCallback(() => { + const dimensions = readRawDimensions(); - const handleWheelInterceptor = (e: WheelEvent) => { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); + if (dimensions) { + setRawImageDimensions(dimensions); + } - if (!imageRef.current || !transformRef.current) return; - if (!transformRef.current.instance.contentComponent) { - scheduleFitTransform(0); - return; - } + resetToFit(); + }, [readRawDimensions, resetToFit, setRawImageDimensions]); - clearScheduledFit(); + const startDrag = useCallback( + (pointerId: number | 'mouse', clientX: number, clientY: number) => { + const geometry = getGeometry(); + if (!geometry || dragStateRef.current) return false; - const transformState = transformRef.current.instance.transformState; - const viewportElement = getViewportElement() ?? wheelElement; + const overflow = getOverflowState( + geometry.baseDimensions, + transformStateRef.current.scale, + geometry.viewportWidth, + geometry.viewportHeight, + ); - cachedViewportRect = viewportElement.getBoundingClientRect(); - const viewportRect = cachedViewportRect; - const imageRect = imageRef.current.getBoundingClientRect(); - const mouseX = e.clientX - imageRect.left; - const mouseY = e.clientY - imageRect.top; + if (!overflow.width && !overflow.height) return false; - const isOverImage = - mouseX >= 0 && - mouseX <= imageRect.width && - mouseY >= 0 && - mouseY <= imageRect.height; + dragStateRef.current = { + pointerId, + startClientX: clientX, + startClientY: clientY, + startTransform: transformStateRef.current, + }; + hasUserInteractedRef.current = true; + setIsPanning(true); + return true; + }, + [getGeometry], + ); - const isLineMode = e.deltaMode === 1; - const multiplier = isLineMode ? LINE_HEIGHT_MULTIPLIER : 1; - const factor = ZOOM_FACTOR; + const updateDrag = useCallback( + (pointerId: number | 'mouse', clientX: number, clientY: number) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== pointerId) return false; - const zoomChange = -e.deltaY * multiplier * factor; + const deltaX = clientX - dragState.startClientX; + const deltaY = clientY - dragState.startClientY; - const baseDimensions = getBaseDimensions(); - if (!baseDimensions) return; + applyTransform({ + positionX: dragState.startTransform.positionX + deltaX, + positionY: dragState.startTransform.positionY + deltaY, + scale: dragState.startTransform.scale, + }); + return true; + }, + [applyTransform], + ); - const fitTransform = getFitTransform( - baseDimensions, - viewportRect.width, - viewportRect.height, - ); - if (!fitTransform) return; + const endDrag = useCallback((pointerId: number | 'mouse') => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== pointerId) return false; - const minimumScale = getMinimumScale( - baseDimensions, - viewportRect.width, - viewportRect.height, - ); - fitTransformRef.current = fitTransform; - - const shouldUseFitTransform = - hasPendingFitRef.current || !isFitInitializedRef.current; - const currentScale = shouldUseFitTransform - ? fitTransform.scale - : transformState.scale; - const currentPositionX = shouldUseFitTransform - ? fitTransform.positionX - : transformState.positionX; - const currentPositionY = shouldUseFitTransform - ? fitTransform.positionY - : transformState.positionY; - const desiredScale = Math.max( - minimumScale, - Math.min(MAX_SCALE, currentScale + zoomChange), - ); - setMinimumScale(minimumScale); + dragStateRef.current = null; + setIsPanning(false); + return true; + }, []); - const currentDimensions = { - width: baseDimensions.width * currentScale, - height: baseDimensions.height * currentScale, - }; - const currentTouchesViewport = { - width: axisTouchesViewport( - currentDimensions.width, - viewportRect.width, - ), - height: axisTouchesViewport( - currentDimensions.height, - viewportRect.height, - ), - }; - const isZoomingIn = desiredScale > currentScale; + const handlePointerDown = (e: ReactPointerEvent) => { + if (e.button !== 0) return; - const shouldFitFirst = - isZoomingIn && - !currentTouchesViewport.width && - !currentTouchesViewport.height; - const firstViewportEdgeScale = getFirstViewportEdgeScale( - baseDimensions, - viewportRect.width, - viewportRect.height, - ); - const newScale = - shouldFitFirst && desiredScale > firstViewportEdgeScale - ? firstViewportEdgeScale - : desiredScale; + const didStartDrag = startDrag(e.pointerId, e.clientX, e.clientY); + if (!didStartDrag) return; - const scaledDimensions = getScaledDimensions(newScale); - if (!scaledDimensions) return; + e.currentTarget.setPointerCapture?.(e.pointerId); + e.preventDefault(); + }; - const newOverflow = getOverflowState( - newScale, - viewportRect.width, - viewportRect.height, - ); + const handlePointerMove = (e: ReactPointerEvent) => { + const didUpdateDrag = updateDrag(e.pointerId, e.clientX, e.clientY); + if (!didUpdateDrag) return; - const shouldRecenter = - newScale <= minimumScale + SCALE_EPSILON || - (!newOverflow.width && !newOverflow.height); + e.preventDefault(); + }; - const centeredX = getCenteredAxisPosition( - viewportRect.width, - scaledDimensions.width, - ); - const centeredY = getCenteredAxisPosition( - viewportRect.height, - scaledDimensions.height, - ); - const ratio = currentScale > 0 ? newScale / currentScale : 1; - const mouseViewportX = e.clientX - viewportRect.left; - const mouseViewportY = e.clientY - viewportRect.top; - const anchoredX = - mouseViewportX - (mouseViewportX - currentPositionX) * ratio; - const anchoredY = - mouseViewportY - (mouseViewportY - currentPositionY) * ratio; - const shouldAnchorX = - isOverImage && currentTouchesViewport.width && newOverflow.width; - const shouldAnchorY = - isOverImage && currentTouchesViewport.height && newOverflow.height; + const handlePointerEnd = (e: ReactPointerEvent) => { + const didEndDrag = endDrag(e.pointerId); + if (!didEndDrag) return; - const targetX = shouldRecenter - ? centeredX - : getAxisPosition( - shouldAnchorX ? anchoredX : centeredX, - viewportRect.width, - scaledDimensions.width, - newOverflow.width, - ); - const targetY = shouldRecenter - ? centeredY - : getAxisPosition( - shouldAnchorY ? anchoredY : centeredY, - viewportRect.height, - scaledDimensions.height, - newOverflow.height, - ); + e.currentTarget.releasePointerCapture?.(e.pointerId); + }; - setIsOverflowing(newOverflow.width || newOverflow.height); - isFitInitializedRef.current = true; - hasPendingFitRef.current = false; - hasUserInteractedRef.current = true; - transformRef.current.setTransform(targetX, targetY, newScale, 0); - }; + const handleMouseDown = (e: ReactMouseEvent) => { + if (e.button !== 0) return; - wheelElement.addEventListener('wheel', handleWheelInterceptor, { - passive: false, - capture: true, - }); + const didStartDrag = startDrag('mouse', e.clientX, e.clientY); + if (didStartDrag) { + e.preventDefault(); + } + }; - return () => { - clearScheduledFit(); - resizeObserver.disconnect(); - wheelElement.removeEventListener('wheel', handleWheelInterceptor, true); - }; - }, [ - clearScheduledFit, - getBaseDimensions, - getOverflowState, - getScaledDimensions, - getViewportElement, - scheduleFitTransform, - setMinimumScale, - ]); + const handleMouseMove = (e: ReactMouseEvent) => { + const didUpdateDrag = updateDrag('mouse', e.clientX, e.clientY); + if (didUpdateDrag) { + e.preventDefault(); + } + }; - return ( -
- { - 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) => { - hasUserInteractedRef.current = true; - 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 baseDimensions = getBaseDimensions(); - - if (!baseDimensions) return; - - const scaledW = baseDimensions.width * scale; - const scaledH = baseDimensions.height * scale; - - const finalX = getAxisPosition( - positionX, - viewW, - scaledW, - overflow.width, - ); - const finalY = getAxisPosition( - positionY, - viewH, - scaledH, - overflow.height, - ); - const clamped = positionX !== finalX || positionY !== finalY; + const handleMouseEnd = () => { + endDrag('mouse'); + }; - if (clamped) { - ref.setTransform(finalX, finalY, scale, 0); - } + const contentDimensions = rawDimensions + ? getEffectiveDimensions( + rawDimensions.width, + rawDimensions.height, + rotation, + ) + : null; + const imageOffset = + rawDimensions && contentDimensions + ? { + left: (contentDimensions.width - rawDimensions.width) / 2, + top: (contentDimensions.height - rawDimensions.height) / 2, + } + : { left: 0, top: 0 }; + + return ( +
+
- { + const img = e.target as HTMLImageElement; + img.onerror = null; + img.src = '/placeholder.svg'; }} - contentStyle={{ - width: 'fit-content', - height: 'fit-content', - cursor: isOverflowing ? 'move' : 'default', + style={{ + position: contentDimensions ? 'absolute' : 'relative', + left: `${imageOffset.left}px`, + top: `${imageOffset.top}px`, + width: rawDimensions ? `${rawDimensions.width}px` : undefined, + height: rawDimensions ? `${rawDimensions.height}px` : undefined, + maxWidth: 'none', + maxHeight: 'none', + objectFit: 'contain', + zIndex: 50, + transform: `rotate(${rotation}deg)`, + transformOrigin: 'center center', + cursor: isPanning + ? 'grabbing' + : isOverflowing + ? 'move' + : 'default', + pointerEvents: 'none', }} - > - {alt} { - const img = e.target as HTMLImageElement; - img.onerror = null; - img.src = '/placeholder.svg'; - }} - style={{ - maxWidth: 'none', - maxHeight: 'none', - 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 index 1a0424f8a..03992b7c8 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -1,76 +1,15 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; -import type { ReactNode, Ref } from 'react'; -import { ZoomableImage } from '../ZoomableImage'; - -const mockZoomIn = jest.fn(); -const mockZoomOut = jest.fn(); -const mockTransformState = { - scale: 1, - positionX: 0, - positionY: 0, -}; -const mockSetTransform = jest.fn( - (positionX: number, positionY: number, scale: number, _duration?: number) => { - mockTransformState.positionX = positionX; - mockTransformState.positionY = positionY; - mockTransformState.scale = scale; - }, -); -let mockWrapperComponent: HTMLDivElement | null = null; -let mockContentComponent: HTMLDivElement | null = null; -let mockShouldExposeContentComponent = true; +import { createRef } from 'react'; +import { ZoomableImage, ZoomableImageRef } from '../ZoomableImage'; 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: { - get wrapperComponent() { - return mockWrapperComponent; - }, - get contentComponent() { - return mockShouldExposeContentComponent - ? mockContentComponent - : 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', - { - 'data-testid': 'transform-content', - ref: (node: HTMLDivElement | null) => { - mockContentComponent = node; - }, - }, - children, - ); - - return { - TransformWrapper, - TransformComponent, - }; -}); +class ResizeObserverMock { + observe = jest.fn(); + disconnect = jest.fn(); +} const mockElementRect = ( element: Element, @@ -85,8 +24,8 @@ const mockElementRect = ( top: 0, right: (rect.left ?? 0) + (rect.width ?? 0), bottom: (rect.top ?? 0) + (rect.height ?? 0), - x: 0, - y: 0, + x: rect.left ?? 0, + y: rect.top ?? 0, width: 0, height: 0, toJSON: () => undefined, @@ -109,6 +48,28 @@ const mockElementRect = ( } }; +const mockImageDimensions = ( + image: HTMLElement, + dimensions: { width: number; height: number }, +) => { + Object.defineProperty(image, 'naturalWidth', { + configurable: true, + value: dimensions.width, + }); + Object.defineProperty(image, 'naturalHeight', { + configurable: true, + value: dimensions.height, + }); + Object.defineProperty(image, 'clientWidth', { + configurable: true, + value: dimensions.width, + }); + Object.defineProperty(image, 'clientHeight', { + configurable: true, + value: dimensions.height, + }); +}; + const renderZoomableImage = ( props: Partial<{ imagePath: string; @@ -126,372 +87,336 @@ const renderZoomableImage = ( />, ); -const setupWheelScene = ({ - wrapperSize, +const setupScene = ({ + viewportSize, imageSize, - imageOffset = { left: 0, top: 0 }, + props, }: { - wrapperSize: { width: number; height: number }; + viewportSize: { width: number; height: number }; imageSize: { width: number; height: number }; - imageOffset?: { left: number; top: number }; + props?: Partial<{ + imagePath: string; + alt: string; + rotation: number; + resetSignal: number; + }>; }) => { - const { container } = renderZoomableImage(); - const wheelArea = container.firstElementChild as HTMLElement; - const transformWrapper = screen.getByTestId('transform-wrapper'); - const transformContent = screen.getByTestId('transform-content'); + const renderResult = renderZoomableImage(props); + const viewport = screen.getByTestId('zoom-viewport'); + const content = screen.getByTestId('zoom-content'); const image = screen.getByAltText('test image'); - mockWrapperComponent = transformWrapper as HTMLDivElement; - mockContentComponent = transformContent as HTMLDivElement; - mockElementRect( - wheelArea, - { ...wrapperSize, left: 0, top: 0 }, - { clientWidth: wrapperSize.width, clientHeight: wrapperSize.height }, - ); - mockElementRect( - transformWrapper, - { ...wrapperSize, left: 0, top: 0 }, - { clientWidth: wrapperSize.width, clientHeight: wrapperSize.height }, - ); - mockElementRect( - image, - { - ...imageSize, - left: imageOffset.left, - top: imageOffset.top, - }, - { clientWidth: imageSize.width, clientHeight: imageSize.height }, + viewport, + { ...viewportSize, left: 0, top: 0 }, + { clientWidth: viewportSize.width, clientHeight: viewportSize.height }, ); + mockImageDimensions(image, imageSize); + + fireEvent.load(image); - return { wheelArea, image }; + return { ...renderResult, viewport, content, image }; }; -const markSceneInitialized = (wheelArea: HTMLElement) => { - fireEvent.wheel(wheelArea, { - deltaY: 0, - clientX: 0, - clientY: 0, - }); - mockSetTransform.mockClear(); +const getCurrentTransform = () => { + const content = screen.getByTestId('zoom-content'); + const transform = content.style.transform; + const match = transform.match( + /translate3d\((-?[\d.]+)px, (-?[\d.]+)px, 0\) scale\((-?[\d.]+)\)/, + ); + + if (!match) { + throw new Error(`Unexpected transform: ${transform}`); + } + + return { + positionX: Number(match[1]), + positionY: Number(match[2]), + scale: Number(match[3]), + }; }; -const expectLatestTransform = ( +const expectCurrentTransform = ( expectedX: number, expectedY: number, expectedScale: number, ) => { - const lastCall = mockSetTransform.mock.calls.at(-1); + const transform = getCurrentTransform(); - expect(lastCall).toBeDefined(); - expect(lastCall?.[0]).toBeCloseTo(expectedX); - expect(lastCall?.[1]).toBeCloseTo(expectedY); - expect(lastCall?.[2]).toBeCloseTo(expectedScale); - expect(lastCall?.[3]).toBe(0); + expect(transform.positionX).toBeCloseTo(expectedX); + expect(transform.positionY).toBeCloseTo(expectedY); + expect(transform.scale).toBeCloseTo(expectedScale); }; -describe('ZoomableImage wheel behavior', () => { +describe('ZoomableImage controlled transform behavior', () => { beforeEach(() => { - mockSetTransform.mockClear(); - mockZoomIn.mockClear(); - mockZoomOut.mockClear(); - // Keep the internal wrapper unavailable by default so tests cover the - // production fallback path where the wheel area owns scroll handling. - mockWrapperComponent = null; - mockContentComponent = null; - mockShouldExposeContentComponent = true; - mockTransformState.scale = 1; - mockTransformState.positionX = 0; - mockTransformState.positionY = 0; + global.ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; + jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + jest + .spyOn(window, 'cancelAnimationFrame') + .mockImplementation(() => undefined); }); - test('keeps zoom centered while the image still fits in the viewport', () => { - const { container } = renderZoomableImage(); + afterEach(() => { + jest.restoreAllMocks(); + }); - const wheelArea = container.firstElementChild as HTMLElement; - const image = screen.getByAltText('test image'); + test('fits a large image to the measured viewport on load', () => { + setupScene({ + viewportSize: { width: 500, height: 400 }, + imageSize: { width: 1000, height: 800 }, + }); - 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 }, - ); + expectCurrentTransform(0, 0, 0.5); + }); - fireEvent.wheel(wheelArea, { + test('keeps zoom centered while the image still fits in the viewport', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 200, height: 100 }, + }); + + fireEvent.wheel(viewport, { deltaY: -100, clientX: 750, clientY: 550, }); - expectLatestTransform(290, 245, 1.1); + expectCurrentTransform(290, 245, 1.1); }); - test('centers and stops at the viewport edge before mouse anchoring begins', () => { - const { wheelArea } = setupWheelScene({ - wrapperSize: { width: 800, height: 600 }, + test('stops at the first viewport edge before mouse anchoring begins', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, imageSize: { width: 760, height: 300 }, }); - fireEvent.wheel(wheelArea, { + fireEvent.wheel(viewport, { deltaY: -100, clientX: 750, clientY: 300, }); - expectLatestTransform(0, 142.10526315789474, 1.0526315789473684); + expectCurrentTransform(0, 142.10526315789474, 1.0526315789473684); }); - test('anchors horizontally after the width has reached the viewport edge', () => { - const { wheelArea } = setupWheelScene({ - wrapperSize: { width: 800, height: 600 }, - imageSize: { width: 760, height: 300 }, + test('stops a newly overflowing axis at the viewport edge before anchoring it', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, }); - markSceneInitialized(wheelArea); - mockTransformState.scale = 1.1; - mockTransformState.positionX = -18; - mockTransformState.positionY = 135; + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); + + expectCurrentTransform(0, -18.518518518518476, 0.8888888888888888); + }); + + test('anchors horizontally only after width has reached the viewport edge', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 760, height: 300 }, + }); - fireEvent.wheel(wheelArea, { + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 750, + clientY: 300, + }); + fireEvent.wheel(viewport, { deltaY: -100, clientX: 750, clientY: 300, }); - expectLatestTransform(-87.81818181818176, 120, 1.2000000000000002); + expectCurrentTransform(-71.25, 127.10526315789474, 1.1526315789473685); }); - test('anchors vertically after the height has reached the viewport edge', () => { - const { wheelArea } = setupWheelScene({ - wrapperSize: { width: 800, height: 600 }, + test('anchors vertically only after height has reached the viewport edge', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, imageSize: { width: 300, height: 560 }, }); - markSceneInitialized(wheelArea); - mockTransformState.scale = 1.1; - mockTransformState.positionX = 235; - mockTransformState.positionY = -8; - - fireEvent.wheel(wheelArea, { + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 300, + clientY: 550, + }); + fireEvent.wheel(viewport, { deltaY: -100, clientX: 300, clientY: 550, }); - expectLatestTransform(220, -58.72727272727275, 1.2000000000000002); + expectCurrentTransform( + 224.28571428571428, + -51.33333333333337, + 1.1714285714285715, + ); }); test('anchors both axes when width and height overflow', () => { - const { wheelArea } = setupWheelScene({ - wrapperSize: { width: 800, height: 600 }, + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, imageSize: { width: 900, height: 700 }, }); - markSceneInitialized(wheelArea); - mockTransformState.scale = 1; - mockTransformState.positionX = -50; - mockTransformState.positionY = -40; - - fireEvent.wheel(wheelArea, { + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); + fireEvent.wheel(viewport, { deltaY: -100, clientX: 700, clientY: 500, }); - expectLatestTransform(-125, -94, 1.1); + expectCurrentTransform(-78.75, -76.85185185185185, 0.9888888888888888); }); - test('recenters the image when zooming back to minimum scale', () => { - const { wheelArea } = setupWheelScene({ - wrapperSize: { width: 800, height: 600 }, + test('recenters when zooming back to minimum scale', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, imageSize: { width: 400, height: 300 }, }); - markSceneInitialized(wheelArea); - mockTransformState.scale = 1.05; - mockTransformState.positionX = -200; - mockTransformState.positionY = -150; - - fireEvent.wheel(wheelArea, { + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); + fireEvent.wheel(viewport, { deltaY: 100, clientX: 700, clientY: 500, }); - expectLatestTransform(200, 150, 1); + expectCurrentTransform(200, 150, 1); }); - test('uses a fit-to-view minimum scale when the image is larger than the measured viewer', () => { - const { wheelArea } = setupWheelScene({ - wrapperSize: { width: 500, height: 400 }, - imageSize: { width: 1000, height: 800 }, + test('panning clamps overflowing axes and keeps fitting axes centered', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 760, height: 300 }, }); - markSceneInitialized(wheelArea); - mockTransformState.scale = 0.55; - mockTransformState.positionX = -80; - mockTransformState.positionY = -60; - - fireEvent.wheel(wheelArea, { - deltaY: 100, - clientX: 450, - clientY: 350, + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 750, + clientY: 300, + }); + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 750, + clientY: 300, + }); + fireEvent.mouseDown(viewport, { + button: 0, + clientX: 300, + clientY: 300, + }); + fireEvent.mouseMove(viewport, { + clientX: 1000, + clientY: 1000, + }); + fireEvent.mouseUp(viewport, { + clientX: 1000, + clientY: 1000, }); - expectLatestTransform(0, 0, 0.5); + expectCurrentTransform(0, 127.10526315789474, 1.1526315789473685); }); - test('starts a production first wheel from fit-to-view when transform state is stale', () => { - mockTransformState.scale = 1; - mockTransformState.positionX = 0; - mockTransformState.positionY = 0; - - const { wheelArea } = setupWheelScene({ - wrapperSize: { width: 500, height: 400 }, + test('starts from the new image fit transform after switching images', () => { + const { viewport, rerender } = setupScene({ + viewportSize: { width: 500, height: 400 }, imageSize: { width: 1000, height: 800 }, + props: { imagePath: '/tmp/photo-a.jpg' }, }); - fireEvent.wheel(wheelArea, { + fireEvent.wheel(viewport, { deltaY: -100, clientX: 450, clientY: 350, }); - expectLatestTransform(-90, -70, 0.6); - }); - - test('retries initial fit until the transform content is ready', () => { - jest.useFakeTimers(); - mockShouldExposeContentComponent = false; - - const { container } = renderZoomableImage(); - - const wheelArea = container.firstElementChild as HTMLElement; - const transformWrapper = screen.getByTestId('transform-wrapper'); - const transformContent = screen.getByTestId('transform-content'); - const image = screen.getByAltText('test image'); - - mockWrapperComponent = transformWrapper as HTMLDivElement; - mockElementRect( - wheelArea, - { width: 500, height: 400, left: 0, top: 0 }, - { clientWidth: 500, clientHeight: 400 }, - ); - mockElementRect( - transformWrapper, - { width: 500, height: 400, left: 0, top: 0 }, - { clientWidth: 500, clientHeight: 400 }, - ); - mockElementRect( - image, - { width: 1000, height: 800, left: 0, top: 0 }, - { clientWidth: 1000, clientHeight: 800 }, + rerender( + , ); - fireEvent.load(image); - - act(() => { - jest.runOnlyPendingTimers(); - }); - expect(mockSetTransform).not.toHaveBeenCalled(); - - mockShouldExposeContentComponent = true; - mockContentComponent = transformContent as HTMLDivElement; + const newImage = screen.getByAltText('test image'); + mockImageDimensions(newImage, { width: 200, height: 100 }); + fireEvent.load(newImage); - act(() => { - jest.runOnlyPendingTimers(); - }); - - expectLatestTransform(0, 0, 0.5); - jest.useRealTimers(); + expectCurrentTransform(150, 150, 1); }); - test('starts from the new image fit transform after switching images', () => { - const { container, rerender } = renderZoomableImage({ - imagePath: '/tmp/photo-a.jpg', + test('rotation recalculates the fit transform', () => { + const { rerender } = setupScene({ + viewportSize: { width: 600, height: 400 }, + imageSize: { width: 1000, height: 500 }, + props: { rotation: 0 }, }); - const wheelArea = container.firstElementChild as HTMLElement; - let transformWrapper = screen.getByTestId('transform-wrapper'); - let image = screen.getByAltText('test image'); + expectCurrentTransform(0, 50, 0.6); - mockWrapperComponent = transformWrapper as HTMLDivElement; - mockElementRect( - wheelArea, - { width: 500, height: 400, left: 0, top: 0 }, - { clientWidth: 500, clientHeight: 400 }, - ); - mockElementRect( - transformWrapper, - { width: 500, height: 400, left: 0, top: 0 }, - { clientWidth: 500, clientHeight: 400 }, - ); - mockElementRect( - image, - { width: 1000, height: 800, left: 0, top: 0 }, - { clientWidth: 1000, clientHeight: 800 }, + rerender( + , ); - mockTransformState.scale = 2; - mockTransformState.positionX = -300; - mockTransformState.positionY = -250; + const image = screen.getByAltText('test image'); + mockImageDimensions(image, { width: 1000, height: 500 }); + fireEvent.load(image); - rerender( + expectCurrentTransform(200, 0, 0.4); + }); + + test('imperative reset returns to fit-to-view', () => { + const imageRef = createRef(); + + render( , ); - transformWrapper = screen.getByTestId('transform-wrapper'); - image = screen.getByAltText('test image'); - mockWrapperComponent = transformWrapper as HTMLDivElement; + const viewport = screen.getByTestId('zoom-viewport'); + const image = screen.getByAltText('test image'); mockElementRect( - transformWrapper, + viewport, { width: 500, height: 400, left: 0, top: 0 }, { clientWidth: 500, clientHeight: 400 }, ); - mockElementRect( - image, - { width: 1000, height: 800, left: 0, top: 0 }, - { clientWidth: 1000, clientHeight: 800 }, - ); - + mockImageDimensions(image, { width: 1000, height: 800 }); fireEvent.load(image); - mockSetTransform.mockClear(); - - fireEvent.wheel(wheelArea, { - deltaY: -100, - clientX: 450, - clientY: 350, - }); - - expectLatestTransform(-90, -70, 0.6); - }); - - test('clamps wheel zoom targets so the image cannot be pulled offscreen', () => { - const { wheelArea } = setupWheelScene({ - wrapperSize: { width: 800, height: 600 }, - imageSize: { width: 1000, height: 800 }, - }); - - markSceneInitialized(wheelArea); - mockTransformState.scale = 2; - mockTransformState.positionX = 2000; - mockTransformState.positionY = 2000; - fireEvent.wheel(wheelArea, { - deltaY: -100, - clientX: 790, - clientY: 590, + act(() => { + imageRef.current?.zoomIn(); + imageRef.current?.reset(); }); - expectLatestTransform(0, 0, 2.1); + expectCurrentTransform(0, 0, 0.5); }); }); From cda5a9bb2ed226f2279d24564320bc1a85f76a02 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Tue, 2 Jun 2026 01:34:12 +0530 Subject: [PATCH 12/16] Smooth zoom recenter transition --- .../src/components/Media/ZoomableImage.tsx | 68 ++++++++++++++++--- .../Media/__tests__/ZoomableImage.test.tsx | 20 ++++++ 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index 2a93e5749..3be768b16 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -85,6 +85,47 @@ const getAxisPosition = ( : centeredPosition; }; +const getOverflowRatio = (viewportSize: number, scaledSize: number) => { + if (!viewportSize || scaledSize <= viewportSize) return 0; + + return clamp((scaledSize - viewportSize) / viewportSize, 0, 1); +}; + +const interpolate = (from: number, to: number, ratio: number) => + from + (to - from) * ratio; + +const getSmoothedWheelAxisPosition = ({ + anchoredPosition, + viewportSize, + scaledSize, + isOverflowingAxis, + shouldAnchor, + isZoomingOut, +}: { + anchoredPosition: number; + viewportSize: number; + scaledSize: number; + isOverflowingAxis: boolean; + shouldAnchor: boolean; + isZoomingOut: boolean; +}) => { + const centeredPosition = getCenteredAxisPosition(viewportSize, scaledSize); + + if (!isOverflowingAxis || !shouldAnchor) return centeredPosition; + + const clampedAnchor = clampOverflowAxisPosition( + anchoredPosition, + viewportSize, + scaledSize, + ); + + if (!isZoomingOut) return clampedAnchor; + + const anchorRatio = getOverflowRatio(viewportSize, scaledSize); + + return interpolate(centeredPosition, clampedAnchor, anchorRatio); +}; + const axisTouchesViewport = (scaledSize: number, viewportSize: number) => scaledSize >= viewportSize - SCALE_EPSILON; @@ -463,23 +504,30 @@ export const ZoomableImage = forwardRef( const anchoredY = mouseViewportY - (mouseViewportY - currentTransform.positionY) * ratio; - const centeredX = getCenteredAxisPosition( - geometry.viewportWidth, - scaledDimensions.width, - ); - const centeredY = getCenteredAxisPosition( - geometry.viewportHeight, - scaledDimensions.height, - ); const shouldAnchorX = isOverImage && currentTouchesViewport.width && newOverflow.width; const shouldAnchorY = isOverImage && currentTouchesViewport.height && newOverflow.height; + const isZoomingOut = scale < currentTransform.scale; const didApply = applyTransform( { - positionX: shouldAnchorX ? anchoredX : centeredX, - positionY: shouldAnchorY ? anchoredY : centeredY, + positionX: getSmoothedWheelAxisPosition({ + anchoredPosition: anchoredX, + viewportSize: geometry.viewportWidth, + scaledSize: scaledDimensions.width, + isOverflowingAxis: newOverflow.width, + shouldAnchor: shouldAnchorX, + isZoomingOut, + }), + positionY: getSmoothedWheelAxisPosition({ + anchoredPosition: anchoredY, + viewportSize: geometry.viewportHeight, + scaledSize: scaledDimensions.height, + isOverflowingAxis: newOverflow.height, + shouldAnchor: shouldAnchorY, + isZoomingOut, + }), scale, }, geometry, diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index 03992b7c8..4a327f800 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -305,6 +305,26 @@ describe('ZoomableImage controlled transform behavior', () => { expectCurrentTransform(200, 150, 1); }); + test('blends overflowing axes back toward center while zooming out', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 1600, height: 1200 }, + }); + + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); + fireEvent.wheel(viewport, { + deltaY: 50, + clientX: 700, + clientY: 500, + }); + + expectCurrentTransform(-43, -32, 0.55); + }); + test('panning clamps overflowing axes and keeps fitting axes centered', () => { const { viewport } = setupScene({ viewportSize: { width: 800, height: 600 }, From dbcfbb514b1642a6a5732d4592c64b9530b46926 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Tue, 2 Jun 2026 15:24:12 +0530 Subject: [PATCH 13/16] Address zoom component review --- frontend/src/components/Media/ImageViewer.tsx | 2 + .../src/components/Media/ZoomableImage.tsx | 768 +----------------- .../Media/__tests__/ZoomableImage.test.tsx | 97 ++- frontend/src/hooks/useZoomTransform.ts | 496 +++++++++++ .../src/utils/__tests__/zoomUtils.test.ts | 195 +++++ frontend/src/utils/zoomUtils.ts | 315 +++++++ 6 files changed, 1126 insertions(+), 747 deletions(-) create mode 100644 frontend/src/hooks/useZoomTransform.ts create mode 100644 frontend/src/utils/__tests__/zoomUtils.test.ts create mode 100644 frontend/src/utils/zoomUtils.ts diff --git a/frontend/src/components/Media/ImageViewer.tsx b/frontend/src/components/Media/ImageViewer.tsx index fa878250d..84e003d11 100644 --- a/frontend/src/components/Media/ImageViewer.tsx +++ b/frontend/src/components/Media/ImageViewer.tsx @@ -35,3 +35,5 @@ export const ImageViewer = forwardRef( ); }, ); + +ImageViewer.displayName = 'ImageViewer'; diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index 3be768b16..a7947b4c7 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -1,46 +1,6 @@ -import { - useRef, - useImperativeHandle, - forwardRef, - useEffect, - useState, - useCallback, - type MouseEvent as ReactMouseEvent, - type PointerEvent as ReactPointerEvent, -} from 'react'; +import { useImperativeHandle, forwardRef } from 'react'; import { convertFileSrc } from '@tauri-apps/api/core'; - -const ZOOM_FACTOR = 0.001; -const LINE_HEIGHT_MULTIPLIER = 33; -const MAX_SCALE = 8; -const MIN_SCALE = 1; -const SCALE_EPSILON = 0.0001; -const MAX_FIT_RETRY_FRAMES = 12; -const CONTROL_BUTTON_ZOOM_STEP = 0.5; - -type Size = { - width: number; - height: number; -}; - -type OverflowState = { - width: boolean; - height: boolean; -}; - -type TransformState = { - positionX: number; - positionY: number; - scale: number; -}; - -type Geometry = { - viewportWidth: number; - viewportHeight: number; - rawDimensions: Size; - baseDimensions: Size; - minScale: number; -}; +import { useZoomTransform } from '@/hooks/useZoomTransform'; interface ZoomableImageProps { imagePath: string; @@ -55,702 +15,32 @@ export interface ZoomableImageRef { reset: () => void; } -const getCenteredAxisPosition = (viewportSize: number, scaledSize: number) => - (viewportSize - scaledSize) / 2; - -const clamp = (value: number, min: number, max: number) => - Math.min(Math.max(value, min), max); - -const clampOverflowAxisPosition = ( - position: number, - viewportSize: number, - scaledSize: number, -) => { - const minPosition = viewportSize - scaledSize; - const maxPosition = 0; - - return clamp(position, minPosition, maxPosition); -}; - -const getAxisPosition = ( - anchoredPosition: number, - viewportSize: number, - scaledSize: number, - isOverflowingAxis: boolean, -) => { - const centeredPosition = getCenteredAxisPosition(viewportSize, scaledSize); - - return isOverflowingAxis - ? clampOverflowAxisPosition(anchoredPosition, viewportSize, scaledSize) - : centeredPosition; -}; - -const getOverflowRatio = (viewportSize: number, scaledSize: number) => { - if (!viewportSize || scaledSize <= viewportSize) return 0; - - return clamp((scaledSize - viewportSize) / viewportSize, 0, 1); -}; - -const interpolate = (from: number, to: number, ratio: number) => - from + (to - from) * ratio; - -const getSmoothedWheelAxisPosition = ({ - anchoredPosition, - viewportSize, - scaledSize, - isOverflowingAxis, - shouldAnchor, - isZoomingOut, -}: { - anchoredPosition: number; - viewportSize: number; - scaledSize: number; - isOverflowingAxis: boolean; - shouldAnchor: boolean; - isZoomingOut: boolean; -}) => { - const centeredPosition = getCenteredAxisPosition(viewportSize, scaledSize); - - if (!isOverflowingAxis || !shouldAnchor) return centeredPosition; - - const clampedAnchor = clampOverflowAxisPosition( - anchoredPosition, - viewportSize, - scaledSize, - ); - - if (!isZoomingOut) return clampedAnchor; - - const anchorRatio = getOverflowRatio(viewportSize, scaledSize); - - return interpolate(centeredPosition, clampedAnchor, anchorRatio); -}; - -const axisTouchesViewport = (scaledSize: number, viewportSize: number) => - scaledSize >= viewportSize - SCALE_EPSILON; - -const getEffectiveDimensions = ( - width: number, - height: number, - rotation: number, -): Size => { - const normalizedRotation = ((rotation % 360) + 360) % 360; - const isRotated90or270 = - normalizedRotation === 90 || normalizedRotation === 270; - - return isRotated90or270 - ? { width: height, height: width } - : { width, height }; -}; - -const getMinimumScale = ( - baseDimensions: Size, - viewportWidth: number, - viewportHeight: number, -) => { - if ( - !baseDimensions.width || - !baseDimensions.height || - !viewportWidth || - !viewportHeight - ) { - return MIN_SCALE; - } - - return Math.min( - MIN_SCALE, - viewportWidth / baseDimensions.width, - viewportHeight / baseDimensions.height, - ); -}; - -const getNextViewportEdgeScale = ( - baseDimensions: Size, - viewportWidth: number, - viewportHeight: number, - currentTouchesViewport: OverflowState, -) => { - const edgeScales = []; - - if (!currentTouchesViewport.width) { - edgeScales.push(viewportWidth / baseDimensions.width); - } - - if (!currentTouchesViewport.height) { - edgeScales.push(viewportHeight / baseDimensions.height); - } - - if (!edgeScales.length) return null; - - return Math.min( - MAX_SCALE, - Math.max( - getMinimumScale(baseDimensions, viewportWidth, viewportHeight), - Math.min(...edgeScales), - ), - ); -}; - -const getScaledDimensions = (baseDimensions: Size, scale: number): Size => ({ - width: baseDimensions.width * scale, - height: baseDimensions.height * scale, -}); - -const getOverflowState = ( - baseDimensions: Size, - scale: number, - viewportWidth: number, - viewportHeight: number, -): OverflowState => { - const scaledDimensions = getScaledDimensions(baseDimensions, scale); - - return { - width: scaledDimensions.width > viewportWidth, - height: scaledDimensions.height > viewportHeight, - }; -}; - -const getFitTransform = ({ - baseDimensions, - viewportWidth, - viewportHeight, - minScale, -}: Geometry): TransformState => { - const scaledDimensions = getScaledDimensions(baseDimensions, minScale); - - return { - positionX: getCenteredAxisPosition(viewportWidth, scaledDimensions.width), - positionY: getCenteredAxisPosition(viewportHeight, scaledDimensions.height), - scale: minScale, - }; -}; - -const getElementSize = (element: HTMLElement) => { - const rect = element.getBoundingClientRect(); - - return { - width: rect.width || element.clientWidth, - height: rect.height || element.clientHeight, - left: rect.left, - top: rect.top, - }; -}; - export const ZoomableImage = forwardRef( ({ imagePath, alt, rotation, resetSignal }, ref) => { - const viewportRef = useRef(null); - const imageRef = useRef(null); - const transformStateRef = useRef({ - positionX: 0, - positionY: 0, - scale: MIN_SCALE, - }); - const rawDimensionsRef = useRef(null); - const isFitInitializedRef = useRef(false); - const hasUserInteractedRef = useRef(false); - const fitFrameRef = useRef(null); - const fitRetryCountRef = useRef(0); - const dragStateRef = useRef<{ - pointerId: number | 'mouse'; - startClientX: number; - startClientY: number; - startTransform: TransformState; - } | null>(null); - const rotationRef = useRef(rotation); - const [transformState, setTransformState] = useState( - transformStateRef.current, - ); - const [rawDimensions, setRawDimensions] = useState(null); - const [isOverflowing, setIsOverflowing] = useState(false); - const [isPanning, setIsPanning] = useState(false); - - const setRawImageDimensions = useCallback((dimensions: Size | null) => { - rawDimensionsRef.current = dimensions; - setRawDimensions(dimensions); - }, []); - - const readRawDimensions = useCallback((): Size | null => { - const img = imageRef.current; - const fallbackDimensions = rawDimensionsRef.current; - - const width = - img?.naturalWidth || img?.clientWidth || fallbackDimensions?.width || 0; - const height = - img?.naturalHeight || - img?.clientHeight || - fallbackDimensions?.height || - 0; - - if (!width || !height) return null; - - return { width, height }; - }, []); - - const getGeometry = useCallback((): Geometry | null => { - const viewport = viewportRef.current; - const rawImageDimensions = readRawDimensions(); - - if (!viewport || !rawImageDimensions) return null; - - const viewportSize = getElementSize(viewport); - - if (!viewportSize.width || !viewportSize.height) return null; - - const baseDimensions = getEffectiveDimensions( - rawImageDimensions.width, - rawImageDimensions.height, - rotationRef.current, - ); - const minScale = getMinimumScale( - baseDimensions, - viewportSize.width, - viewportSize.height, - ); - - return { - viewportWidth: viewportSize.width, - viewportHeight: viewportSize.height, - rawDimensions: rawImageDimensions, - baseDimensions, - minScale, - }; - }, [readRawDimensions]); - - const setControlledTransform = useCallback( - (nextTransform: TransformState) => { - transformStateRef.current = nextTransform; - setTransformState(nextTransform); - }, - [], - ); - - const applyTransform = useCallback( - (nextTransform: TransformState, geometry = getGeometry()) => { - if (!geometry) return false; - - const scale = clamp(nextTransform.scale, geometry.minScale, MAX_SCALE); - const scaledDimensions = getScaledDimensions( - geometry.baseDimensions, - scale, - ); - const overflow = getOverflowState( - geometry.baseDimensions, - scale, - geometry.viewportWidth, - geometry.viewportHeight, - ); - const shouldRecenter = - scale <= geometry.minScale + SCALE_EPSILON || - (!overflow.width && !overflow.height); - - const positionX = shouldRecenter - ? getCenteredAxisPosition( - geometry.viewportWidth, - scaledDimensions.width, - ) - : getAxisPosition( - nextTransform.positionX, - geometry.viewportWidth, - scaledDimensions.width, - overflow.width, - ); - const positionY = shouldRecenter - ? getCenteredAxisPosition( - geometry.viewportHeight, - scaledDimensions.height, - ) - : getAxisPosition( - nextTransform.positionY, - geometry.viewportHeight, - scaledDimensions.height, - overflow.height, - ); - - setIsOverflowing(overflow.width || overflow.height); - setControlledTransform({ positionX, positionY, scale }); - return true; - }, - [getGeometry, setControlledTransform], - ); - - const applyFitTransform = useCallback(() => { - const geometry = getGeometry(); - - if (!geometry) return false; - - const didApply = applyTransform(getFitTransform(geometry), geometry); - - if (didApply) { - isFitInitializedRef.current = true; - hasUserInteractedRef.current = false; - fitRetryCountRef.current = 0; - } - - return didApply; - }, [applyTransform, getGeometry]); - - const clearScheduledFit = useCallback(() => { - if (fitFrameRef.current === null) return; - - if (typeof window.cancelAnimationFrame === 'function') { - window.cancelAnimationFrame(fitFrameRef.current); - } else { - window.clearTimeout(fitFrameRef.current); - } - fitFrameRef.current = null; - }, []); - - const scheduleFitTransform = useCallback(() => { - clearScheduledFit(); - - const scheduleFrame: (callback: FrameRequestCallback) => number = - typeof window.requestAnimationFrame === 'function' - ? window.requestAnimationFrame.bind(window) - : (callback) => - window.setTimeout(() => callback(performance.now()), 0); - - fitFrameRef.current = scheduleFrame(() => { - fitFrameRef.current = null; - const applied = applyFitTransform(); - - if (!applied && fitRetryCountRef.current < MAX_FIT_RETRY_FRAMES) { - fitRetryCountRef.current += 1; - scheduleFitTransform(); - } - }); - }, [applyFitTransform, clearScheduledFit]); - - const resetToFit = useCallback(() => { - isFitInitializedRef.current = false; - hasUserInteractedRef.current = false; - fitRetryCountRef.current = 0; - scheduleFitTransform(); - }, [scheduleFitTransform]); - - const zoomBy = useCallback( - (zoomChange: number, clientX?: number, clientY?: number) => { - const geometry = getGeometry(); - const viewport = viewportRef.current; - - if (!geometry || !viewport) return false; - - const viewportSize = getElementSize(viewport); - const fitTransform = getFitTransform(geometry); - const currentTransform = isFitInitializedRef.current - ? transformStateRef.current - : fitTransform; - const desiredScale = clamp( - currentTransform.scale + zoomChange, - geometry.minScale, - MAX_SCALE, - ); - const currentDimensions = getScaledDimensions( - geometry.baseDimensions, - currentTransform.scale, - ); - const currentTouchesViewport = { - width: axisTouchesViewport( - currentDimensions.width, - geometry.viewportWidth, - ), - height: axisTouchesViewport( - currentDimensions.height, - geometry.viewportHeight, - ), - }; - const isZoomingIn = desiredScale > currentTransform.scale; - const nextViewportEdgeScale = getNextViewportEdgeScale( - geometry.baseDimensions, - geometry.viewportWidth, - geometry.viewportHeight, - currentTouchesViewport, - ); - const scale = - isZoomingIn && - nextViewportEdgeScale !== null && - desiredScale > nextViewportEdgeScale - ? nextViewportEdgeScale - : desiredScale; - const scaledDimensions = getScaledDimensions( - geometry.baseDimensions, - scale, - ); - const newOverflow = getOverflowState( - geometry.baseDimensions, - scale, - geometry.viewportWidth, - geometry.viewportHeight, - ); - const mouseViewportX = - clientX === undefined - ? geometry.viewportWidth / 2 - : clientX - viewportSize.left; - const mouseViewportY = - clientY === undefined - ? geometry.viewportHeight / 2 - : clientY - viewportSize.top; - const isOverImage = - mouseViewportX >= currentTransform.positionX && - mouseViewportX <= - currentTransform.positionX + currentDimensions.width && - mouseViewportY >= currentTransform.positionY && - mouseViewportY <= - currentTransform.positionY + currentDimensions.height; - const ratio = - currentTransform.scale > 0 ? scale / currentTransform.scale : 1; - const anchoredX = - mouseViewportX - - (mouseViewportX - currentTransform.positionX) * ratio; - const anchoredY = - mouseViewportY - - (mouseViewportY - currentTransform.positionY) * ratio; - const shouldAnchorX = - isOverImage && currentTouchesViewport.width && newOverflow.width; - const shouldAnchorY = - isOverImage && currentTouchesViewport.height && newOverflow.height; - const isZoomingOut = scale < currentTransform.scale; - - const didApply = applyTransform( - { - positionX: getSmoothedWheelAxisPosition({ - anchoredPosition: anchoredX, - viewportSize: geometry.viewportWidth, - scaledSize: scaledDimensions.width, - isOverflowingAxis: newOverflow.width, - shouldAnchor: shouldAnchorX, - isZoomingOut, - }), - positionY: getSmoothedWheelAxisPosition({ - anchoredPosition: anchoredY, - viewportSize: geometry.viewportHeight, - scaledSize: scaledDimensions.height, - isOverflowingAxis: newOverflow.height, - shouldAnchor: shouldAnchorY, - isZoomingOut, - }), - scale, - }, - geometry, - ); - - if (didApply) { - isFitInitializedRef.current = true; - hasUserInteractedRef.current = true; - } - - return didApply; - }, - [applyTransform, getGeometry], - ); + const { + viewportRef, + imageRef, + transformState, + rawDimensions, + contentDimensions, + imageOffset, + cursor, + handleImageLoad, + handlePointerDown, + handlePointerMove, + handlePointerEnd, + handlePointerLeave, + zoomIn, + zoomOut, + reset, + } = useZoomTransform({ imagePath, rotation, resetSignal }); useImperativeHandle(ref, () => ({ - zoomIn: () => { - zoomBy(CONTROL_BUTTON_ZOOM_STEP); - }, - zoomOut: () => { - zoomBy(-CONTROL_BUTTON_ZOOM_STEP); - }, - reset: () => resetToFit(), + zoomIn, + zoomOut, + reset, })); - useEffect(() => { - rotationRef.current = rotation; - resetToFit(); - }, [rotation, resetToFit]); - - useEffect(() => { - resetToFit(); - }, [resetSignal, resetToFit]); - - useEffect(() => { - setIsOverflowing(false); - setRawImageDimensions(null); - resetToFit(); - }, [imagePath, resetToFit, setRawImageDimensions]); - - useEffect(() => { - const viewport = viewportRef.current; - if (!viewport) return; - - const handleResize = () => { - if (hasUserInteractedRef.current) { - applyTransform(transformStateRef.current); - } else { - resetToFit(); - } - }; - - let resizeObserver: ResizeObserver | null = null; - - if (typeof ResizeObserver === 'function') { - resizeObserver = new ResizeObserver(handleResize); - resizeObserver.observe(viewport); - } - - window.addEventListener('resize', handleResize); - - return () => { - resizeObserver?.disconnect(); - window.removeEventListener('resize', handleResize); - }; - }, [applyTransform, resetToFit]); - - useEffect(() => { - const viewport = viewportRef.current; - if (!viewport) return; - - const handleWheel = (e: WheelEvent) => { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - const isLineMode = e.deltaMode === 1; - const multiplier = isLineMode ? LINE_HEIGHT_MULTIPLIER : 1; - const zoomChange = -e.deltaY * multiplier * ZOOM_FACTOR; - - zoomBy(zoomChange, e.clientX, e.clientY); - }; - - viewport.addEventListener('wheel', handleWheel, { - passive: false, - capture: true, - }); - - return () => { - viewport.removeEventListener('wheel', handleWheel, true); - }; - }, [zoomBy]); - - useEffect( - () => () => { - clearScheduledFit(); - }, - [clearScheduledFit], - ); - - const handleImageLoad = useCallback(() => { - const dimensions = readRawDimensions(); - - if (dimensions) { - setRawImageDimensions(dimensions); - } - - resetToFit(); - }, [readRawDimensions, resetToFit, setRawImageDimensions]); - - const startDrag = useCallback( - (pointerId: number | 'mouse', clientX: number, clientY: number) => { - const geometry = getGeometry(); - if (!geometry || dragStateRef.current) return false; - - const overflow = getOverflowState( - geometry.baseDimensions, - transformStateRef.current.scale, - geometry.viewportWidth, - geometry.viewportHeight, - ); - - if (!overflow.width && !overflow.height) return false; - - dragStateRef.current = { - pointerId, - startClientX: clientX, - startClientY: clientY, - startTransform: transformStateRef.current, - }; - hasUserInteractedRef.current = true; - setIsPanning(true); - return true; - }, - [getGeometry], - ); - - const updateDrag = useCallback( - (pointerId: number | 'mouse', clientX: number, clientY: number) => { - const dragState = dragStateRef.current; - if (!dragState || dragState.pointerId !== pointerId) return false; - - const deltaX = clientX - dragState.startClientX; - const deltaY = clientY - dragState.startClientY; - - applyTransform({ - positionX: dragState.startTransform.positionX + deltaX, - positionY: dragState.startTransform.positionY + deltaY, - scale: dragState.startTransform.scale, - }); - return true; - }, - [applyTransform], - ); - - const endDrag = useCallback((pointerId: number | 'mouse') => { - const dragState = dragStateRef.current; - if (!dragState || dragState.pointerId !== pointerId) return false; - - dragStateRef.current = null; - setIsPanning(false); - return true; - }, []); - - const handlePointerDown = (e: ReactPointerEvent) => { - if (e.button !== 0) return; - - const didStartDrag = startDrag(e.pointerId, e.clientX, e.clientY); - if (!didStartDrag) return; - - e.currentTarget.setPointerCapture?.(e.pointerId); - e.preventDefault(); - }; - - const handlePointerMove = (e: ReactPointerEvent) => { - const didUpdateDrag = updateDrag(e.pointerId, e.clientX, e.clientY); - if (!didUpdateDrag) return; - - e.preventDefault(); - }; - - const handlePointerEnd = (e: ReactPointerEvent) => { - const didEndDrag = endDrag(e.pointerId); - if (!didEndDrag) return; - - e.currentTarget.releasePointerCapture?.(e.pointerId); - }; - - const handleMouseDown = (e: ReactMouseEvent) => { - if (e.button !== 0) return; - - const didStartDrag = startDrag('mouse', e.clientX, e.clientY); - if (didStartDrag) { - e.preventDefault(); - } - }; - - const handleMouseMove = (e: ReactMouseEvent) => { - const didUpdateDrag = updateDrag('mouse', e.clientX, e.clientY); - if (didUpdateDrag) { - e.preventDefault(); - } - }; - - const handleMouseEnd = () => { - endDrag('mouse'); - }; - - const contentDimensions = rawDimensions - ? getEffectiveDimensions( - rawDimensions.width, - rawDimensions.height, - rotation, - ) - : null; - const imageOffset = - rawDimensions && contentDimensions - ? { - left: (contentDimensions.width - rawDimensions.width) / 2, - top: (contentDimensions.height - rawDimensions.height) / 2, - } - : { left: 0, top: 0 }; - return (
( onPointerMove={handlePointerMove} onPointerUp={handlePointerEnd} onPointerCancel={handlePointerEnd} - onMouseDown={handleMouseDown} - onMouseMove={handleMouseMove} - onMouseUp={handleMouseEnd} - onMouseLeave={handleMouseEnd} + onPointerLeave={handlePointerLeave} + onLostPointerCapture={handlePointerEnd} style={{ touchAction: 'none', - cursor: isPanning ? 'grabbing' : isOverflowing ? 'move' : 'default', + cursor, }} >
( : 'fit-content', transform: `translate3d(${transformState.positionX}px, ${transformState.positionY}px, 0) scale(${transformState.scale})`, transformOrigin: '0 0', - cursor: isPanning ? 'grabbing' : isOverflowing ? 'move' : 'default', willChange: 'transform', }} > @@ -810,11 +97,6 @@ export const ZoomableImage = forwardRef( zIndex: 50, transform: `rotate(${rotation}deg)`, transformOrigin: 'center center', - cursor: isPanning - ? 'grabbing' - : isOverflowing - ? 'move' - : 'default', pointerEvents: 'none', }} /> diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index 4a327f800..c0b24de07 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -50,7 +50,7 @@ const mockElementRect = ( const mockImageDimensions = ( image: HTMLElement, - dimensions: { width: number; height: number }, + dimensions: { width: number; height: number; complete?: boolean }, ) => { Object.defineProperty(image, 'naturalWidth', { configurable: true, @@ -68,6 +68,10 @@ const mockImageDimensions = ( configurable: true, value: dimensions.height, }); + Object.defineProperty(image, 'complete', { + configurable: true, + value: dimensions.complete ?? true, + }); }; const renderZoomableImage = ( @@ -148,6 +152,23 @@ const expectCurrentTransform = ( expect(transform.scale).toBeCloseTo(expectedScale); }; +const firePointerEvent = ( + element: Element, + type: string, + properties: Record = {}, +) => { + const event = new Event(type, { bubbles: true, cancelable: true }); + + Object.entries(properties).forEach(([key, value]) => { + Object.defineProperty(event, key, { + configurable: true, + value, + }); + }); + + fireEvent(element, event); +}; + describe('ZoomableImage controlled transform behavior', () => { beforeEach(() => { global.ResizeObserver = @@ -341,16 +362,20 @@ describe('ZoomableImage controlled transform behavior', () => { clientX: 750, clientY: 300, }); - fireEvent.mouseDown(viewport, { + firePointerEvent(viewport, 'pointerdown', { button: 0, + buttons: 1, + pointerId: 1, clientX: 300, clientY: 300, }); - fireEvent.mouseMove(viewport, { + firePointerEvent(viewport, 'pointermove', { + pointerId: 1, clientX: 1000, clientY: 1000, }); - fireEvent.mouseUp(viewport, { + firePointerEvent(viewport, 'pointerup', { + pointerId: 1, clientX: 1000, clientY: 1000, }); @@ -358,6 +383,70 @@ describe('ZoomableImage controlled transform behavior', () => { expectCurrentTransform(0, 127.10526315789474, 1.1526315789473685); }); + test('cleans up panning on pointer leave when pointer capture is unavailable', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); + firePointerEvent(viewport, 'pointerdown', { + button: 0, + buttons: 1, + pointerId: 5, + clientX: 300, + clientY: 300, + }); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'pointerout', { + pointerId: 5, + clientX: 900, + clientY: 700, + }); + + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); + }); + + test('does not use client dimensions before the image natural size is ready', () => { + renderZoomableImage(); + + const viewport = screen.getByTestId('zoom-viewport'); + const content = screen.getByTestId('zoom-content'); + const image = screen.getByAltText('test image'); + + mockElementRect( + viewport, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + mockImageDimensions(image, { + width: 0, + height: 0, + complete: false, + }); + Object.defineProperty(image, 'clientWidth', { + configurable: true, + value: 1000, + }); + Object.defineProperty(image, 'clientHeight', { + configurable: true, + value: 800, + }); + + fireEvent.load(image); + + expect(content.style.width).not.toBe('1000px'); + expect(content.style.height).not.toBe('800px'); + expect(image.style.width).toBe(''); + expect(image.style.height).toBe(''); + }); + test('starts from the new image fit transform after switching images', () => { const { viewport, rerender } = setupScene({ viewportSize: { width: 500, height: 400 }, diff --git a/frontend/src/hooks/useZoomTransform.ts b/frontend/src/hooks/useZoomTransform.ts new file mode 100644 index 000000000..570dedbb9 --- /dev/null +++ b/frontend/src/hooks/useZoomTransform.ts @@ -0,0 +1,496 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from 'react'; +import { + CONTROL_BUTTON_ZOOM_STEP, + LINE_HEIGHT_MULTIPLIER, + MAX_FIT_RETRY_FRAMES, + MAX_SCALE, + MIN_SCALE, + SCALE_EPSILON, + ZOOM_FACTOR, + clamp, + computeZoomTransform, + getAxisPosition, + getCenteredAxisPosition, + getEffectiveDimensions, + getElementSize, + getFitTransform, + getMinimumScale, + getOverflowState, + getScaledDimensions, + type Geometry, + type Size, + type TransformState, +} from '@/utils/zoomUtils'; + +type UseZoomTransformParams = { + imagePath: string; + rotation: number; + resetSignal?: number; +}; + +type DragState = { + pointerId: number; + startClientX: number; + startClientY: number; + startTransform: TransformState; +}; + +export const useZoomTransform = ({ + imagePath, + rotation, + resetSignal, +}: UseZoomTransformParams) => { + const viewportRef = useRef(null); + const imageRef = useRef(null); + const transformStateRef = useRef({ + positionX: 0, + positionY: 0, + scale: MIN_SCALE, + }); + const rawDimensionsRef = useRef(null); + const isFitInitializedRef = useRef(false); + const hasUserInteractedRef = useRef(false); + const fitFrameRef = useRef(null); + const fitRetryCountRef = useRef(0); + const dragStateRef = useRef(null); + const rotationRef = useRef(rotation); + const [transformState, setTransformState] = useState( + transformStateRef.current, + ); + const [rawDimensions, setRawDimensions] = useState(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const [isPanning, setIsPanning] = useState(false); + + const setRawImageDimensions = useCallback((dimensions: Size | null) => { + rawDimensionsRef.current = dimensions; + setRawDimensions(dimensions); + }, []); + + const readRawDimensions = useCallback((): Size | null => { + const img = imageRef.current; + const fallbackDimensions = rawDimensionsRef.current; + + if (img?.naturalWidth && img.naturalHeight) { + return { width: img.naturalWidth, height: img.naturalHeight }; + } + + if (fallbackDimensions?.width && fallbackDimensions.height) { + return fallbackDimensions; + } + + if (img?.complete && img.clientWidth && img.clientHeight) { + return { width: img.clientWidth, height: img.clientHeight }; + } + + return null; + }, []); + + const getGeometry = useCallback((): Geometry | null => { + const viewport = viewportRef.current; + const rawImageDimensions = readRawDimensions(); + + if (!viewport || !rawImageDimensions) return null; + + const viewportSize = getElementSize(viewport); + + if (!viewportSize.width || !viewportSize.height) return null; + + const baseDimensions = getEffectiveDimensions( + rawImageDimensions.width, + rawImageDimensions.height, + rotationRef.current, + ); + const minScale = getMinimumScale( + baseDimensions, + viewportSize.width, + viewportSize.height, + ); + + return { + viewportWidth: viewportSize.width, + viewportHeight: viewportSize.height, + viewportLeft: viewportSize.left, + viewportTop: viewportSize.top, + rawDimensions: rawImageDimensions, + baseDimensions, + minScale, + }; + }, [readRawDimensions]); + + const setControlledTransform = useCallback( + (nextTransform: TransformState) => { + transformStateRef.current = nextTransform; + setTransformState(nextTransform); + }, + [], + ); + + const applyTransform = useCallback( + (nextTransform: TransformState, geometry = getGeometry()) => { + if (!geometry) return false; + + const scale = clamp(nextTransform.scale, geometry.minScale, MAX_SCALE); + const scaledDimensions = getScaledDimensions( + geometry.baseDimensions, + scale, + ); + const overflow = getOverflowState( + geometry.baseDimensions, + scale, + geometry.viewportWidth, + geometry.viewportHeight, + ); + const shouldRecenter = + scale <= geometry.minScale + SCALE_EPSILON || + (!overflow.width && !overflow.height); + + const positionX = shouldRecenter + ? getCenteredAxisPosition( + geometry.viewportWidth, + scaledDimensions.width, + ) + : getAxisPosition( + nextTransform.positionX, + geometry.viewportWidth, + scaledDimensions.width, + overflow.width, + ); + const positionY = shouldRecenter + ? getCenteredAxisPosition( + geometry.viewportHeight, + scaledDimensions.height, + ) + : getAxisPosition( + nextTransform.positionY, + geometry.viewportHeight, + scaledDimensions.height, + overflow.height, + ); + + setIsOverflowing(overflow.width || overflow.height); + setControlledTransform({ positionX, positionY, scale }); + return true; + }, + [getGeometry, setControlledTransform], + ); + + const applyFitTransform = useCallback(() => { + const geometry = getGeometry(); + + if (!geometry) return false; + + const didApply = applyTransform(getFitTransform(geometry), geometry); + + if (didApply) { + isFitInitializedRef.current = true; + hasUserInteractedRef.current = false; + fitRetryCountRef.current = 0; + } + + return didApply; + }, [applyTransform, getGeometry]); + + const clearScheduledFit = useCallback((resetRetryCount = true) => { + if (resetRetryCount) { + fitRetryCountRef.current = 0; + } + + if (fitFrameRef.current === null) return; + + if (typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(fitFrameRef.current); + } else { + window.clearTimeout(fitFrameRef.current); + } + fitFrameRef.current = null; + }, []); + + const scheduleFitTransform = useCallback( + (resetRetryCount = true) => { + clearScheduledFit(resetRetryCount); + + const scheduleFrame: (callback: FrameRequestCallback) => number = + typeof window.requestAnimationFrame === 'function' + ? window.requestAnimationFrame.bind(window) + : (callback) => + window.setTimeout(() => callback(performance.now()), 0); + + fitFrameRef.current = scheduleFrame(() => { + fitFrameRef.current = null; + const applied = applyFitTransform(); + + if (!applied && fitRetryCountRef.current < MAX_FIT_RETRY_FRAMES) { + fitRetryCountRef.current += 1; + scheduleFitTransform(false); + } + }); + }, + [applyFitTransform, clearScheduledFit], + ); + + const resetToFit = useCallback(() => { + isFitInitializedRef.current = false; + hasUserInteractedRef.current = false; + fitRetryCountRef.current = 0; + scheduleFitTransform(false); + }, [scheduleFitTransform]); + + const zoomBy = useCallback( + (zoomChange: number, clientX?: number, clientY?: number) => { + const geometry = getGeometry(); + + if (!geometry) return false; + + const currentTransform = isFitInitializedRef.current + ? transformStateRef.current + : getFitTransform(geometry); + const didApply = applyTransform( + computeZoomTransform({ + geometry, + currentTransform, + zoomChange, + clientX, + clientY, + }), + geometry, + ); + + if (didApply) { + isFitInitializedRef.current = true; + hasUserInteractedRef.current = true; + } + + return didApply; + }, + [applyTransform, getGeometry], + ); + + useEffect(() => { + rotationRef.current = rotation; + resetToFit(); + }, [rotation, resetToFit]); + + useEffect(() => { + resetToFit(); + }, [resetSignal, resetToFit]); + + useEffect(() => { + setIsOverflowing(false); + setRawImageDimensions(null); + resetToFit(); + }, [imagePath, resetToFit, setRawImageDimensions]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const handleResize = () => { + if (hasUserInteractedRef.current) { + applyTransform(transformStateRef.current); + } else { + resetToFit(); + } + }; + + let resizeObserver: ResizeObserver | null = null; + + if (typeof ResizeObserver === 'function') { + resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(viewport); + } + + window.addEventListener('resize', handleResize); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', handleResize); + }; + }, [applyTransform, resetToFit]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + const isLineMode = e.deltaMode === 1; + const multiplier = isLineMode ? LINE_HEIGHT_MULTIPLIER : 1; + const zoomChange = -e.deltaY * multiplier * ZOOM_FACTOR; + + zoomBy(zoomChange, e.clientX, e.clientY); + }; + + viewport.addEventListener('wheel', handleWheel, { + passive: false, + capture: true, + }); + + return () => { + viewport.removeEventListener('wheel', handleWheel, true); + }; + }, [zoomBy]); + + useEffect( + () => () => { + clearScheduledFit(); + }, + [clearScheduledFit], + ); + + const handleImageLoad = useCallback(() => { + const dimensions = readRawDimensions(); + + if (dimensions) { + setRawImageDimensions(dimensions); + } + + resetToFit(); + }, [readRawDimensions, resetToFit, setRawImageDimensions]); + + const startDrag = useCallback( + (pointerId: number, clientX: number, clientY: number) => { + const geometry = getGeometry(); + if (!geometry || dragStateRef.current) return false; + + const overflow = getOverflowState( + geometry.baseDimensions, + transformStateRef.current.scale, + geometry.viewportWidth, + geometry.viewportHeight, + ); + + if (!overflow.width && !overflow.height) return false; + + dragStateRef.current = { + pointerId, + startClientX: clientX, + startClientY: clientY, + startTransform: transformStateRef.current, + }; + hasUserInteractedRef.current = true; + setIsPanning(true); + return true; + }, + [getGeometry], + ); + + const updateDrag = useCallback( + (pointerId: number, clientX: number, clientY: number) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== pointerId) return false; + + const deltaX = clientX - dragState.startClientX; + const deltaY = clientY - dragState.startClientY; + + applyTransform({ + positionX: dragState.startTransform.positionX + deltaX, + positionY: dragState.startTransform.positionY + deltaY, + scale: dragState.startTransform.scale, + }); + return true; + }, + [applyTransform], + ); + + const endDrag = useCallback((pointerId: number) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== pointerId) return false; + + dragStateRef.current = null; + setIsPanning(false); + return true; + }, []); + + const handlePointerDown = useCallback( + (e: ReactPointerEvent) => { + if (e.button > 0 || (e.buttons && e.buttons !== 1)) return; + + const didStartDrag = startDrag(e.pointerId, e.clientX, e.clientY); + if (!didStartDrag) return; + + try { + e.currentTarget.setPointerCapture?.(e.pointerId); + } catch { + // Pointer capture is a best-effort guard; pointerleave is the fallback. + } + e.preventDefault(); + }, + [startDrag], + ); + + const handlePointerMove = useCallback( + (e: ReactPointerEvent) => { + const didUpdateDrag = updateDrag(e.pointerId, e.clientX, e.clientY); + if (!didUpdateDrag) return; + + e.preventDefault(); + }, + [updateDrag], + ); + + const handlePointerEnd = useCallback( + (e: ReactPointerEvent) => { + const didEndDrag = endDrag(e.pointerId); + if (!didEndDrag) return; + + try { + e.currentTarget.releasePointerCapture?.(e.pointerId); + } catch { + // The pointer may already be released if capture was unavailable. + } + }, + [endDrag], + ); + + const handlePointerLeave = useCallback( + (e: ReactPointerEvent) => { + if (e.currentTarget.hasPointerCapture?.(e.pointerId)) return; + + endDrag(e.pointerId); + }, + [endDrag], + ); + + const contentDimensions = rawDimensions + ? getEffectiveDimensions( + rawDimensions.width, + rawDimensions.height, + rotation, + ) + : null; + const imageOffset = + rawDimensions && contentDimensions + ? { + left: (contentDimensions.width - rawDimensions.width) / 2, + top: (contentDimensions.height - rawDimensions.height) / 2, + } + : { left: 0, top: 0 }; + const cursor = isPanning ? 'grabbing' : isOverflowing ? 'move' : 'default'; + + return { + viewportRef, + imageRef, + transformState, + rawDimensions, + contentDimensions, + imageOffset, + cursor, + handleImageLoad, + handlePointerDown, + handlePointerMove, + handlePointerEnd, + handlePointerLeave, + zoomIn: () => zoomBy(CONTROL_BUTTON_ZOOM_STEP), + zoomOut: () => zoomBy(-CONTROL_BUTTON_ZOOM_STEP), + reset: resetToFit, + }; +}; diff --git a/frontend/src/utils/__tests__/zoomUtils.test.ts b/frontend/src/utils/__tests__/zoomUtils.test.ts new file mode 100644 index 000000000..fee59e41a --- /dev/null +++ b/frontend/src/utils/__tests__/zoomUtils.test.ts @@ -0,0 +1,195 @@ +import { + computeZoomTransform, + getFitTransform, + getMinimumScale, + type Geometry, +} from '../zoomUtils'; + +const makeGeometry = (overrides: Partial = {}): Geometry => { + const baseDimensions = overrides.baseDimensions ?? { + width: 800, + height: 600, + }; + const viewportWidth = overrides.viewportWidth ?? 800; + const viewportHeight = overrides.viewportHeight ?? 600; + + return { + viewportWidth, + viewportHeight, + viewportLeft: 0, + viewportTop: 0, + rawDimensions: baseDimensions, + baseDimensions, + minScale: getMinimumScale(baseDimensions, viewportWidth, viewportHeight), + ...overrides, + }; +}; + +describe('zoomUtils', () => { + test('computes fit scale and centered transform for a large image', () => { + const geometry = makeGeometry({ + viewportWidth: 500, + viewportHeight: 400, + baseDimensions: { width: 1000, height: 800 }, + rawDimensions: { width: 1000, height: 800 }, + minScale: 0.5, + }); + + expect(getFitTransform(geometry)).toEqual({ + positionX: 0, + positionY: 0, + scale: 0.5, + }); + }); + + test('keeps zoom centered while the image still fits', () => { + const geometry = makeGeometry({ + viewportWidth: 800, + viewportHeight: 600, + viewportLeft: 20, + viewportTop: 10, + baseDimensions: { width: 200, height: 100 }, + rawDimensions: { width: 200, height: 100 }, + minScale: 1, + }); + + const next = computeZoomTransform({ + geometry, + currentTransform: { positionX: 300, positionY: 250, scale: 1 }, + zoomChange: 0.1, + clientX: 770, + clientY: 560, + }); + + expect(next.positionX).toBeCloseTo(290); + expect(next.positionY).toBeCloseTo(245); + expect(next.scale).toBeCloseTo(1.1); + }); + + test('stops at the first viewport edge before mouse anchoring begins', () => { + const geometry = makeGeometry({ + viewportWidth: 800, + viewportHeight: 600, + baseDimensions: { width: 760, height: 300 }, + rawDimensions: { width: 760, height: 300 }, + minScale: 1, + }); + + const next = computeZoomTransform({ + geometry, + currentTransform: { positionX: 20, positionY: 150, scale: 1 }, + zoomChange: 0.1, + clientX: 750, + clientY: 300, + }); + + expect(next.positionX).toBeCloseTo(0); + expect(next.positionY).toBeCloseTo(142.10526315789474); + expect(next.scale).toBeCloseTo(1.0526315789473684); + }); + + test('anchors an overflowing axis when the cursor is in viewport padding', () => { + const geometry = makeGeometry({ + viewportWidth: 800, + viewportHeight: 600, + baseDimensions: { width: 900, height: 300 }, + rawDimensions: { width: 900, height: 300 }, + minScale: 0.8888888888888888, + }); + + const next = computeZoomTransform({ + geometry, + currentTransform: { positionX: -80, positionY: 135, scale: 1.1 }, + zoomChange: 0.1, + clientX: 700, + clientY: 50, + }); + + expect(next.positionX).toBeCloseTo(-150.90909090909088); + expect(next.positionY).toBeCloseTo(120); + expect(next.scale).toBeCloseTo(1.2); + }); + + test('blends overflowing axes back toward center while zooming out', () => { + const geometry = makeGeometry({ + viewportWidth: 800, + viewportHeight: 600, + baseDimensions: { width: 1600, height: 1200 }, + rawDimensions: { width: 1600, height: 1200 }, + minScale: 0.5, + }); + + const zoomedIn = computeZoomTransform({ + geometry, + currentTransform: getFitTransform(geometry), + zoomChange: 0.1, + clientX: 700, + clientY: 500, + }); + const zoomedOut = computeZoomTransform({ + geometry, + currentTransform: zoomedIn, + zoomChange: -0.05, + clientX: 700, + clientY: 500, + }); + + expect(zoomedOut.positionX).toBeCloseTo(-43); + expect(zoomedOut.positionY).toBeCloseTo(-32); + expect(zoomedOut.scale).toBeCloseTo(0.55); + }); + + test('anchors one axis or both axes according to overflow state', () => { + const widthOnlyGeometry = makeGeometry({ + viewportWidth: 800, + viewportHeight: 600, + baseDimensions: { width: 760, height: 300 }, + rawDimensions: { width: 760, height: 300 }, + minScale: 1, + }); + + const widthAtEdge = computeZoomTransform({ + geometry: widthOnlyGeometry, + currentTransform: { positionX: 20, positionY: 150, scale: 1 }, + zoomChange: 0.1, + clientX: 750, + clientY: 300, + }); + const widthAnchored = computeZoomTransform({ + geometry: widthOnlyGeometry, + currentTransform: widthAtEdge, + zoomChange: 0.1, + clientX: 750, + clientY: 300, + }); + + expect(widthAnchored.positionX).toBeCloseTo(-71.25); + expect(widthAnchored.positionY).toBeCloseTo(127.10526315789474); + + const bothAxisGeometry = makeGeometry({ + viewportWidth: 800, + viewportHeight: 600, + baseDimensions: { width: 900, height: 700 }, + rawDimensions: { width: 900, height: 700 }, + minScale: 0.8571428571428571, + }); + + const bothAtEdge = computeZoomTransform({ + geometry: bothAxisGeometry, + currentTransform: getFitTransform(bothAxisGeometry), + zoomChange: 0.1, + clientX: 700, + clientY: 500, + }); + const bothAnchored = computeZoomTransform({ + geometry: bothAxisGeometry, + currentTransform: bothAtEdge, + zoomChange: 0.1, + clientX: 700, + clientY: 500, + }); + + expect(bothAnchored.positionX).toBeCloseTo(-78.75); + expect(bothAnchored.positionY).toBeCloseTo(-76.85185185185185); + }); +}); diff --git a/frontend/src/utils/zoomUtils.ts b/frontend/src/utils/zoomUtils.ts new file mode 100644 index 000000000..ad3bae738 --- /dev/null +++ b/frontend/src/utils/zoomUtils.ts @@ -0,0 +1,315 @@ +export const ZOOM_FACTOR = 0.001; +// Browser line-mode wheel events report lines, not pixels. This normalizes one +// line-scroll notch to the commonly used 33px pixel delta. +export const LINE_HEIGHT_MULTIPLIER = 33; +export const MAX_SCALE = 8; +export const MIN_SCALE = 1; +export const SCALE_EPSILON = 0.0001; +export const MAX_FIT_RETRY_FRAMES = 12; +export const CONTROL_BUTTON_ZOOM_STEP = 0.5; + +export type Size = { + width: number; + height: number; +}; + +export type OverflowState = { + width: boolean; + height: boolean; +}; + +export type TransformState = { + positionX: number; + positionY: number; + scale: number; +}; + +export type Geometry = { + viewportWidth: number; + viewportHeight: number; + viewportLeft: number; + viewportTop: number; + rawDimensions: Size; + baseDimensions: Size; + minScale: number; +}; + +type ComputeZoomTransformOptions = { + geometry: Geometry; + currentTransform: TransformState; + zoomChange: number; + clientX?: number; + clientY?: number; +}; + +export const getCenteredAxisPosition = ( + viewportSize: number, + scaledSize: number, +) => (viewportSize - scaledSize) / 2; + +export const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + +export const clampOverflowAxisPosition = ( + position: number, + viewportSize: number, + scaledSize: number, +) => { + const minPosition = viewportSize - scaledSize; + const maxPosition = 0; + + return clamp(position, minPosition, maxPosition); +}; + +export const getAxisPosition = ( + anchoredPosition: number, + viewportSize: number, + scaledSize: number, + isOverflowingAxis: boolean, +) => { + const centeredPosition = getCenteredAxisPosition(viewportSize, scaledSize); + + return isOverflowingAxis + ? clampOverflowAxisPosition(anchoredPosition, viewportSize, scaledSize) + : centeredPosition; +}; + +export const getOverflowRatio = (viewportSize: number, scaledSize: number) => { + if (!viewportSize || scaledSize <= viewportSize) return 0; + + return clamp((scaledSize - viewportSize) / viewportSize, 0, 1); +}; + +export const interpolate = (from: number, to: number, ratio: number) => + from + (to - from) * ratio; + +export const getSmoothedWheelAxisPosition = ({ + anchoredPosition, + viewportSize, + scaledSize, + isOverflowingAxis, + shouldAnchor, + isZoomingOut, +}: { + anchoredPosition: number; + viewportSize: number; + scaledSize: number; + isOverflowingAxis: boolean; + shouldAnchor: boolean; + isZoomingOut: boolean; +}) => { + const centeredPosition = getCenteredAxisPosition(viewportSize, scaledSize); + + if (!isOverflowingAxis || !shouldAnchor) return centeredPosition; + + const clampedAnchor = clampOverflowAxisPosition( + anchoredPosition, + viewportSize, + scaledSize, + ); + + if (!isZoomingOut) return clampedAnchor; + + const anchorRatio = getOverflowRatio(viewportSize, scaledSize); + + return interpolate(centeredPosition, clampedAnchor, anchorRatio); +}; + +export const axisTouchesViewport = (scaledSize: number, viewportSize: number) => + scaledSize >= viewportSize - SCALE_EPSILON; + +export const getEffectiveDimensions = ( + width: number, + height: number, + rotation: number, +): Size => { + const normalizedRotation = ((rotation % 360) + 360) % 360; + const isRotated90or270 = + normalizedRotation === 90 || normalizedRotation === 270; + + return isRotated90or270 + ? { width: height, height: width } + : { width, height }; +}; + +export const getMinimumScale = ( + baseDimensions: Size, + viewportWidth: number, + viewportHeight: number, +) => { + if ( + !baseDimensions.width || + !baseDimensions.height || + !viewportWidth || + !viewportHeight + ) { + return MIN_SCALE; + } + + return Math.min( + MIN_SCALE, + viewportWidth / baseDimensions.width, + viewportHeight / baseDimensions.height, + ); +}; + +export const getNextViewportEdgeScale = ( + baseDimensions: Size, + viewportWidth: number, + viewportHeight: number, + currentTouchesViewport: OverflowState, +) => { + const edgeScales = []; + + if (!currentTouchesViewport.width) { + edgeScales.push(viewportWidth / baseDimensions.width); + } + + if (!currentTouchesViewport.height) { + edgeScales.push(viewportHeight / baseDimensions.height); + } + + if (!edgeScales.length) return null; + + return Math.min( + MAX_SCALE, + Math.max( + getMinimumScale(baseDimensions, viewportWidth, viewportHeight), + Math.min(...edgeScales), + ), + ); +}; + +export const getScaledDimensions = ( + baseDimensions: Size, + scale: number, +): Size => ({ + width: baseDimensions.width * scale, + height: baseDimensions.height * scale, +}); + +export const getOverflowState = ( + baseDimensions: Size, + scale: number, + viewportWidth: number, + viewportHeight: number, +): OverflowState => { + const scaledDimensions = getScaledDimensions(baseDimensions, scale); + + return { + width: scaledDimensions.width > viewportWidth, + height: scaledDimensions.height > viewportHeight, + }; +}; + +export const getFitTransform = ({ + baseDimensions, + viewportWidth, + viewportHeight, + minScale, +}: Geometry): TransformState => { + const scaledDimensions = getScaledDimensions(baseDimensions, minScale); + + return { + positionX: getCenteredAxisPosition(viewportWidth, scaledDimensions.width), + positionY: getCenteredAxisPosition(viewportHeight, scaledDimensions.height), + scale: minScale, + }; +}; + +export const getElementSize = (element: HTMLElement) => { + const rect = element.getBoundingClientRect(); + + return { + width: rect.width || element.clientWidth, + height: rect.height || element.clientHeight, + left: rect.left, + top: rect.top, + }; +}; + +export const computeZoomTransform = ({ + geometry, + currentTransform, + zoomChange, + clientX, + clientY, +}: ComputeZoomTransformOptions): TransformState => { + const desiredScale = clamp( + currentTransform.scale + zoomChange, + geometry.minScale, + MAX_SCALE, + ); + const currentDimensions = getScaledDimensions( + geometry.baseDimensions, + currentTransform.scale, + ); + const currentTouchesViewport = { + width: axisTouchesViewport(currentDimensions.width, geometry.viewportWidth), + height: axisTouchesViewport( + currentDimensions.height, + geometry.viewportHeight, + ), + }; + const isZoomingIn = desiredScale > currentTransform.scale; + const nextViewportEdgeScale = getNextViewportEdgeScale( + geometry.baseDimensions, + geometry.viewportWidth, + geometry.viewportHeight, + currentTouchesViewport, + ); + const scale = + isZoomingIn && + nextViewportEdgeScale !== null && + desiredScale > nextViewportEdgeScale + ? nextViewportEdgeScale + : desiredScale; + const scaledDimensions = getScaledDimensions(geometry.baseDimensions, scale); + const newOverflow = getOverflowState( + geometry.baseDimensions, + scale, + geometry.viewportWidth, + geometry.viewportHeight, + ); + const mouseViewportX = + clientX === undefined + ? geometry.viewportWidth / 2 + : clientX - geometry.viewportLeft; + const mouseViewportY = + clientY === undefined + ? geometry.viewportHeight / 2 + : clientY - geometry.viewportTop; + const isWithinViewport = + mouseViewportX >= 0 && + mouseViewportX <= geometry.viewportWidth && + mouseViewportY >= 0 && + mouseViewportY <= geometry.viewportHeight; + const ratio = currentTransform.scale > 0 ? scale / currentTransform.scale : 1; + const anchoredX = + mouseViewportX - (mouseViewportX - currentTransform.positionX) * ratio; + const anchoredY = + mouseViewportY - (mouseViewportY - currentTransform.positionY) * ratio; + const isZoomingOut = scale < currentTransform.scale; + + return { + positionX: getSmoothedWheelAxisPosition({ + anchoredPosition: anchoredX, + viewportSize: geometry.viewportWidth, + scaledSize: scaledDimensions.width, + isOverflowingAxis: newOverflow.width, + shouldAnchor: + isWithinViewport && currentTouchesViewport.width && newOverflow.width, + isZoomingOut, + }), + positionY: getSmoothedWheelAxisPosition({ + anchoredPosition: anchoredY, + viewportSize: geometry.viewportHeight, + scaledSize: scaledDimensions.height, + isOverflowingAxis: newOverflow.height, + shouldAnchor: + isWithinViewport && currentTouchesViewport.height && newOverflow.height, + isZoomingOut, + }), + scale, + }; +}; From 143ca194f29beaf504a70468d0d7cd524b398dd7 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Tue, 2 Jun 2026 20:34:03 +0530 Subject: [PATCH 14/16] Address zoom follow-up cleanup --- frontend/src/components/Media/ImageViewer.tsx | 14 +- .../src/components/Media/MediaThumbnails.tsx | 12 +- .../src/components/Media/ZoomableImage.tsx | 30 +- .../Media/__tests__/ZoomableImage.test.tsx | 349 +++++++++++++++++- frontend/src/hooks/useImageViewControls.ts | 65 +--- frontend/src/hooks/useZoomTransform.ts | 25 +- frontend/src/utils/imageFallback.ts | 21 ++ 7 files changed, 411 insertions(+), 105 deletions(-) create mode 100644 frontend/src/utils/imageFallback.ts diff --git a/frontend/src/components/Media/ImageViewer.tsx b/frontend/src/components/Media/ImageViewer.tsx index 84e003d11..c8d72c6a6 100644 --- a/frontend/src/components/Media/ImageViewer.tsx +++ b/frontend/src/components/Media/ImageViewer.tsx @@ -18,11 +18,15 @@ export const ImageViewer = forwardRef( ({ imagePath, alt, rotation, resetSignal }, ref) => { const zoomableImageRef = useRef(null); - useImperativeHandle(ref, () => ({ - zoomIn: () => zoomableImageRef.current?.zoomIn(), - zoomOut: () => zoomableImageRef.current?.zoomOut(), - reset: () => zoomableImageRef.current?.reset(), - })); + useImperativeHandle( + ref, + () => ({ + zoomIn: () => zoomableImageRef.current?.zoomIn(), + zoomOut: () => zoomableImageRef.current?.zoomOut(), + reset: () => zoomableImageRef.current?.reset(), + }), + [], + ); return ( = ({ } cursor-pointer transition-all duration-200 hover:scale-105`} > {`thumbnail-${index}`} { - const img = e.target as HTMLImageElement; - img.onerror = null; - img.src = '/placeholder.svg'; - }} + onError={handlePlaceholderImageError} />
))} diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index a7947b4c7..77cd81bd2 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -1,6 +1,10 @@ import { useImperativeHandle, forwardRef } from 'react'; import { convertFileSrc } from '@tauri-apps/api/core'; import { useZoomTransform } from '@/hooks/useZoomTransform'; +import { + PLACEHOLDER_IMAGE_SRC, + handlePlaceholderImageError, +} from '@/utils/imageFallback'; interface ZoomableImageProps { imagePath: string; @@ -35,11 +39,15 @@ export const ZoomableImage = forwardRef( reset, } = useZoomTransform({ imagePath, rotation, resetSignal }); - useImperativeHandle(ref, () => ({ - zoomIn, - zoomOut, - reset, - })); + useImperativeHandle( + ref, + () => ({ + zoomIn, + zoomOut, + reset, + }), + [zoomIn, zoomOut, reset], + ); return (
( {alt} { - const img = e.target as HTMLImageElement; - img.onerror = null; - img.src = '/placeholder.svg'; - }} + onError={handlePlaceholderImageError} style={{ position: contentDimensions ? 'absolute' : 'relative', left: `${imageOffset.left}px`, top: `${imageOffset.top}px`, width: rawDimensions ? `${rawDimensions.width}px` : undefined, height: rawDimensions ? `${rawDimensions.height}px` : undefined, + // Override the framework's `img { max-width: 100% }` reset so the + // image can scale beyond the viewport while zoomed. maxWidth: 'none', maxHeight: 'none', objectFit: 'contain', - zIndex: 50, transform: `rotate(${rotation}deg)`, transformOrigin: 'center center', pointerEvents: 'none', diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index c0b24de07..6015aab41 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -1,6 +1,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { createRef } from 'react'; import { ZoomableImage, ZoomableImageRef } from '../ZoomableImage'; +import { MAX_FIT_RETRY_FRAMES } from '@/utils/zoomUtils'; jest.mock('@tauri-apps/api/core', () => ({ convertFileSrc: (path: string) => path, @@ -169,6 +170,82 @@ const firePointerEvent = ( fireEvent(element, event); }; +const installPointerCaptureMocks = (element: Element) => { + const capturedPointers = new Set(); + + Object.defineProperty(element, 'setPointerCapture', { + configurable: true, + value: jest.fn((pointerId: number) => { + capturedPointers.add(pointerId); + }), + }); + Object.defineProperty(element, 'releasePointerCapture', { + configurable: true, + value: jest.fn((pointerId: number) => { + capturedPointers.delete(pointerId); + }), + }); + Object.defineProperty(element, 'hasPointerCapture', { + configurable: true, + value: jest.fn((pointerId: number) => capturedPointers.has(pointerId)), + }); +}; + +const zoomToOverflow = (viewport: Element) => { + fireEvent.wheel(viewport, { + deltaY: -100, + clientX: 700, + clientY: 500, + }); +}; + +const startPan = (viewport: Element, pointerId = 5) => { + firePointerEvent(viewport, 'pointerdown', { + button: 0, + buttons: 1, + pointerId, + clientX: 300, + clientY: 300, + }); +}; + +type ManualAnimationFrames = { + frames: Map; + flushNextFrame: () => void; +}; + +// Replaces the synchronous rAF/cAF mocks (installed in beforeEach) with a manual +// queue so a single generation of scheduled fit attempts can be flushed at a +// time. This makes the fit-retry loop deterministic to assert against. +const setupManualAnimationFrames = (): ManualAnimationFrames => { + const frames = new Map(); + let nextFrameId = 1; + + (window.requestAnimationFrame as unknown as jest.Mock).mockImplementation( + (callback: FrameRequestCallback) => { + const id = nextFrameId; + nextFrameId += 1; + frames.set(id, callback); + return id; + }, + ); + (window.cancelAnimationFrame as unknown as jest.Mock).mockImplementation( + (id: number) => { + frames.delete(id); + }, + ); + + const flushNextFrame = () => { + const callbacks = Array.from(frames.values()); + frames.clear(); + act(() => { + callbacks.forEach((callback) => callback(0)); + }); + }; + + return { frames, flushNextFrame }; +}; + describe('ZoomableImage controlled transform behavior', () => { beforeEach(() => { global.ResizeObserver = @@ -383,24 +460,79 @@ describe('ZoomableImage controlled transform behavior', () => { expectCurrentTransform(0, 127.10526315789474, 1.1526315789473685); }); - test('cleans up panning on pointer leave when pointer capture is unavailable', () => { + test('reuses the drag-start geometry while panning', () => { const { viewport } = setupScene({ viewportSize: { width: 800, height: 600 }, imageSize: { width: 900, height: 700 }, }); + const getBoundingClientRect = jest.fn( + () => + ({ + left: 0, + top: 0, + right: 800, + bottom: 600, + x: 0, + y: 0, + width: 800, + height: 600, + toJSON: () => undefined, + }) as DOMRect, + ); - fireEvent.wheel(viewport, { - deltaY: -100, - clientX: 700, - clientY: 500, + Object.defineProperty(viewport, 'getBoundingClientRect', { + configurable: true, + value: getBoundingClientRect, }); - firePointerEvent(viewport, 'pointerdown', { - button: 0, - buttons: 1, + + zoomToOverflow(viewport); + startPan(viewport, 1); + + const callsAfterDragStart = getBoundingClientRect.mock.calls.length; + + firePointerEvent(viewport, 'pointermove', { + pointerId: 1, + clientX: 350, + clientY: 350, + }); + firePointerEvent(viewport, 'pointermove', { + pointerId: 1, + clientX: 400, + clientY: 400, + }); + + expect(getBoundingClientRect).toHaveBeenCalledTimes(callsAfterDragStart); + }); + + test('cleans up panning on pointer leave when pointer capture is unavailable', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + + zoomToOverflow(viewport); + startPan(viewport); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'pointerout', { pointerId: 5, - clientX: 300, - clientY: 300, + clientX: 900, + clientY: 700, + }); + + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); + }); + + test('keeps panning when the pointer leaves while capture is held', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, }); + installPointerCaptureMocks(viewport); + + zoomToOverflow(viewport); + startPan(viewport); expect(viewport).toHaveStyle({ cursor: 'grabbing' }); @@ -410,6 +542,108 @@ describe('ZoomableImage controlled transform behavior', () => { clientY: 700, }); + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'pointerup', { + pointerId: 5, + clientX: 900, + clientY: 700, + }); + + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); + }); + + test('cleans up panning on pointer cancel', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + + zoomToOverflow(viewport); + startPan(viewport); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'pointercancel', { + pointerId: 5, + clientX: 300, + clientY: 300, + }); + + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); + }); + + test('cleans up panning when pointer capture is lost', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + + zoomToOverflow(viewport); + startPan(viewport); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'lostpointercapture', { + pointerId: 5, + clientX: 300, + clientY: 300, + }); + + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); + }); + + test('cleans up panning on pointer up after pointer capture succeeds', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + installPointerCaptureMocks(viewport); + + zoomToOverflow(viewport); + startPan(viewport); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + expect(viewport.setPointerCapture).toHaveBeenCalledWith(5); + expect(viewport.hasPointerCapture(5)).toBe(true); + + firePointerEvent(viewport, 'pointerup', { + pointerId: 5, + clientX: 300, + clientY: 300, + }); + + expect(viewport.releasePointerCapture).toHaveBeenCalledWith(5); + expect(viewport.hasPointerCapture(5)).toBe(false); + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); + }); + + test('does not replace an active drag with a second pointer', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 900, height: 700 }, + }); + + zoomToOverflow(viewport); + startPan(viewport, 1); + startPan(viewport, 2); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'pointerup', { + pointerId: 2, + clientX: 300, + clientY: 300, + }); + + expect(viewport).toHaveStyle({ cursor: 'grabbing' }); + + firePointerEvent(viewport, 'pointerup', { + pointerId: 1, + clientX: 300, + clientY: 300, + }); + expect(viewport).not.toHaveStyle({ cursor: 'grabbing' }); }); @@ -528,4 +762,99 @@ describe('ZoomableImage controlled transform behavior', () => { expectCurrentTransform(0, 0, 0.5); }); + + test('applies the line-height multiplier for line-mode wheel events', () => { + const { viewport } = setupScene({ + viewportSize: { width: 800, height: 600 }, + imageSize: { width: 200, height: 100 }, + }); + + // A line-mode wheel (deltaMode === 1) reports scroll in lines, not pixels. + // It must be normalized by LINE_HEIGHT_MULTIPLIER (33), so a 3-line notch + // zooms by 3 * 33 * ZOOM_FACTOR(0.001) = 0.099 -> scale 1.099, identical to + // a 99px pixel-mode notch. Without the multiplier it would be only 0.003. + fireEvent.wheel(viewport, { + deltaY: -3, + deltaMode: 1, + clientX: 400, + clientY: 300, + }); + + expectCurrentTransform(290.1, 245.05, 1.099); + }); + + test('retries the fit until the viewport can be measured', () => { + const { flushNextFrame } = setupManualAnimationFrames(); + + renderZoomableImage(); + const viewport = screen.getByTestId('zoom-viewport'); + const image = screen.getByAltText('test image'); + + // The image dimensions are known, but the viewport cannot be measured yet. + mockElementRect( + viewport, + { width: 0, height: 0, left: 0, top: 0 }, + { clientWidth: 0, clientHeight: 0 }, + ); + mockImageDimensions(image, { width: 1000, height: 800 }); + fireEvent.load(image); + + // Fit attempts run but cannot succeed while the viewport has no size, so + // the transform stays at its initial (unfitted) value. + flushNextFrame(); + flushNextFrame(); + expectCurrentTransform(0, 0, 1); + + // Once the viewport reports a size, the next retry fits the image. + mockElementRect( + viewport, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + flushNextFrame(); + + expectCurrentTransform(0, 0, 0.5); + }); + + test('stops retrying after the maximum number of frames and recovers on resize', () => { + const { frames, flushNextFrame } = setupManualAnimationFrames(); + + renderZoomableImage(); + const viewport = screen.getByTestId('zoom-viewport'); + const image = screen.getByAltText('test image'); + + mockElementRect( + viewport, + { width: 0, height: 0, left: 0, top: 0 }, + { clientWidth: 0, clientHeight: 0 }, + ); + mockImageDimensions(image, { width: 1000, height: 800 }); + fireEvent.load(image); + + // Exhaust every retry while the viewport stays unmeasurable. + for (let i = 0; i < MAX_FIT_RETRY_FRAMES + 2; i += 1) { + flushNextFrame(); + } + + // The retry loop has given up: nothing is scheduled and no fit happened. + expect(frames.size).toBe(0); + expectCurrentTransform(0, 0, 1); + + // A now-measurable viewport alone does not revive the abandoned loop. + mockElementRect( + viewport, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + flushNextFrame(); + expectCurrentTransform(0, 0, 1); + + // A fresh fit cycle (resize) resets the retry count and fits the image. + act(() => { + window.dispatchEvent(new Event('resize')); + }); + flushNextFrame(); + + expectCurrentTransform(0, 0, 0.5); + }); }); diff --git a/frontend/src/hooks/useImageViewControls.ts b/frontend/src/hooks/useImageViewControls.ts index ca2541962..6fb30fa2e 100644 --- a/frontend/src/hooks/useImageViewControls.ts +++ b/frontend/src/hooks/useImageViewControls.ts @@ -1,94 +1,31 @@ import { useState, useCallback } from 'react'; interface ViewState { - scale: number; - position: { x: number; y: number }; rotation: number; - isDragging: boolean; - dragStart: { x: number; y: number }; } export const useImageViewControls = () => { const [viewState, setViewState] = useState({ - scale: 1, - position: { x: 0, y: 0 }, rotation: 0, - isDragging: false, - dragStart: { x: 0, y: 0 }, }); - const handleZoomIn = useCallback(() => { - setViewState((prev) => ({ - ...prev, - scale: Math.min(4, prev.scale + 0.1), - })); - }, []); - - const handleZoomOut = useCallback(() => { - setViewState((prev) => ({ - ...prev, - scale: Math.max(0.5, prev.scale - 0.1), - })); - }, []); - const handleRotate = useCallback(() => { setViewState((prev) => ({ - ...prev, rotation: (prev.rotation + 90) % 360, })); }, []); const resetZoom = useCallback(() => { - setViewState((prev) => ({ - ...prev, - scale: 1, - position: { x: 0, y: 0 }, + setViewState({ rotation: 0, - })); - }, []); - - const handleMouseDown = useCallback((e: React.MouseEvent) => { - setViewState((prev) => ({ - ...prev, - isDragging: true, - dragStart: { - x: e.clientX - prev.position.x, - y: e.clientY - prev.position.y, - }, - })); - }, []); - - const handleMouseMove = useCallback((e: React.MouseEvent) => { - setViewState((prev) => { - if (!prev.isDragging) return prev; - - return { - ...prev, - position: { - x: e.clientX - prev.dragStart.x, - y: e.clientY - prev.dragStart.y, - }, - }; }); }, []); - const handleMouseUp = useCallback(() => { - setViewState((prev) => ({ - ...prev, - isDragging: false, - })); - }, []); - return { viewState, handlers: { - handleZoomIn, - handleZoomOut, handleRotate, resetZoom, - handleMouseDown, - handleMouseMove, - handleMouseUp, }, }; }; diff --git a/frontend/src/hooks/useZoomTransform.ts b/frontend/src/hooks/useZoomTransform.ts index 570dedbb9..91f142940 100644 --- a/frontend/src/hooks/useZoomTransform.ts +++ b/frontend/src/hooks/useZoomTransform.ts @@ -39,6 +39,7 @@ type DragState = { startClientX: number; startClientY: number; startTransform: TransformState; + geometry: Geometry; }; export const useZoomTransform = ({ @@ -375,6 +376,7 @@ export const useZoomTransform = ({ startClientX: clientX, startClientY: clientY, startTransform: transformStateRef.current, + geometry, }; hasUserInteractedRef.current = true; setIsPanning(true); @@ -391,11 +393,14 @@ export const useZoomTransform = ({ const deltaX = clientX - dragState.startClientX; const deltaY = clientY - dragState.startClientY; - applyTransform({ - positionX: dragState.startTransform.positionX + deltaX, - positionY: dragState.startTransform.positionY + deltaY, - scale: dragState.startTransform.scale, - }); + applyTransform( + { + positionX: dragState.startTransform.positionX + deltaX, + positionY: dragState.startTransform.positionY + deltaY, + scale: dragState.startTransform.scale, + }, + dragState.geometry, + ); return true; }, [applyTransform], @@ -460,6 +465,12 @@ export const useZoomTransform = ({ [endDrag], ); + const zoomIn = useCallback(() => zoomBy(CONTROL_BUTTON_ZOOM_STEP), [zoomBy]); + const zoomOut = useCallback( + () => zoomBy(-CONTROL_BUTTON_ZOOM_STEP), + [zoomBy], + ); + const contentDimensions = rawDimensions ? getEffectiveDimensions( rawDimensions.width, @@ -489,8 +500,8 @@ export const useZoomTransform = ({ handlePointerMove, handlePointerEnd, handlePointerLeave, - zoomIn: () => zoomBy(CONTROL_BUTTON_ZOOM_STEP), - zoomOut: () => zoomBy(-CONTROL_BUTTON_ZOOM_STEP), + zoomIn, + zoomOut, reset: resetToFit, }; }; diff --git a/frontend/src/utils/imageFallback.ts b/frontend/src/utils/imageFallback.ts new file mode 100644 index 000000000..2417e1b21 --- /dev/null +++ b/frontend/src/utils/imageFallback.ts @@ -0,0 +1,21 @@ +import type { SyntheticEvent } from 'react'; + +export const PLACEHOLDER_IMAGE_SRC = '/placeholder.svg'; + +/** + * Builds an `` onError handler that swaps in a fallback image exactly once + * and detaches itself, so a broken fallback cannot trigger an error loop. + * + * Centralizes the handler that was previously copy-pasted across the media and + * memories components (each with its own fallback asset). + */ +export const createImageErrorHandler = + (fallbackSrc: string = PLACEHOLDER_IMAGE_SRC) => + (event: SyntheticEvent) => { + const img = event.currentTarget; + img.onerror = null; + img.src = fallbackSrc; + }; + +/** Shared, stable handler for components that fall back to the placeholder. */ +export const handlePlaceholderImageError = createImageErrorHandler(); From 72cd74fe327a0bc7e48b33dc8567a931afaa3a27 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sat, 6 Jun 2026 20:05:05 +0530 Subject: [PATCH 15/16] Smooth mouse wheel zoom scaling --- .../src/components/Media/ZoomableImage.tsx | 4 ++ .../Media/__tests__/ZoomableImage.test.tsx | 42 ++++++++++++++----- frontend/src/hooks/useZoomTransform.ts | 34 ++++++++++----- .../src/utils/__tests__/zoomUtils.test.ts | 38 ++++++++--------- frontend/src/utils/zoomUtils.ts | 13 +++--- 5 files changed, 85 insertions(+), 46 deletions(-) diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index 77cd81bd2..ca5be23e5 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -29,11 +29,13 @@ export const ZoomableImage = forwardRef( contentDimensions, imageOffset, cursor, + isButtonZoom, handleImageLoad, handlePointerDown, handlePointerMove, handlePointerEnd, handlePointerLeave, + handleZoomTransitionEnd, zoomIn, zoomOut, reset, @@ -67,6 +69,7 @@ export const ZoomableImage = forwardRef( >
( transform: `translate3d(${transformState.positionX}px, ${transformState.positionY}px, 0) scale(${transformState.scale})`, transformOrigin: '0 0', willChange: 'transform', + transition: isButtonZoom ? 'transform 250ms ease-out' : undefined, }} > { clientY: 550, }); - expectCurrentTransform(290, 245, 1.1); + expectCurrentTransform( + 289.4829081924352, + 244.7414540962176, + 1.1051709180756477, + ); }); test('stops at the first viewport edge before mouse anchoring begins', () => { @@ -336,7 +340,11 @@ describe('ZoomableImage controlled transform behavior', () => { clientY: 300, }); - expectCurrentTransform(-71.25, 127.10526315789474, 1.1526315789473685); + expectCurrentTransform( + -78.87818855673584, + 125.49932872489774, + 1.1633378085006818, + ); }); test('anchors vertically only after height has reached the viewport edge', () => { @@ -357,9 +365,9 @@ describe('ZoomableImage controlled transform behavior', () => { }); expectCurrentTransform( - 224.28571428571428, - -51.33333333333337, - 1.1714285714285715, + 222.3832453092709, + -57.84400494160627, + 1.184111697938194, ); }); @@ -380,7 +388,11 @@ describe('ZoomableImage controlled transform behavior', () => { clientY: 500, }); - expectCurrentTransform(-78.75, -76.85185185185185, 0.9888888888888888); + expectCurrentTransform( + -73.61964265295342, + -73.05158715033576, + 0.9823741494005757, + ); }); test('recenters when zooming back to minimum scale', () => { @@ -420,7 +432,11 @@ describe('ZoomableImage controlled transform behavior', () => { clientY: 500, }); - expectCurrentTransform(-43, -32, 0.55); + expectCurrentTransform( + -21.29705614748953, + -15.90707397752716, + 0.5256355481880121, + ); }); test('panning clamps overflowing axes and keeps fitting axes centered', () => { @@ -457,7 +473,7 @@ describe('ZoomableImage controlled transform behavior', () => { clientY: 1000, }); - expectCurrentTransform(0, 127.10526315789474, 1.1526315789473685); + expectCurrentTransform(0, 125.49932872489774, 1.1633378085006818); }); test('reuses the drag-start geometry while panning', () => { @@ -771,8 +787,8 @@ describe('ZoomableImage controlled transform behavior', () => { // A line-mode wheel (deltaMode === 1) reports scroll in lines, not pixels. // It must be normalized by LINE_HEIGHT_MULTIPLIER (33), so a 3-line notch - // zooms by 3 * 33 * ZOOM_FACTOR(0.001) = 0.099 -> scale 1.099, identical to - // a 99px pixel-mode notch. Without the multiplier it would be only 0.003. + // produces the same zoomRatio as a 99px pixel-mode notch: exp(99 * 0.001). + // Without the multiplier, a 3-line notch would produce exp(3 * 0.001) instead. fireEvent.wheel(viewport, { deltaY: -3, deltaMode: 1, @@ -780,7 +796,11 @@ describe('ZoomableImage controlled transform behavior', () => { clientY: 300, }); - expectCurrentTransform(290.1, 245.05, 1.099); + expectCurrentTransform( + 289.5933700441118, + 244.7966850220559, + 1.104066299558882, + ); }); test('retries the fit until the viewport can be measured', () => { diff --git a/frontend/src/hooks/useZoomTransform.ts b/frontend/src/hooks/useZoomTransform.ts index 91f142940..739dd94ad 100644 --- a/frontend/src/hooks/useZoomTransform.ts +++ b/frontend/src/hooks/useZoomTransform.ts @@ -6,13 +6,13 @@ import { type PointerEvent as ReactPointerEvent, } from 'react'; import { - CONTROL_BUTTON_ZOOM_STEP, + CONTROL_BUTTON_ZOOM_RATIO, LINE_HEIGHT_MULTIPLIER, MAX_FIT_RETRY_FRAMES, MAX_SCALE, MIN_SCALE, SCALE_EPSILON, - ZOOM_FACTOR, + WHEEL_ZOOM_SENSITIVITY, clamp, computeZoomTransform, getAxisPosition, @@ -67,6 +67,7 @@ export const useZoomTransform = ({ const [rawDimensions, setRawDimensions] = useState(null); const [isOverflowing, setIsOverflowing] = useState(false); const [isPanning, setIsPanning] = useState(false); + const [isButtonZoom, setIsButtonZoom] = useState(false); const setRawImageDimensions = useCallback((dimensions: Size | null) => { rawDimensionsRef.current = dimensions; @@ -243,7 +244,7 @@ export const useZoomTransform = ({ }, [scheduleFitTransform]); const zoomBy = useCallback( - (zoomChange: number, clientX?: number, clientY?: number) => { + (zoomRatio: number, clientX?: number, clientY?: number) => { const geometry = getGeometry(); if (!geometry) return false; @@ -255,7 +256,7 @@ export const useZoomTransform = ({ computeZoomTransform({ geometry, currentTransform, - zoomChange, + zoomRatio, clientX, clientY, }), @@ -323,11 +324,14 @@ export const useZoomTransform = ({ e.stopPropagation(); e.stopImmediatePropagation(); + setIsButtonZoom(false); + const isLineMode = e.deltaMode === 1; const multiplier = isLineMode ? LINE_HEIGHT_MULTIPLIER : 1; - const zoomChange = -e.deltaY * multiplier * ZOOM_FACTOR; + const normalizedDelta = -e.deltaY * multiplier; + const zoomRatio = Math.exp(normalizedDelta * WHEEL_ZOOM_SENSITIVITY); - zoomBy(zoomChange, e.clientX, e.clientY); + zoomBy(zoomRatio, e.clientX, e.clientY); }; viewport.addEventListener('wheel', handleWheel, { @@ -465,11 +469,17 @@ export const useZoomTransform = ({ [endDrag], ); - const zoomIn = useCallback(() => zoomBy(CONTROL_BUTTON_ZOOM_STEP), [zoomBy]); - const zoomOut = useCallback( - () => zoomBy(-CONTROL_BUTTON_ZOOM_STEP), - [zoomBy], - ); + const zoomIn = useCallback(() => { + setIsButtonZoom(true); + zoomBy(CONTROL_BUTTON_ZOOM_RATIO); + }, [zoomBy]); + const zoomOut = useCallback(() => { + setIsButtonZoom(true); + zoomBy(1 / CONTROL_BUTTON_ZOOM_RATIO); + }, [zoomBy]); + const handleZoomTransitionEnd = useCallback(() => { + setIsButtonZoom(false); + }, []); const contentDimensions = rawDimensions ? getEffectiveDimensions( @@ -495,11 +505,13 @@ export const useZoomTransform = ({ contentDimensions, imageOffset, cursor, + isButtonZoom, handleImageLoad, handlePointerDown, handlePointerMove, handlePointerEnd, handlePointerLeave, + handleZoomTransitionEnd, zoomIn, zoomOut, reset: resetToFit, diff --git a/frontend/src/utils/__tests__/zoomUtils.test.ts b/frontend/src/utils/__tests__/zoomUtils.test.ts index fee59e41a..d3306b497 100644 --- a/frontend/src/utils/__tests__/zoomUtils.test.ts +++ b/frontend/src/utils/__tests__/zoomUtils.test.ts @@ -56,7 +56,7 @@ describe('zoomUtils', () => { const next = computeZoomTransform({ geometry, currentTransform: { positionX: 300, positionY: 250, scale: 1 }, - zoomChange: 0.1, + zoomRatio: 1.1, clientX: 770, clientY: 560, }); @@ -78,7 +78,7 @@ describe('zoomUtils', () => { const next = computeZoomTransform({ geometry, currentTransform: { positionX: 20, positionY: 150, scale: 1 }, - zoomChange: 0.1, + zoomRatio: 1.1, clientX: 750, clientY: 300, }); @@ -100,14 +100,14 @@ describe('zoomUtils', () => { const next = computeZoomTransform({ geometry, currentTransform: { positionX: -80, positionY: 135, scale: 1.1 }, - zoomChange: 0.1, + zoomRatio: 1.1, clientX: 700, clientY: 50, }); - expect(next.positionX).toBeCloseTo(-150.90909090909088); - expect(next.positionY).toBeCloseTo(120); - expect(next.scale).toBeCloseTo(1.2); + expect(next.positionX).toBeCloseTo(-158); + expect(next.positionY).toBeCloseTo(118.5); + expect(next.scale).toBeCloseTo(1.21); }); test('blends overflowing axes back toward center while zooming out', () => { @@ -122,21 +122,21 @@ describe('zoomUtils', () => { const zoomedIn = computeZoomTransform({ geometry, currentTransform: getFitTransform(geometry), - zoomChange: 0.1, + zoomRatio: 1.1, clientX: 700, clientY: 500, }); const zoomedOut = computeZoomTransform({ geometry, currentTransform: zoomedIn, - zoomChange: -0.05, + zoomRatio: 0.95, clientX: 700, clientY: 500, }); - expect(zoomedOut.positionX).toBeCloseTo(-43); - expect(zoomedOut.positionY).toBeCloseTo(-32); - expect(zoomedOut.scale).toBeCloseTo(0.55); + expect(zoomedOut.positionX).toBeCloseTo(-18.6075); + expect(zoomedOut.positionY).toBeCloseTo(-13.905); + expect(zoomedOut.scale).toBeCloseTo(0.5225); }); test('anchors one axis or both axes according to overflow state', () => { @@ -151,20 +151,20 @@ describe('zoomUtils', () => { const widthAtEdge = computeZoomTransform({ geometry: widthOnlyGeometry, currentTransform: { positionX: 20, positionY: 150, scale: 1 }, - zoomChange: 0.1, + zoomRatio: 1.1, clientX: 750, clientY: 300, }); const widthAnchored = computeZoomTransform({ geometry: widthOnlyGeometry, currentTransform: widthAtEdge, - zoomChange: 0.1, + zoomRatio: 1.1, clientX: 750, clientY: 300, }); - expect(widthAnchored.positionX).toBeCloseTo(-71.25); - expect(widthAnchored.positionY).toBeCloseTo(127.10526315789474); + expect(widthAnchored.positionX).toBeCloseTo(-75); + expect(widthAnchored.positionY).toBeCloseTo(126.31578947368419); const bothAxisGeometry = makeGeometry({ viewportWidth: 800, @@ -177,19 +177,19 @@ describe('zoomUtils', () => { const bothAtEdge = computeZoomTransform({ geometry: bothAxisGeometry, currentTransform: getFitTransform(bothAxisGeometry), - zoomChange: 0.1, + zoomRatio: 1.1, clientX: 700, clientY: 500, }); const bothAnchored = computeZoomTransform({ geometry: bothAxisGeometry, currentTransform: bothAtEdge, - zoomChange: 0.1, + zoomRatio: 1.1, clientX: 700, clientY: 500, }); - expect(bothAnchored.positionX).toBeCloseTo(-78.75); - expect(bothAnchored.positionY).toBeCloseTo(-76.85185185185185); + expect(bothAnchored.positionX).toBeCloseTo(-70); + expect(bothAnchored.positionY).toBeCloseTo(-70.37037037037032); }); }); diff --git a/frontend/src/utils/zoomUtils.ts b/frontend/src/utils/zoomUtils.ts index ad3bae738..b5069690f 100644 --- a/frontend/src/utils/zoomUtils.ts +++ b/frontend/src/utils/zoomUtils.ts @@ -1,12 +1,15 @@ -export const ZOOM_FACTOR = 0.001; // Browser line-mode wheel events report lines, not pixels. This normalizes one // line-scroll notch to the commonly used 33px pixel delta. export const LINE_HEIGHT_MULTIPLIER = 33; +// Sensitivity for exponential wheel zoom: ratio = exp(normalizedDelta * sensitivity). +// A mouse notch (~100px delta) becomes ~10.5% scale change; trackpad stays smooth. +export const WHEEL_ZOOM_SENSITIVITY = 0.001; export const MAX_SCALE = 8; export const MIN_SCALE = 1; export const SCALE_EPSILON = 0.0001; export const MAX_FIT_RETRY_FRAMES = 12; -export const CONTROL_BUTTON_ZOOM_STEP = 0.5; +// Multiplicative zoom ratio for the zoom-in/out buttons (50% per click). +export const CONTROL_BUTTON_ZOOM_RATIO = 1.5; export type Size = { width: number; @@ -37,7 +40,7 @@ export type Geometry = { type ComputeZoomTransformOptions = { geometry: Geometry; currentTransform: TransformState; - zoomChange: number; + zoomRatio: number; clientX?: number; clientY?: number; }; @@ -231,12 +234,12 @@ export const getElementSize = (element: HTMLElement) => { export const computeZoomTransform = ({ geometry, currentTransform, - zoomChange, + zoomRatio, clientX, clientY, }: ComputeZoomTransformOptions): TransformState => { const desiredScale = clamp( - currentTransform.scale + zoomChange, + currentTransform.scale * zoomRatio, geometry.minScale, MAX_SCALE, ); From efa51e67ccb86f7e481428ec4a8837706032fed3 Mon Sep 17 00:00:00 2001 From: Vanshaj Poonia Date: Sat, 6 Jun 2026 22:33:35 +0530 Subject: [PATCH 16/16] Harden button zoom animation --- .../src/components/Media/ZoomableImage.tsx | 9 +- .../Media/__tests__/ZoomableImage.test.tsx | 201 ++++++++++++++++++ frontend/src/hooks/useZoomTransform.ts | 73 +++++-- 3 files changed, 266 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index ca5be23e5..c6f0720b5 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -69,7 +69,14 @@ export const ZoomableImage = forwardRef( >
{ + if ( + e.target === e.currentTarget && + e.propertyName === 'transform' + ) { + handleZoomTransitionEnd(); + } + }} style={{ position: 'relative', width: contentDimensions diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index 4ba514142..dab8a45bb 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -877,4 +877,205 @@ describe('ZoomableImage controlled transform behavior', () => { expectCurrentTransform(0, 0, 0.5); }); + + describe('control button zoom animation', () => { + // jsdom has no TransitionEvent constructor, and fireEvent.transitionEnd does + // not deliver `propertyName` to React's synthetic event. Build the event + // manually (mirroring firePointerEvent) so the handler's property filter + // sees a real value. + const fireTransitionEnd = (element: Element, propertyName: string) => { + const event = new Event('transitionend', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'propertyName', { + configurable: true, + value: propertyName, + }); + fireEvent(element, event); + }; + + const setupSceneWithRef = ( + viewportSize: { width: number; height: number }, + imageSize: { width: number; height: number }, + ) => { + const imageRef = createRef(); + + render( + , + ); + + const viewport = screen.getByTestId('zoom-viewport'); + const content = screen.getByTestId('zoom-content'); + const image = screen.getByAltText('test image'); + + mockElementRect( + viewport, + { ...viewportSize, left: 0, top: 0 }, + { clientWidth: viewportSize.width, clientHeight: viewportSize.height }, + ); + mockImageDimensions(image, imageSize); + fireEvent.load(image); + + return { imageRef, viewport, content, image }; + }; + + test('enables a smooth transition for a button zoom that changes scale', () => { + const { imageRef, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + act(() => { + imageRef.current?.zoomIn(); + }); + + expect(content.style.transition).toBe('transform 250ms ease-out'); + expect(getCurrentTransform().scale).toBeCloseTo(1.5); + }); + + test('switching to the wheel cancels the in-flight button transition', () => { + const { imageRef, viewport, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + act(() => { + imageRef.current?.zoomIn(); + }); + expect(content.style.transition).toBe('transform 250ms ease-out'); + + fireEvent.wheel(viewport, { deltaY: -100, clientX: 400, clientY: 300 }); + + // Wheel zoom must stay instant: the transition is cleared immediately. + expect(content.style.transition).toBe(''); + }); + + test('transitionend ends the animation so later transforms are instant', () => { + const { imageRef, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + act(() => { + imageRef.current?.zoomIn(); + }); + expect(content.style.transition).toBe('transform 250ms ease-out'); + + act(() => { + fireTransitionEnd(content, 'transform'); + }); + + expect(content.style.transition).toBe(''); + }); + + test('ignores unrelated transitionend events', () => { + const { imageRef, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + act(() => { + imageRef.current?.zoomIn(); + }); + + // A bubbled, non-transform transitionend must not clear the animation. + act(() => { + fireTransitionEnd(content, 'opacity'); + }); + + expect(content.style.transition).toBe('transform 250ms ease-out'); + }); + + test('does not animate a button zoom that is clamped at maximum scale', () => { + const { imageRef, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 800, height: 600 }, + ); + + // Saturate at MAX_SCALE; well past the 1.5^n needed to reach 8. + for (let i = 0; i < 12; i += 1) { + act(() => { + imageRef.current?.zoomIn(); + }); + } + + expect(getCurrentTransform().scale).toBeCloseTo(8); + // The final click could not change the transform, so no transition runs. + expect(content.style.transition).toBe(''); + }); + + test('does not animate a button zoom that is clamped at minimum scale', () => { + const { imageRef, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + // The image already fits at minimum scale; zooming out is a no-op. + act(() => { + imageRef.current?.zoomOut(); + }); + + expect(getCurrentTransform().scale).toBeCloseTo(1); + expect(content.style.transition).toBe(''); + }); + + test('ignores a transform transitionend bubbled from a child element', () => { + const { imageRef, content, image } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + act(() => { + imageRef.current?.zoomIn(); + }); + expect(content.style.transition).toBe('transform 250ms ease-out'); + + // A transform transitionend from the child bubbles to the content + // handler, but must be ignored (target !== currentTarget). + act(() => { + fireTransitionEnd(image, 'transform'); + }); + + expect(content.style.transition).toBe('transform 250ms ease-out'); + }); + + test('skips the transition when reduced motion is preferred', () => { + const matchMediaSpy = jest.spyOn(window, 'matchMedia').mockImplementation( + (query: string) => + ({ + matches: query.includes('prefers-reduced-motion'), + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + }) as unknown as MediaQueryList, + ); + + try { + const { imageRef, content } = setupSceneWithRef( + { width: 800, height: 600 }, + { width: 400, height: 300 }, + ); + + act(() => { + imageRef.current?.zoomIn(); + }); + + // The zoom still applies, but without the CSS transition. + expect(getCurrentTransform().scale).toBeCloseTo(1.5); + expect(content.style.transition).toBe(''); + } finally { + matchMediaSpy.mockRestore(); + } + }); + }); }); diff --git a/frontend/src/hooks/useZoomTransform.ts b/frontend/src/hooks/useZoomTransform.ts index 739dd94ad..a6d6e62e8 100644 --- a/frontend/src/hooks/useZoomTransform.ts +++ b/frontend/src/hooks/useZoomTransform.ts @@ -60,6 +60,9 @@ export const useZoomTransform = ({ const fitFrameRef = useRef(null); const fitRetryCountRef = useRef(0); const dragStateRef = useRef(null); + const buttonZoomTimeoutRef = useRef | null>( + null, + ); const rotationRef = useRef(rotation); const [transformState, setTransformState] = useState( transformStateRef.current, @@ -236,12 +239,21 @@ export const useZoomTransform = ({ [applyFitTransform, clearScheduledFit], ); + const clearButtonZoomAnimation = useCallback(() => { + setIsButtonZoom(false); + if (buttonZoomTimeoutRef.current !== null) { + clearTimeout(buttonZoomTimeoutRef.current); + buttonZoomTimeoutRef.current = null; + } + }, []); + const resetToFit = useCallback(() => { + clearButtonZoomAnimation(); isFitInitializedRef.current = false; hasUserInteractedRef.current = false; fitRetryCountRef.current = 0; scheduleFitTransform(false); - }, [scheduleFitTransform]); + }, [clearButtonZoomAnimation, scheduleFitTransform]); const zoomBy = useCallback( (zoomRatio: number, clientX?: number, clientY?: number) => { @@ -294,6 +306,7 @@ export const useZoomTransform = ({ const handleResize = () => { if (hasUserInteractedRef.current) { + clearButtonZoomAnimation(); applyTransform(transformStateRef.current); } else { resetToFit(); @@ -313,7 +326,7 @@ export const useZoomTransform = ({ resizeObserver?.disconnect(); window.removeEventListener('resize', handleResize); }; - }, [applyTransform, resetToFit]); + }, [applyTransform, clearButtonZoomAnimation, resetToFit]); useEffect(() => { const viewport = viewportRef.current; @@ -324,7 +337,7 @@ export const useZoomTransform = ({ e.stopPropagation(); e.stopImmediatePropagation(); - setIsButtonZoom(false); + clearButtonZoomAnimation(); const isLineMode = e.deltaMode === 1; const multiplier = isLineMode ? LINE_HEIGHT_MULTIPLIER : 1; @@ -342,11 +355,14 @@ export const useZoomTransform = ({ return () => { viewport.removeEventListener('wheel', handleWheel, true); }; - }, [zoomBy]); + }, [clearButtonZoomAnimation, zoomBy]); useEffect( () => () => { clearScheduledFit(); + if (buttonZoomTimeoutRef.current !== null) { + clearTimeout(buttonZoomTimeoutRef.current); + } }, [clearScheduledFit], ); @@ -375,6 +391,8 @@ export const useZoomTransform = ({ if (!overflow.width && !overflow.height) return false; + clearButtonZoomAnimation(); + dragStateRef.current = { pointerId, startClientX: clientX, @@ -386,7 +404,7 @@ export const useZoomTransform = ({ setIsPanning(true); return true; }, - [getGeometry], + [clearButtonZoomAnimation, getGeometry], ); const updateDrag = useCallback( @@ -469,17 +487,40 @@ export const useZoomTransform = ({ [endDrag], ); - const zoomIn = useCallback(() => { - setIsButtonZoom(true); - zoomBy(CONTROL_BUTTON_ZOOM_RATIO); - }, [zoomBy]); - const zoomOut = useCallback(() => { - setIsButtonZoom(true); - zoomBy(1 / CONTROL_BUTTON_ZOOM_RATIO); - }, [zoomBy]); - const handleZoomTransitionEnd = useCallback(() => { - setIsButtonZoom(false); - }, []); + const startButtonZoom = useCallback( + (ratio: number) => { + clearButtonZoomAnimation(); + + const before = transformStateRef.current; + const didApply = zoomBy(ratio); + const after = transformStateRef.current; + const didChange = + didApply && + (Math.abs(after.scale - before.scale) > SCALE_EPSILON || + Math.abs(after.positionX - before.positionX) > SCALE_EPSILON || + Math.abs(after.positionY - before.positionY) > SCALE_EPSILON); + + if (!didChange) return; + if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) + return; + + setIsButtonZoom(true); + buttonZoomTimeoutRef.current = setTimeout(() => { + setIsButtonZoom(false); + buttonZoomTimeoutRef.current = null; + }, 300); + }, + [clearButtonZoomAnimation, zoomBy], + ); + const zoomIn = useCallback( + () => startButtonZoom(CONTROL_BUTTON_ZOOM_RATIO), + [startButtonZoom], + ); + const zoomOut = useCallback( + () => startButtonZoom(1 / CONTROL_BUTTON_ZOOM_RATIO), + [startButtonZoom], + ); + const handleZoomTransitionEnd = clearButtonZoomAnimation; const contentDimensions = rawDimensions ? getEffectiveDimensions(