11import { useState , useCallback , useRef , useEffect } from 'react' ;
22
3+ interface Vector2 {
4+ x : number ;
5+ y : number ;
6+ }
7+
8+ interface Transforms {
9+ zoom : number ;
10+ pan : Vector2 ;
11+ }
12+
13+ // calculate pointer position relative to the image center
14+ //
15+ // use container rect & manually apply transforms as if we get two+ events quickly,
16+ // the second one might use an outdated image rect (before new transforms are applied)
17+ function getCursorOffsetFromImageCenter (
18+ event : React . MouseEvent ,
19+ containerRect : DOMRect ,
20+ pan : Vector2
21+ ) : Vector2 {
22+ return {
23+ x : containerRect . width / 2 - ( event . clientX - containerRect . x - pan . x ) ,
24+ y : containerRect . height / 2 - ( event . clientY - containerRect . y - pan . y ) ,
25+ } ;
26+ }
27+
328export const useImageGestures = ( active : boolean , step = 0.2 , min = 0.1 , max = 5 ) => {
4- const [ zoom , setZoom ] = useState < number > ( 1 ) ;
5- const [ pan , setPan ] = useState ( { translateX : 0 , translateY : 0 } ) ;
29+ const [ transforms , setTransforms ] = useState < Transforms > ( {
30+ zoom : 1 ,
31+ pan : { x : 0 , y : 0 } ,
32+ } ) ;
633 const [ cursor , setCursor ] = useState < 'grab' | 'grabbing' | 'initial' > (
734 active ? 'grab' : 'initial'
835 ) ;
@@ -11,29 +38,82 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5
1138 const initialDist = useRef < number > ( 0 ) ;
1239 const lastTapRef = useRef < number > ( 0 ) ;
1340
14- const onPointerDown = ( e : React . PointerEvent ) => {
15- if ( ! active ) return ;
16-
17- e . stopPropagation ( ) ;
18- ( e . target as HTMLElement ) . setPointerCapture ( e . pointerId ) ;
19-
20- const now = Date . now ( ) ;
21- if ( now - lastTapRef . current < 300 ) {
22- setZoom ( zoom === 1 ? 2 : 1 ) ;
23- setPan ( { translateX : 0 , translateY : 0 } ) ;
24- lastTapRef . current = 0 ;
25- return ;
26- }
27- lastTapRef . current = now ;
41+ const setZoom = useCallback ( ( next : number | ( ( prev : number ) => number ) ) => {
42+ setTransforms ( ( prev ) => {
43+ if ( typeof next === 'function' ) {
44+ return {
45+ ...prev ,
46+ zoom : next ( prev . zoom ) ,
47+ } ;
48+ }
49+ return {
50+ ...prev ,
51+ zoom : next ,
52+ } ;
53+ } ) ;
54+ } , [ ] ) ;
55+
56+ const setPan = useCallback ( ( next : Vector2 | ( ( prev : Vector2 ) => Vector2 ) ) => {
57+ setTransforms ( ( prev ) => {
58+ if ( typeof next === 'function' ) {
59+ return {
60+ ...prev ,
61+ pan : next ( prev . pan ) ,
62+ } ;
63+ }
64+ return {
65+ ...prev ,
66+ pan : next ,
67+ } ;
68+ } ) ;
69+ } , [ ] ) ;
70+
71+ const resetTransforms = useCallback ( ( ) => {
72+ setTransforms ( { zoom : 1 , pan : { x : 0 , y : 0 } } ) ;
73+ } , [ ] ) ;
74+
75+ const onPointerDown = useCallback (
76+ ( e : React . PointerEvent ) => {
77+ if ( ! active ) return ;
78+
79+ e . stopPropagation ( ) ;
80+ const target = e . target as HTMLElement ;
81+ target . setPointerCapture ( e . pointerId ) ;
82+
83+ const now = Date . now ( ) ;
84+ if ( now - lastTapRef . current < 300 ) {
85+ const container = target . parentElement ?? target ;
86+ const containerRect = container . getBoundingClientRect ( ) ;
87+ setTransforms ( ( prev ) => {
88+ if ( prev . zoom !== 1 ) {
89+ return { zoom : 1 , pan : { x : 0 , y : 0 } } ;
90+ }
91+
92+ // pan using the pointer's offset relative to the center of the image
93+ const offset = getCursorOffsetFromImageCenter ( e , containerRect , prev . pan ) ;
94+ return {
95+ zoom : 2 ,
96+ pan : {
97+ x : offset . x + prev . pan . x ,
98+ y : offset . y + prev . pan . y ,
99+ } ,
100+ } ;
101+ } ) ;
102+ lastTapRef . current = 0 ;
103+ return ;
104+ }
105+ lastTapRef . current = now ;
28106
29- activePointers . current . set ( e . pointerId , { x : e . clientX , y : e . clientY } ) ;
30- setCursor ( 'grabbing' ) ;
107+ activePointers . current . set ( e . pointerId , { x : e . clientX , y : e . clientY } ) ;
108+ setCursor ( 'grabbing' ) ;
31109
32- if ( activePointers . current . size === 2 ) {
33- const points = Array . from ( activePointers . current . values ( ) ) ;
34- initialDist . current = Math . hypot ( points [ 0 ] . x - points [ 1 ] . x , points [ 0 ] . y - points [ 1 ] . y ) ;
35- }
36- } ;
110+ if ( activePointers . current . size === 2 ) {
111+ const points = Array . from ( activePointers . current . values ( ) ) ;
112+ initialDist . current = Math . hypot ( points [ 0 ] . x - points [ 1 ] . x , points [ 0 ] . y - points [ 1 ] . y ) ;
113+ }
114+ } ,
115+ [ active ]
116+ ) ;
37117
38118 const handlePointerMove = useCallback (
39119 ( e : PointerEvent ) => {
@@ -53,12 +133,12 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5
53133
54134 if ( activePointers . current . size === 1 ) {
55135 setPan ( ( p ) => ( {
56- translateX : p . translateX + e . movementX ,
57- translateY : p . translateY + e . movementY ,
136+ x : p . x + e . movementX ,
137+ y : p . y + e . movementY ,
58138 } ) ) ;
59139 }
60140 } ,
61- [ min , max ]
141+ [ setZoom , min , max , setPan ]
62142 ) ;
63143
64144 const handlePointerUp = useCallback (
@@ -86,20 +166,62 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5
86166 } , [ handlePointerMove , handlePointerUp ] ) ;
87167
88168 const zoomIn = useCallback ( ( ) => {
89- setZoom ( ( z ) => Math . min ( z + step , max ) ) ;
90- } , [ step , max ] ) ;
169+ setZoom ( ( z ) => Math . min ( z * ( 1 + step ) , max ) ) ;
170+ } , [ setZoom , step , max ] ) ;
91171
92172 const zoomOut = useCallback ( ( ) => {
93- setZoom ( ( z ) => Math . max ( z - step , min ) ) ;
94- } , [ step , min ] ) ;
173+ setZoom ( ( z ) => Math . max ( z / ( 1 + step ) , min ) ) ;
174+ } , [ setZoom , step , min ] ) ;
175+
176+ const handleWheel = useCallback (
177+ ( e : React . WheelEvent ) => {
178+ const { deltaY } = e ;
179+ // Mouse wheel scrolls only by integer delta values, therefore
180+ // If deltaY is an integer, then it's a mouse wheel action
181+ if ( ! Number . isInteger ( deltaY ) ) {
182+ // If it's not an integer, then it's a touchpad action, do nothing and let the browser handle the zooming
183+ return ;
184+ }
185+
186+ // the wheel handler is attached to the container element, not the image
187+ const containerRect = e . currentTarget . getBoundingClientRect ( ) ;
188+
189+ setTransforms ( ( prev ) => {
190+ // calculate multiplicative zoom
191+ const newZoom =
192+ deltaY < 0
193+ ? Math . min ( prev . zoom * ( 1 + step ) , max )
194+ : Math . max ( prev . zoom / ( 1 + step ) , min ) ;
195+ const zoomMult = newZoom / prev . zoom - 1 ;
196+
197+ // calculate pointer position relative to the image center
198+ //
199+ // manually apply transforms as if we get two+ wheel events quickly,
200+ // the second one might use an outdated image rect (before new transforms are applied)
201+ const offset = getCursorOffsetFromImageCenter ( e , containerRect , prev . pan ) ;
202+
203+ return {
204+ zoom : newZoom ,
205+ // magic math that happens to do what i want it to do
206+ pan : {
207+ x : offset . x * zoomMult + prev . pan . x ,
208+ y : offset . y * zoomMult + prev . pan . y ,
209+ } ,
210+ } ;
211+ } ) ;
212+ } ,
213+ [ max , min , step ]
214+ ) ;
95215
96216 return {
97- zoom,
98- pan,
217+ transforms,
99218 cursor,
100219 onPointerDown,
220+ handleWheel,
101221 setZoom,
102222 setPan,
223+ setTransforms,
224+ resetTransforms,
103225 zoomIn,
104226 zoomOut,
105227 } ;
0 commit comments