@@ -20,13 +20,21 @@ const NO_TRANSITION_PROPS = {
2020
2121const DEFAULT_INERTIA = 300 ;
2222const INERTIA_EASING = t => 1 - ( 1 - t ) * ( 1 - t ) ;
23+ // One-finger double-tap-drag-to-zoom gesture (matches Google Maps).
24+ // Empirical values chosen to feel snappy but reject accidental triggers.
25+ const DOUBLE_TAP_DRAG_INTERVAL = 500 ;
26+ const DOUBLE_TAP_DRAG_MAX_TAP_DURATION = 350 ;
27+ const DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE = 28 ;
28+ const DOUBLE_TAP_DRAG_START_THRESHOLD = 1 ;
29+ const DOUBLE_TAP_DRAG_PIXELS_PER_ZOOM = 120 ;
2330
2431const EVENT_TYPES = {
2532 WHEEL : [ 'wheel' ] ,
2633 PAN : [ 'panstart' , 'panmove' , 'panend' ] ,
2734 PINCH : [ 'pinchstart' , 'pinchmove' , 'pinchend' ] ,
2835 MULTI_PAN : [ 'multipanstart' , 'multipanmove' , 'multipanend' ] ,
2936 DOUBLE_CLICK : [ 'dblclick' ] ,
37+ DOUBLE_TAP_DRAG : [ 'pointerdown' , 'pointermove' , 'pointerup' , 'pointercancel' ] ,
3038 KEYBOARD : [ 'keydown' ]
3139} as const ;
3240
@@ -112,6 +120,24 @@ export type ViewStateChangeParameters<ViewStateT = any> = {
112120
113121const pinchEventWorkaround : any = { } ;
114122
123+ type OneFingerTapState = {
124+ pos : [ number , number ] ;
125+ time : number ;
126+ pointerId ?: number ;
127+ } ;
128+
129+ type OneFingerZoomState = {
130+ startPos : [ number , number ] ;
131+ pointerId ?: number ;
132+ active : boolean ;
133+ } ;
134+
135+ function getDistance ( a : [ number , number ] , b : [ number , number ] ) : number {
136+ const dx = a [ 0 ] - b [ 0 ] ;
137+ const dy = a [ 1 ] - b [ 1 ] ;
138+ return Math . sqrt ( dx * dx + dy * dy ) ;
139+ }
140+
115141export default abstract class Controller < ControllerState extends IViewState < ControllerState > > {
116142 abstract get ControllerState ( ) : ConstructorOf < ControllerState > ;
117143 abstract get transition ( ) : TransitionProps ;
@@ -135,6 +161,10 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
135161 private _customEvents : string [ ] = [ ] ;
136162 private _eventStartBlocked : any = null ;
137163 private _panMove : boolean = false ;
164+ private _tapStart : OneFingerTapState | null = null ;
165+ private _lastTap : OneFingerTapState | null = null ;
166+ private _oneFingerZoom : OneFingerZoomState | null = null ;
167+ private _suppressDoubleClickUntil : number = 0 ;
138168
139169 protected invertPan : boolean = false ;
140170 protected dragMode : 'pan' | 'rotate' = 'rotate' ;
@@ -209,10 +239,19 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
209239
210240 switch ( event . type ) {
211241 case 'panstart' :
242+ if ( this . _oneFingerZoom ) {
243+ return false ;
244+ }
212245 return eventStartBlocked ? false : this . _onPanStart ( event ) ;
213246 case 'panmove' :
247+ if ( this . _oneFingerZoom ) {
248+ return false ;
249+ }
214250 return this . _onPan ( event ) ;
215251 case 'panend' :
252+ if ( this . _oneFingerZoom ) {
253+ return false ;
254+ }
216255 return this . _onPanEnd ( event ) ;
217256 case 'pinchstart' :
218257 return eventStartBlocked ? false : this . _onPinchStart ( event ) ;
@@ -228,6 +267,13 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
228267 return this . _onMultiPanEnd ( event ) ;
229268 case 'dblclick' :
230269 return this . _onDoubleClick ( event ) ;
270+ case 'pointerdown' :
271+ return this . _onPointerDown ( event ) ;
272+ case 'pointermove' :
273+ return this . _onPointerMove ( event ) ;
274+ case 'pointerup' :
275+ case 'pointercancel' :
276+ return this . _onPointerUp ( event ) ;
231277 case 'wheel' :
232278 return this . _onWheel ( event as MjolnirWheelEvent ) ;
233279 case 'keydown' :
@@ -328,6 +374,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
328374 this . toggleEvents ( EVENT_TYPES . PINCH , isInteractive && ( touchZoom || touchRotate ) ) ;
329375 this . toggleEvents ( EVENT_TYPES . MULTI_PAN , isInteractive && touchRotate ) ;
330376 this . toggleEvents ( EVENT_TYPES . DOUBLE_CLICK , isInteractive && doubleClickZoom ) ;
377+ this . toggleEvents ( EVENT_TYPES . DOUBLE_TAP_DRAG , isInteractive && touchZoom ) ;
331378 this . toggleEvents ( EVENT_TYPES . KEYBOARD , isInteractive && keyboard ) ;
332379
333380 // Interaction toggles
@@ -641,6 +688,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
641688
642689 // Default handler for the `pinchstart` event.
643690 protected _onPinchStart ( event : MjolnirGestureEvent ) : boolean {
691+ this . _resetOneFingerZoom ( ) ;
644692 const pos = this . getCenter ( event ) ;
645693 if ( ! this . isPointInBounds ( pos , event ) ) {
646694 return false ;
@@ -735,6 +783,9 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
735783 if ( ! this . doubleClickZoom ) {
736784 return false ;
737785 }
786+ if ( Date . now ( ) < this . _suppressDoubleClickUntil ) {
787+ return false ;
788+ }
738789 const pos = this . getCenter ( event ) ;
739790 if ( ! this . isPointInBounds ( pos , event ) ) {
740791 return false ;
@@ -751,6 +802,147 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
751802 return true ;
752803 }
753804
805+ protected _onPointerDown ( event : MjolnirEvent ) : boolean {
806+ if ( ! this . touchZoom || ! this . _isPrimaryPointer ( event ) ) {
807+ this . _resetOneFingerZoom ( ) ;
808+ return false ;
809+ }
810+
811+ const pos = this . getCenter ( event as MjolnirGestureEvent ) ;
812+ if ( ! this . isPointInBounds ( pos , event ) ) {
813+ this . _resetOneFingerZoom ( ) ;
814+ return false ;
815+ }
816+
817+ const time = this . _getEventTime ( event ) ;
818+ const pointerId = ( event . srcEvent as PointerEvent ) . pointerId ;
819+ if (
820+ this . _lastTap &&
821+ time - this . _lastTap . time <= DOUBLE_TAP_DRAG_INTERVAL &&
822+ getDistance ( pos , this . _lastTap . pos ) <= DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE
823+ ) {
824+ this . _tapStart = null ;
825+ this . _lastTap = null ;
826+ this . _oneFingerZoom = { startPos : pos , pointerId, active : false } ;
827+ event . srcEvent . preventDefault ( ) ;
828+ event . stopPropagation ( ) ;
829+ return true ;
830+ }
831+
832+ this . _tapStart = { pos, time, pointerId} ;
833+ this . _lastTap = null ;
834+ if ( ( event . srcEvent as PointerEvent ) . pointerType === 'touch' ) {
835+ event . srcEvent . preventDefault ( ) ;
836+ }
837+ return false ;
838+ }
839+
840+ protected _onPointerMove ( event : MjolnirEvent ) : boolean {
841+ const oneFingerZoom = this . _oneFingerZoom ;
842+ if ( ! oneFingerZoom || ! this . _isSamePointer ( event , oneFingerZoom . pointerId ) ) {
843+ return false ;
844+ }
845+
846+ const pos = this . getCenter ( event as MjolnirGestureEvent ) ;
847+ const dy = oneFingerZoom . startPos [ 1 ] - pos [ 1 ] ;
848+ if ( ! oneFingerZoom . active && Math . abs ( dy ) < DOUBLE_TAP_DRAG_START_THRESHOLD ) {
849+ event . srcEvent . preventDefault ( ) ;
850+ event . stopPropagation ( ) ;
851+ return true ;
852+ }
853+
854+ const scale = Math . pow ( 2 , dy / DOUBLE_TAP_DRAG_PIXELS_PER_ZOOM ) ;
855+ const startPos = oneFingerZoom . startPos ;
856+ let newControllerState = this . controllerState ;
857+ if ( ! oneFingerZoom . active ) {
858+ oneFingerZoom . active = true ;
859+ newControllerState = newControllerState . zoomStart ( { pos : startPos } ) ;
860+ }
861+ newControllerState = newControllerState . zoom ( { pos : startPos , scale} ) ;
862+ this . updateViewport ( newControllerState , NO_TRANSITION_PROPS , {
863+ isDragging : true ,
864+ isPanning : true ,
865+ isZooming : true
866+ } ) ;
867+
868+ event . srcEvent . preventDefault ( ) ;
869+ event . stopPropagation ( ) ;
870+ return true ;
871+ }
872+
873+ protected _onPointerUp ( event : MjolnirEvent ) : boolean {
874+ const oneFingerZoom = this . _oneFingerZoom ;
875+ if ( oneFingerZoom && this . _isSamePointer ( event , oneFingerZoom . pointerId ) ) {
876+ this . _oneFingerZoom = null ;
877+ if ( oneFingerZoom . active ) {
878+ const newControllerState = this . controllerState . zoomEnd ( ) ;
879+ this . updateViewport ( newControllerState , null , {
880+ isDragging : false ,
881+ isPanning : false ,
882+ isZooming : false
883+ } ) ;
884+ this . _suppressDoubleClickUntil = Date . now ( ) + 100 ;
885+ this . blockEvents ( 100 ) ;
886+ event . srcEvent . preventDefault ( ) ;
887+ event . stopPropagation ( ) ;
888+ return true ;
889+ }
890+ return false ;
891+ }
892+
893+ if ( event . type === 'pointercancel' ) {
894+ this . _resetOneFingerZoom ( ) ;
895+ return false ;
896+ }
897+
898+ const tapStart = this . _tapStart ;
899+ if ( ! tapStart || ! this . _isSamePointer ( event , tapStart . pointerId ) ) {
900+ return false ;
901+ }
902+
903+ const pos = this . getCenter ( event as MjolnirGestureEvent ) ;
904+ const time = this . _getEventTime ( event ) ;
905+ if (
906+ time - tapStart . time <= DOUBLE_TAP_DRAG_MAX_TAP_DURATION &&
907+ getDistance ( pos , tapStart . pos ) <= DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE
908+ ) {
909+ this . _lastTap = { pos, time, pointerId : tapStart . pointerId } ;
910+ } else {
911+ this . _lastTap = null ;
912+ }
913+ this . _tapStart = null ;
914+ if ( ( event . srcEvent as PointerEvent ) . pointerType === 'touch' ) {
915+ event . srcEvent . preventDefault ( ) ;
916+ }
917+ return false ;
918+ }
919+
920+ private _resetOneFingerZoom ( ) : void {
921+ this . _tapStart = null ;
922+ this . _lastTap = null ;
923+ this . _oneFingerZoom = null ;
924+ }
925+
926+ private _getEventTime ( event : MjolnirEvent ) : number {
927+ return ( event as any ) . timeStamp || event . srcEvent . timeStamp || Date . now ( ) ;
928+ }
929+
930+ private _isPrimaryPointer ( event : MjolnirEvent ) : boolean {
931+ const pointers = ( event as any ) . pointers ;
932+ if ( pointers && pointers . length > 1 ) {
933+ return false ;
934+ }
935+ const srcEvent = event . srcEvent as PointerEvent ;
936+ if ( srcEvent . pointerType === 'mouse' ) {
937+ return ( event as any ) . leftButton !== false ;
938+ }
939+ return true ;
940+ }
941+
942+ private _isSamePointer ( event : MjolnirEvent , pointerId ?: number ) : boolean {
943+ return pointerId === undefined || ( event . srcEvent as PointerEvent ) . pointerId === pointerId ;
944+ }
945+
754946 // Default handler for the `keydown` event
755947 protected _onKeyDown ( event : MjolnirKeyEvent ) : boolean {
756948 if ( ! this . keyboard ) {
0 commit comments