diff --git a/frontend/src/components/Media/ZoomableImage.tsx b/frontend/src/components/Media/ZoomableImage.tsx index c6f0720b5..b2345822a 100644 --- a/frontend/src/components/Media/ZoomableImage.tsx +++ b/frontend/src/components/Media/ZoomableImage.tsx @@ -39,6 +39,7 @@ export const ZoomableImage = forwardRef( zoomIn, zoomOut, reset, + isFitReady } = useZoomTransform({ imagePath, rotation, resetSignal }); useImperativeHandle( @@ -113,6 +114,8 @@ export const ZoomableImage = forwardRef( transform: `rotate(${rotation}deg)`, transformOrigin: 'center center', pointerEvents: 'none', + opacity: isFitReady ? 1 : 0, + transition: isFitReady ? 'opacity 120ms ease-out' : 'none', }} /> diff --git a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx index dab8a45bb..a71295861 100644 --- a/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx +++ b/frontend/src/components/Media/__tests__/ZoomableImage.test.tsx @@ -697,6 +697,31 @@ describe('ZoomableImage controlled transform behavior', () => { expect(image.style.height).toBe(''); }); + test('keeps the image hidden until the fit transform is ready, then fades it in', () => { + const { flushNextFrame } = setupManualAnimationFrames(); + + renderZoomableImage(); + + const viewport = screen.getByTestId('zoom-viewport'); + const image = screen.getByAltText('test image'); + + expect(image.style.opacity).toBe('0'); + expect(image.style.transition).toBe('none'); + + mockElementRect( + viewport, + { width: 500, height: 400, left: 0, top: 0 }, + { clientWidth: 500, clientHeight: 400 }, + ); + mockImageDimensions(image, { width: 1000, height: 800 }); + + fireEvent.load(image); + flushNextFrame(); + + expect(image.style.opacity).toBe('1'); + expect(image.style.transition).toBe('opacity 120ms ease-out'); + }); + 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 index a6d6e62e8..ca2398582 100644 --- a/frontend/src/hooks/useZoomTransform.ts +++ b/frontend/src/hooks/useZoomTransform.ts @@ -71,6 +71,7 @@ export const useZoomTransform = ({ const [isOverflowing, setIsOverflowing] = useState(false); const [isPanning, setIsPanning] = useState(false); const [isButtonZoom, setIsButtonZoom] = useState(false); + const [isFitReady, setIsFitReady] = useState(false); const setRawImageDimensions = useCallback((dimensions: Size | null) => { rawDimensionsRef.current = dimensions; @@ -196,6 +197,7 @@ export const useZoomTransform = ({ isFitInitializedRef.current = true; hasUserInteractedRef.current = false; fitRetryCountRef.current = 0; + setIsFitReady(true); } return didApply; @@ -230,9 +232,13 @@ export const useZoomTransform = ({ fitFrameRef.current = null; const applied = applyFitTransform(); - if (!applied && fitRetryCountRef.current < MAX_FIT_RETRY_FRAMES) { - fitRetryCountRef.current += 1; - scheduleFitTransform(false); + if (!applied) { + if (fitRetryCountRef.current < MAX_FIT_RETRY_FRAMES) { + fitRetryCountRef.current += 1; + scheduleFitTransform(false); + } else { + setIsFitReady(true); + } } }); }, @@ -297,6 +303,7 @@ export const useZoomTransform = ({ useEffect(() => { setIsOverflowing(false); setRawImageDimensions(null); + setIsFitReady(false); resetToFit(); }, [imagePath, resetToFit, setRawImageDimensions]); @@ -556,5 +563,6 @@ export const useZoomTransform = ({ zoomIn, zoomOut, reset: resetToFit, + isFitReady, }; };