@@ -29,6 +29,12 @@ type OverflowState = {
2929 height : boolean ;
3030} ;
3131
32+ type FitTransform = {
33+ positionX : number ;
34+ positionY : number ;
35+ scale : number ;
36+ } ;
37+
3238type AnimationType =
3339 | 'easeOut'
3440 | 'linear'
@@ -128,11 +134,33 @@ const getFirstViewportEdgeScale = (
128134 ) ,
129135 ) ;
130136
137+ const getFitTransform = (
138+ baseDimensions : Size ,
139+ viewportWidth : number ,
140+ viewportHeight : number ,
141+ ) : FitTransform | null => {
142+ if ( ! viewportWidth || ! viewportHeight ) return null ;
143+
144+ const scale = getMinimumScale ( baseDimensions , viewportWidth , viewportHeight ) ;
145+ const scaledWidth = baseDimensions . width * scale ;
146+ const scaledHeight = baseDimensions . height * scale ;
147+
148+ return {
149+ positionX : getCenteredAxisPosition ( viewportWidth , scaledWidth ) ,
150+ positionY : getCenteredAxisPosition ( viewportHeight , scaledHeight ) ,
151+ scale,
152+ } ;
153+ } ;
154+
131155export const ZoomableImage = forwardRef < ZoomableImageRef , ZoomableImageProps > (
132156 ( { imagePath, alt, rotation, resetSignal } , ref ) => {
133157 const transformRef = useRef < ReactZoomPanPinchRef > ( null ) ;
134158 const wheelAreaRef = useRef < HTMLDivElement > ( null ) ;
135159 const imageRef = useRef < HTMLImageElement > ( null ) ;
160+ const isFitInitializedRef = useRef ( false ) ;
161+ const hasUserInteractedRef = useRef ( false ) ;
162+ const fitTransformRef = useRef < FitTransform | null > ( null ) ;
163+ const fitFrameRef = useRef < number | null > ( null ) ;
136164 const [ isOverflowing , setIsOverflowing ] = useState ( false ) ;
137165 const [ minScale , setMinScale ] = useState ( MIN_SCALE ) ;
138166 const rotationRef = useRef ( rotation ) ;
@@ -143,10 +171,6 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
143171 ) ;
144172 } , [ ] ) ;
145173
146- useEffect ( ( ) => {
147- rotationRef . current = rotation ;
148- } , [ rotation ] ) ;
149-
150174 const getEffectiveDimensions = useCallback (
151175 ( width : number , height : number ) => {
152176 const normalizedRotation = ( ( rotationRef . current % 360 ) + 360 ) % 360 ;
@@ -211,7 +235,18 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
211235 [ ] ,
212236 ) ;
213237
214- const handleReset = useCallback (
238+ const clearScheduledFit = useCallback ( ( ) => {
239+ if ( fitFrameRef . current === null ) return ;
240+
241+ if ( typeof window . cancelAnimationFrame === 'function' ) {
242+ window . cancelAnimationFrame ( fitFrameRef . current ) ;
243+ } else {
244+ window . clearTimeout ( fitFrameRef . current ) ;
245+ }
246+ fitFrameRef . current = null ;
247+ } , [ ] ) ;
248+
249+ const applyFitTransform = useCallback (
215250 ( duration = 200 , animationType : AnimationType = 'easeOut' ) => {
216251 const viewportElement = getViewportElement ( ) ;
217252 if ( ! transformRef . current || ! viewportElement || ! imageRef . current )
@@ -222,21 +257,22 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
222257
223258 if ( ! baseDimensions ) return ;
224259
225- const scale = getMinimumScale (
260+ const fitTransform = getFitTransform (
226261 baseDimensions ,
227262 wrapperRect . width ,
228263 wrapperRect . height ,
229264 ) ;
230- const renderedW = baseDimensions . width * scale ;
231- const renderedH = baseDimensions . height * scale ;
232- const centerX = getCenteredAxisPosition ( wrapperRect . width , renderedW ) ;
233- const centerY = getCenteredAxisPosition ( wrapperRect . height , renderedH ) ;
234265
235- setMinimumScale ( scale ) ;
266+ if ( ! fitTransform ) return ;
267+
268+ fitTransformRef . current = fitTransform ;
269+ isFitInitializedRef . current = true ;
270+ hasUserInteractedRef . current = false ;
271+ setMinimumScale ( fitTransform . scale ) ;
236272 transformRef . current . setTransform (
237- centerX ,
238- centerY ,
239- scale ,
273+ fitTransform . positionX ,
274+ fitTransform . positionY ,
275+ fitTransform . scale ,
240276 duration ,
241277 animationType ,
242278 ) ;
@@ -245,16 +281,56 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
245281 [ getBaseDimensions , getViewportElement , setMinimumScale ] ,
246282 ) ;
247283
284+ const scheduleFitTransform = useCallback (
285+ ( duration = 200 , animationType : AnimationType = 'easeOut' ) => {
286+ clearScheduledFit ( ) ;
287+
288+ const scheduleFrame : ( callback : FrameRequestCallback ) => number =
289+ typeof window . requestAnimationFrame === 'function'
290+ ? window . requestAnimationFrame . bind ( window )
291+ : ( callback ) =>
292+ window . setTimeout ( ( ) => callback ( performance . now ( ) ) , 0 ) ;
293+
294+ fitFrameRef . current = scheduleFrame ( ( ) => {
295+ fitFrameRef . current = null ;
296+ applyFitTransform ( duration , animationType ) ;
297+ } ) ;
298+ } ,
299+ [ applyFitTransform , clearScheduledFit ] ,
300+ ) ;
301+
302+ const handleReset = useCallback (
303+ ( duration = 200 , animationType : AnimationType = 'easeOut' ) => {
304+ isFitInitializedRef . current = false ;
305+ hasUserInteractedRef . current = false ;
306+ scheduleFitTransform ( duration , animationType ) ;
307+ } ,
308+ [ scheduleFitTransform ] ,
309+ ) ;
310+
248311 useImperativeHandle ( ref , ( ) => ( {
249- zoomIn : ( ) => transformRef . current ?. zoomIn ( ) ,
250- zoomOut : ( ) => transformRef . current ?. zoomOut ( ) ,
312+ zoomIn : ( ) => {
313+ hasUserInteractedRef . current = true ;
314+ transformRef . current ?. zoomIn ( ) ;
315+ } ,
316+ zoomOut : ( ) => {
317+ hasUserInteractedRef . current = true ;
318+ transformRef . current ?. zoomOut ( ) ;
319+ } ,
251320 reset : ( ) => handleReset ( ) ,
252321 } ) ) ;
253322
254323 useEffect ( ( ) => {
255324 handleReset ( ) ;
256325 } , [ resetSignal , handleReset ] ) ;
257326
327+ useEffect ( ( ) => {
328+ rotationRef . current = rotation ;
329+ isFitInitializedRef . current = false ;
330+ hasUserInteractedRef . current = false ;
331+ scheduleFitTransform ( 0 ) ;
332+ } , [ rotation , scheduleFitTransform ] ) ;
333+
258334 useEffect ( ( ) => {
259335 const viewportElement = getViewportElement ( ) ;
260336 if ( ! transformRef . current || ! viewportElement || ! imageRef . current )
@@ -282,12 +358,15 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
282358
283359 useEffect ( ( ) => {
284360 setIsOverflowing ( false ) ;
361+ isFitInitializedRef . current = false ;
362+ hasUserInteractedRef . current = false ;
363+ fitTransformRef . current = null ;
285364
286365 const img = imageRef . current ;
287366 if ( ! img ) return ;
288367
289368 const handleImageLoad = ( ) => {
290- handleReset ( 0 ) ;
369+ scheduleFitTransform ( 0 ) ;
291370 } ;
292371
293372 if ( img . complete && img . naturalWidth > 0 ) {
@@ -296,7 +375,7 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
296375 img . addEventListener ( 'load' , handleImageLoad ) ;
297376 return ( ) => img . removeEventListener ( 'load' , handleImageLoad ) ;
298377 }
299- } , [ imagePath , handleReset ] ) ;
378+ } , [ imagePath , scheduleFitTransform ] ) ;
300379
301380 useEffect ( ( ) => {
302381 const wheelElement = wheelAreaRef . current ;
@@ -313,13 +392,20 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
313392
314393 const baseDimensions = getBaseDimensions ( ) ;
315394 if ( baseDimensions ) {
316- setMinimumScale (
317- getMinimumScale (
318- baseDimensions ,
319- cachedViewportRect . width ,
320- cachedViewportRect . height ,
321- ) ,
395+ const fitTransform = getFitTransform (
396+ baseDimensions ,
397+ cachedViewportRect . width ,
398+ cachedViewportRect . height ,
322399 ) ;
400+
401+ if ( fitTransform ) {
402+ fitTransformRef . current = fitTransform ;
403+ setMinimumScale ( fitTransform . scale ) ;
404+
405+ if ( ! hasUserInteractedRef . current ) {
406+ scheduleFitTransform ( 0 ) ;
407+ }
408+ }
323409 }
324410 } ) ;
325411
@@ -329,6 +415,7 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
329415 e . preventDefault ( ) ;
330416 e . stopPropagation ( ) ;
331417 e . stopImmediatePropagation ( ) ;
418+ clearScheduledFit ( ) ;
332419
333420 if ( ! imageRef . current || ! transformRef . current ) return ;
334421
@@ -356,12 +443,35 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
356443 const baseDimensions = getBaseDimensions ( ) ;
357444 if ( ! baseDimensions ) return ;
358445
446+ const fitTransform = getFitTransform (
447+ baseDimensions ,
448+ viewportRect . width ,
449+ viewportRect . height ,
450+ ) ;
451+ if ( ! fitTransform ) return ;
452+
359453 const minimumScale = getMinimumScale (
360454 baseDimensions ,
361455 viewportRect . width ,
362456 viewportRect . height ,
363457 ) ;
364- const currentScale = transformState . scale ;
458+ fitTransformRef . current = fitTransform ;
459+
460+ const shouldUseFitTransform =
461+ ! isFitInitializedRef . current &&
462+ fitTransform . scale < MIN_SCALE - SCALE_EPSILON &&
463+ transformState . scale > fitTransform . scale + SCALE_EPSILON &&
464+ Math . abs ( transformState . positionX ) < SCALE_EPSILON &&
465+ Math . abs ( transformState . positionY ) < SCALE_EPSILON ;
466+ const currentScale = shouldUseFitTransform
467+ ? fitTransform . scale
468+ : transformState . scale ;
469+ const currentPositionX = shouldUseFitTransform
470+ ? fitTransform . positionX
471+ : transformState . positionX ;
472+ const currentPositionY = shouldUseFitTransform
473+ ? fitTransform . positionY
474+ : transformState . positionY ;
365475 const desiredScale = Math . max (
366476 minimumScale ,
367477 Math . min ( MAX_SCALE , currentScale + zoomChange ) ,
@@ -423,9 +533,9 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
423533 const mouseViewportX = e . clientX - viewportRect . left ;
424534 const mouseViewportY = e . clientY - viewportRect . top ;
425535 const anchoredX =
426- mouseViewportX - ( mouseViewportX - transformState . positionX ) * ratio ;
536+ mouseViewportX - ( mouseViewportX - currentPositionX ) * ratio ;
427537 const anchoredY =
428- mouseViewportY - ( mouseViewportY - transformState . positionY ) * ratio ;
538+ mouseViewportY - ( mouseViewportY - currentPositionY ) * ratio ;
429539 const shouldAnchorX =
430540 isOverImage && currentTouchesViewport . width && newOverflow . width ;
431541 const shouldAnchorY =
@@ -449,6 +559,8 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
449559 ) ;
450560
451561 setIsOverflowing ( newOverflow . width || newOverflow . height ) ;
562+ isFitInitializedRef . current = true ;
563+ hasUserInteractedRef . current = true ;
452564 transformRef . current . setTransform ( targetX , targetY , newScale , 0 ) ;
453565 } ;
454566
@@ -458,14 +570,17 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
458570 } ) ;
459571
460572 return ( ) => {
573+ clearScheduledFit ( ) ;
461574 resizeObserver . disconnect ( ) ;
462575 wheelElement . removeEventListener ( 'wheel' , handleWheelInterceptor , true ) ;
463576 } ;
464577 } , [
578+ clearScheduledFit ,
465579 getBaseDimensions ,
466580 getOverflowState ,
467581 getScaledDimensions ,
468582 getViewportElement ,
583+ scheduleFitTransform ,
469584 setMinimumScale ,
470585 ] ) ;
471586
@@ -505,6 +620,7 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
505620 setIsOverflowing ( overflow . width || overflow . height ) ;
506621 } }
507622 onPanning = { ( ref ) => {
623+ hasUserInteractedRef . current = true ;
508624 const scale = ref . state . scale ;
509625 const wrapper = getViewportElement ( ) ;
510626 if ( ! wrapper || ! imageRef . current ) return ;
0 commit comments