@@ -17,7 +17,16 @@ const ZOOM_FACTOR = 0.001;
1717const LINE_HEIGHT_MULTIPLIER = 33 ;
1818const MAX_SCALE = 8 ;
1919const MIN_SCALE = 1 ;
20- const PAN_PADDING = 20 ;
20+
21+ type Size = {
22+ width : number ;
23+ height : number ;
24+ } ;
25+
26+ type OverflowState = {
27+ width : boolean ;
28+ height : boolean ;
29+ } ;
2130
2231type AnimationType =
2332 | 'easeOut'
@@ -48,6 +57,36 @@ export interface ZoomableImageRef {
4857 reset : ( ) => void ;
4958}
5059
60+ const getCenteredAxisPosition = ( viewportSize : number , scaledSize : number ) =>
61+ ( viewportSize - scaledSize ) / 2 ;
62+
63+ const clamp = ( value : number , min : number , max : number ) =>
64+ Math . min ( Math . max ( value , min ) , max ) ;
65+
66+ const clampOverflowAxisPosition = (
67+ position : number ,
68+ viewportSize : number ,
69+ scaledSize : number ,
70+ ) => {
71+ const minPosition = viewportSize - scaledSize ;
72+ const maxPosition = 0 ;
73+
74+ return clamp ( position , minPosition , maxPosition ) ;
75+ } ;
76+
77+ const getAxisPosition = (
78+ anchoredPosition : number ,
79+ viewportSize : number ,
80+ scaledSize : number ,
81+ isOverflowingAxis : boolean ,
82+ ) => {
83+ const centeredPosition = getCenteredAxisPosition ( viewportSize , scaledSize ) ;
84+
85+ return isOverflowingAxis
86+ ? clampOverflowAxisPosition ( anchoredPosition , viewportSize , scaledSize )
87+ : centeredPosition ;
88+ } ;
89+
5190export const ZoomableImage = forwardRef < ZoomableImageRef , ZoomableImageProps > (
5291 ( { imagePath, alt, rotation, resetSignal } , ref ) => {
5392 const transformRef = useRef < ReactZoomPanPinchRef > ( null ) ;
@@ -72,29 +111,46 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
72111 [ ] ,
73112 ) ;
74113
75- const getOverflowState = useCallback (
76- ( scale : number , viewportWidth : number , viewportHeight : number ) => {
77- if ( ! imageRef . current ) return { width : false , height : false } ;
114+ const getScaledDimensions = useCallback (
115+ ( scale : number ) : Size | null => {
116+ if ( ! imageRef . current ) return null ;
117+
118+ const renderedWidth = imageRef . current . clientWidth ;
119+ const renderedHeight = imageRef . current . clientHeight ;
78120
79- const imgElement = imageRef . current ;
80- const renderedWidth = imgElement . clientWidth ;
81- const renderedHeight = imgElement . clientHeight ;
121+ if ( ! renderedWidth || ! renderedHeight ) return null ;
82122
83123 const effectiveDims = getEffectiveDimensions (
84124 renderedWidth ,
85125 renderedHeight ,
86126 ) ;
87- const scaledWidth = effectiveDims . width * scale ;
88- const scaledHeight = effectiveDims . height * scale ;
89127
90128 return {
91- width : scaledWidth > viewportWidth ,
92- height : scaledHeight > viewportHeight ,
129+ width : effectiveDims . width * scale ,
130+ height : effectiveDims . height * scale ,
93131 } ;
94132 } ,
95133 [ getEffectiveDimensions ] ,
96134 ) ;
97135
136+ const getOverflowState = useCallback (
137+ (
138+ scale : number ,
139+ viewportWidth : number ,
140+ viewportHeight : number ,
141+ ) : OverflowState => {
142+ const scaledDimensions = getScaledDimensions ( scale ) ;
143+
144+ if ( ! scaledDimensions ) return { width : false , height : false } ;
145+
146+ return {
147+ width : scaledDimensions . width > viewportWidth ,
148+ height : scaledDimensions . height > viewportHeight ,
149+ } ;
150+ } ,
151+ [ getScaledDimensions ] ,
152+ ) ;
153+
98154 const getViewportElement = useCallback (
99155 ( ) =>
100156 transformRef . current ?. instance ?. wrapperComponent ??
@@ -130,8 +186,8 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
130186 renderedW = renderedH * imgAspect ;
131187 }
132188
133- const centerX = ( wrapperRect . width - renderedW ) / 2 ;
134- const centerY = ( wrapperRect . height - renderedH ) / 2 ;
189+ const centerX = getCenteredAxisPosition ( wrapperRect . width , renderedW ) ;
190+ const centerY = getCenteredAxisPosition ( wrapperRect . height , renderedH ) ;
135191
136192 transformRef . current . setTransform (
137193 centerX ,
@@ -186,24 +242,33 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
186242 } , [ imagePath , handleReset ] ) ;
187243
188244 useEffect ( ( ) => {
189- const wrapperElement = wheelAreaRef . current ;
190- if ( ! wrapperElement ) return ;
245+ const wheelElement = wheelAreaRef . current ;
246+ if ( ! wheelElement ) return ;
191247
192- let cachedWrapperRect = wrapperElement . getBoundingClientRect ( ) ;
248+ let cachedViewportRect =
249+ getViewportElement ( ) ?. getBoundingClientRect ( ) ??
250+ wheelElement . getBoundingClientRect ( ) ;
193251
194252 const resizeObserver = new ResizeObserver ( ( ) => {
195- cachedWrapperRect = wrapperElement . getBoundingClientRect ( ) ;
253+ cachedViewportRect =
254+ getViewportElement ( ) ?. getBoundingClientRect ( ) ??
255+ wheelElement . getBoundingClientRect ( ) ;
196256 } ) ;
197257
198- resizeObserver . observe ( wrapperElement ) ;
258+ resizeObserver . observe ( wheelElement ) ;
199259
200260 const handleWheelInterceptor = ( e : WheelEvent ) => {
261+ e . preventDefault ( ) ;
262+ e . stopPropagation ( ) ;
263+ e . stopImmediatePropagation ( ) ;
264+
201265 if ( ! imageRef . current || ! transformRef . current ) return ;
202266
203267 const transformState = transformRef . current . instance . transformState ;
268+ const viewportElement = getViewportElement ( ) ?? wheelElement ;
204269
205- cachedWrapperRect = wrapperElement . getBoundingClientRect ( ) ;
206- const wrapperRect = cachedWrapperRect ;
270+ cachedViewportRect = viewportElement . getBoundingClientRect ( ) ;
271+ const viewportRect = cachedViewportRect ;
207272 const imageRect = imageRef . current . getBoundingClientRect ( ) ;
208273 const mouseX = e . clientX - imageRect . left ;
209274 const mouseY = e . clientY - imageRect . top ;
@@ -226,54 +291,65 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
226291 Math . min ( MAX_SCALE , currentScale + zoomChange ) ,
227292 ) ;
228293
229- const baseW = imageRef . current . clientWidth ;
230- const baseH = imageRef . current . clientHeight ;
231- const effectiveDims = getEffectiveDimensions ( baseW , baseH ) ;
232- const nextW = effectiveDims . width * newScale ;
233- const nextH = effectiveDims . height * newScale ;
294+ const scaledDimensions = getScaledDimensions ( newScale ) ;
295+ if ( ! scaledDimensions ) return ;
234296
235297 const newOverflow = getOverflowState (
236298 newScale ,
237- wrapperRect . width ,
238- wrapperRect . height ,
299+ viewportRect . width ,
300+ viewportRect . height ,
239301 ) ;
240302
241- const centeredX = ( wrapperRect . width - nextW ) / 2 ;
242- const centeredY = ( wrapperRect . height - nextH ) / 2 ;
303+ const shouldRecenter =
304+ newScale === MIN_SCALE || ( ! newOverflow . width && ! newOverflow . height ) ;
305+
306+ const centeredX = getCenteredAxisPosition (
307+ viewportRect . width ,
308+ scaledDimensions . width ,
309+ ) ;
310+ const centeredY = getCenteredAxisPosition (
311+ viewportRect . height ,
312+ scaledDimensions . height ,
313+ ) ;
243314 const ratio = currentScale > 0 ? newScale / currentScale : 1 ;
244- const mouseViewportX = e . clientX - wrapperRect . left ;
245- const mouseViewportY = e . clientY - wrapperRect . top ;
315+ const mouseViewportX = e . clientX - viewportRect . left ;
316+ const mouseViewportY = e . clientY - viewportRect . top ;
246317 const anchoredX =
247318 mouseViewportX - ( mouseViewportX - transformState . positionX ) * ratio ;
248319 const anchoredY =
249320 mouseViewportY - ( mouseViewportY - transformState . positionY ) * ratio ;
250321
251- const targetX =
252- isOverImage && newOverflow . width ? anchoredX : centeredX ;
253- const targetY =
254- isOverImage && newOverflow . height ? anchoredY : centeredY ;
255-
256- e . preventDefault ( ) ;
257- e . stopPropagation ( ) ;
322+ const targetX = shouldRecenter
323+ ? centeredX
324+ : getAxisPosition (
325+ isOverImage ? anchoredX : centeredX ,
326+ viewportRect . width ,
327+ scaledDimensions . width ,
328+ newOverflow . width ,
329+ ) ;
330+ const targetY = shouldRecenter
331+ ? centeredY
332+ : getAxisPosition (
333+ isOverImage ? anchoredY : centeredY ,
334+ viewportRect . height ,
335+ scaledDimensions . height ,
336+ newOverflow . height ,
337+ ) ;
258338
259339 setIsOverflowing ( newOverflow . width || newOverflow . height ) ;
260340 transformRef . current . setTransform ( targetX , targetY , newScale , 0 ) ;
261341 } ;
262342
263- wrapperElement . addEventListener ( 'wheel' , handleWheelInterceptor , {
343+ wheelElement . addEventListener ( 'wheel' , handleWheelInterceptor , {
264344 passive : false ,
265345 capture : true ,
266346 } ) ;
267347
268348 return ( ) => {
269349 resizeObserver . disconnect ( ) ;
270- wrapperElement . removeEventListener (
271- 'wheel' ,
272- handleWheelInterceptor ,
273- true ,
274- ) ;
350+ wheelElement . removeEventListener ( 'wheel' , handleWheelInterceptor , true ) ;
275351 } ;
276- } , [ getEffectiveDimensions , getOverflowState ] ) ;
352+ } , [ getOverflowState , getScaledDimensions , getViewportElement ] ) ;
277353
278354 return (
279355 < div ref = { wheelAreaRef } className = "h-full w-full" >
@@ -330,38 +406,19 @@ export const ZoomableImage = forwardRef<ZoomableImageRef, ZoomableImageProps>(
330406 const scaledW = effectiveDims . width * scale ;
331407 const scaledH = effectiveDims . height * scale ;
332408
333- const limitLeft = - scaledW + PAN_PADDING ;
334- const limitRight = viewW - PAN_PADDING ;
335- const limitTop = - scaledH + PAN_PADDING ;
336- const limitBottom = viewH - PAN_PADDING ;
337- const centeredX = ( viewW - scaledW ) / 2 ;
338- const centeredY = ( viewH - scaledH ) / 2 ;
339-
340- let finalX = overflow . width ? positionX : centeredX ;
341- let finalY = overflow . height ? positionY : centeredY ;
342- let clamped =
343- ( ! overflow . width && positionX !== centeredX ) ||
344- ( ! overflow . height && positionY !== centeredY ) ;
345-
346- if ( overflow . width ) {
347- if ( positionX < limitLeft ) {
348- finalX = limitLeft ;
349- clamped = true ;
350- } else if ( positionX > limitRight ) {
351- finalX = limitRight ;
352- clamped = true ;
353- }
354- }
355-
356- if ( overflow . height ) {
357- if ( positionY < limitTop ) {
358- finalY = limitTop ;
359- clamped = true ;
360- } else if ( positionY > limitBottom ) {
361- finalY = limitBottom ;
362- clamped = true ;
363- }
364- }
409+ const finalX = getAxisPosition (
410+ positionX ,
411+ viewW ,
412+ scaledW ,
413+ overflow . width ,
414+ ) ;
415+ const finalY = getAxisPosition (
416+ positionY ,
417+ viewH ,
418+ scaledH ,
419+ overflow . height ,
420+ ) ;
421+ const clamped = positionX !== finalX || positionY !== finalY ;
365422
366423 if ( clamped ) {
367424 ref . setTransform ( finalX , finalY , scale , 0 ) ;
0 commit comments