| 1 | import { isAndroid } from '../utils/device' |
| 2 | import { matches } from '../utils/util' |
| 3 | |
| 4 | const SWIPE_THRESHOLD = 40; |
| 5 | const ZOOM_GESTURE_THRESHOLD = 1.03; |
| 6 | |
| 7 | /** |
| 8 | * Controls all touch interactions and navigations for |
| 9 | * a presentation. |
| 10 | */ |
| 11 | export default class Touch { |
| 12 | |
| 13 | constructor( Reveal ) { |
| 14 | |
| 15 | this.Reveal = Reveal; |
| 16 | |
| 17 | // Holds information about the currently ongoing touch interaction |
| 18 | this.touchStartX = 0; |
| 19 | this.touchStartY = 0; |
| 20 | this.touchStartCount = 0; |
| 21 | this.touchCaptured = false; |
| 22 | this.activePointers = new Map(); |
| 23 | |
| 24 | this.onPointerDown = this.onPointerDown.bind( this ); |
| 25 | this.onPointerMove = this.onPointerMove.bind( this ); |
| 26 | this.onPointerUp = this.onPointerUp.bind( this ); |
| 27 | this.onPointerCancel = this.onPointerCancel.bind( this ); |
| 28 | this.onTouchStart = this.onTouchStart.bind( this ); |
| 29 | this.onTouchMove = this.onTouchMove.bind( this ); |
| 30 | this.onTouchEnd = this.onTouchEnd.bind( this ); |
| 31 | |
| 32 | } |
| 33 | |
| 34 | /** |
| 35 | * |
| 36 | */ |
| 37 | bind() { |
| 38 | |
| 39 | let revealElement = this.Reveal.getRevealElement(); |
| 40 | |
| 41 | if( 'onpointerdown' in window ) { |
| 42 | revealElement.addEventListener( 'pointerdown', this.onPointerDown, false ); |
| 43 | revealElement.addEventListener( 'pointermove', this.onPointerMove, false ); |
| 44 | revealElement.addEventListener( 'pointerup', this.onPointerUp, false ); |
| 45 | revealElement.addEventListener( 'pointercancel', this.onPointerCancel, false ); |
| 46 | } |
| 47 | // Fall back to touch events |
| 48 | else { |
| 49 | revealElement.addEventListener( 'touchstart', this.onTouchStart, false ); |
| 50 | revealElement.addEventListener( 'touchmove', this.onTouchMove, false ); |
| 51 | revealElement.addEventListener( 'touchend', this.onTouchEnd, false ); |
| 52 | } |
| 53 | |
| 54 | } |
| 55 | |
| 56 | /** |
| 57 | * |
| 58 | */ |
| 59 | unbind() { |
| 60 | |
| 61 | let revealElement = this.Reveal.getRevealElement(); |
| 62 | |
| 63 | revealElement.removeEventListener( 'pointerdown', this.onPointerDown, false ); |
| 64 | revealElement.removeEventListener( 'pointermove', this.onPointerMove, false ); |
| 65 | revealElement.removeEventListener( 'pointerup', this.onPointerUp, false ); |
| 66 | revealElement.removeEventListener( 'pointercancel', this.onPointerCancel, false ); |
| 67 | |
| 68 | revealElement.removeEventListener( 'touchstart', this.onTouchStart, false ); |
| 69 | revealElement.removeEventListener( 'touchmove', this.onTouchMove, false ); |
| 70 | revealElement.removeEventListener( 'touchend', this.onTouchEnd, false ); |
| 71 | |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Checks if the target element prevents the triggering of |
| 76 | * swipe navigation. |
| 77 | */ |
| 78 | isSwipePrevented( target ) { |
| 79 | |
| 80 | // Prevent accidental swipes when scrubbing timelines |
| 81 | if( matches( target, 'video[controls], audio[controls]' ) ) return true; |
| 82 | |
| 83 | while( target && typeof target.hasAttribute === 'function' ) { |
| 84 | if( target.hasAttribute( 'data-prevent-swipe' ) ) return true; |
| 85 | target = target.parentNode; |
| 86 | } |
| 87 | |
| 88 | return false; |
| 89 | |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * Returns true when browser-level zoom is active enough that |
| 94 | * we should not trigger reveal.js touch gestures. |
| 95 | */ |
| 96 | isViewportZoomed() { |
| 97 | |
| 98 | if( !window.visualViewport || typeof window.visualViewport.scale !== 'number' ) { |
| 99 | return false; |
| 100 | } |
| 101 | |
| 102 | const currentScale = window.visualViewport.scale; |
| 103 | |
| 104 | // Fixed-width viewport configurations may start below 1. |
| 105 | // Compare against the smallest observed "resting" scale. |
| 106 | if( typeof this.visualViewportBaseScale === 'undefined' ) { |
| 107 | this.visualViewportBaseScale = currentScale; |
| 108 | } else { |
| 109 | this.visualViewportBaseScale = Math.min( this.visualViewportBaseScale, currentScale ); |
| 110 | } |
| 111 | |
| 112 | return ( currentScale / this.visualViewportBaseScale ) > ZOOM_GESTURE_THRESHOLD; |
| 113 | |
| 114 | } |
| 115 | |
| 116 | /** |
| 117 | * Handler for the 'touchstart' event, enables support for |
| 118 | * swipe and pinch gestures. |
| 119 | * |
| 120 | * @param {object} event |
| 121 | */ |
| 122 | onTouchStart( event ) { |
| 123 | |
| 124 | this.touchCaptured = false; |
| 125 | |
| 126 | // if( this.isViewportZoomed() ) return true; |
| 127 | if( this.isSwipePrevented( event.target ) ) return true; |
| 128 | |
| 129 | this.touchStartX = event.touches[0].clientX; |
| 130 | this.touchStartY = event.touches[0].clientY; |
| 131 | this.touchStartCount = event.touches.length; |
| 132 | |
| 133 | } |
| 134 | |
| 135 | /** |
| 136 | * Handler for the 'touchmove' event. |
| 137 | * |
| 138 | * @param {object} event |
| 139 | */ |
| 140 | onTouchMove( event ) { |
| 141 | |
| 142 | // if( this.isViewportZoomed() ) return true; |
| 143 | if( this.isSwipePrevented( event.target ) ) return true; |
| 144 | |
| 145 | let config = this.Reveal.getConfig(); |
| 146 | |
| 147 | // Each touch should only trigger one action |
| 148 | if( !this.touchCaptured ) { |
| 149 | this.Reveal.onUserInput( event ); |
| 150 | |
| 151 | let currentX = event.touches[0].clientX; |
| 152 | let currentY = event.touches[0].clientY; |
| 153 | |
| 154 | // There was only one touch point, look for a swipe |
| 155 | if( event.touches.length === 1 && this.touchStartCount !== 2 ) { |
| 156 | |
| 157 | let availableRoutes = this.Reveal.availableRoutes({ includeFragments: true }); |
| 158 | |
| 159 | let deltaX = currentX - this.touchStartX, |
| 160 | deltaY = currentY - this.touchStartY; |
| 161 | |
| 162 | if( deltaX > SWIPE_THRESHOLD && Math.abs( deltaX ) > Math.abs( deltaY ) ) { |
| 163 | this.touchCaptured = true; |
| 164 | if( config.navigationMode === 'linear' ) { |
| 165 | if( config.rtl ) { |
| 166 | this.Reveal.next(); |
| 167 | } |
| 168 | else { |
| 169 | this.Reveal.prev(); |
| 170 | } |
| 171 | } |
| 172 | else { |
| 173 | this.Reveal.left(); |
| 174 | } |
| 175 | } |
| 176 | else if( deltaX < -SWIPE_THRESHOLD && Math.abs( deltaX ) > Math.abs( deltaY ) ) { |
| 177 | this.touchCaptured = true; |
| 178 | if( config.navigationMode === 'linear' ) { |
| 179 | if( config.rtl ) { |
| 180 | this.Reveal.prev(); |
| 181 | } |
| 182 | else { |
| 183 | this.Reveal.next(); |
| 184 | } |
| 185 | } |
| 186 | else { |
| 187 | this.Reveal.right(); |
| 188 | } |
| 189 | } |
| 190 | else if( deltaY > SWIPE_THRESHOLD && availableRoutes.up ) { |
| 191 | this.touchCaptured = true; |
| 192 | if( config.navigationMode === 'linear' ) { |
| 193 | this.Reveal.prev(); |
| 194 | } |
| 195 | else { |
| 196 | this.Reveal.up(); |
| 197 | } |
| 198 | } |
| 199 | else if( deltaY < -SWIPE_THRESHOLD && availableRoutes.down ) { |
| 200 | this.touchCaptured = true; |
| 201 | if( config.navigationMode === 'linear' ) { |
| 202 | this.Reveal.next(); |
| 203 | } |
| 204 | else { |
| 205 | this.Reveal.down(); |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | // If we're embedded, only block touch events if they have |
| 210 | // triggered an action |
| 211 | if( config.embedded ) { |
| 212 | if( this.touchCaptured || this.Reveal.isVerticalSlide() ) { |
| 213 | event.preventDefault(); |
| 214 | } |
| 215 | } |
| 216 | // Not embedded? Block them all to avoid needless tossing |
| 217 | // around of the viewport in iOS |
| 218 | else { |
| 219 | event.preventDefault(); |
| 220 | } |
| 221 | |
| 222 | } |
| 223 | } |
| 224 | // There's a bug with swiping on some Android devices unless |
| 225 | // the default action is always prevented |
| 226 | else if( isAndroid ) { |
| 227 | event.preventDefault(); |
| 228 | } |
| 229 | |
| 230 | } |
| 231 | |
| 232 | /** |
| 233 | * Handler for the 'touchend' event. |
| 234 | * |
| 235 | * @param {object} event |
| 236 | */ |
| 237 | onTouchEnd( event ) { |
| 238 | |
| 239 | // Media playback is only allowed as a direct result of a |
| 240 | // user interaction. Some mobile devices do not consider a |
| 241 | // 'touchmove' to be a direct user action. If this is the |
| 242 | // case, we fall back to starting playback here instead. |
| 243 | if( this.touchCaptured && !this.Reveal.slideContent.isAllowedToPlayAudio() ) { |
| 244 | this.Reveal.startEmbeddedContent( this.Reveal.getCurrentSlide() ); |
| 245 | } |
| 246 | |
| 247 | this.touchCaptured = false; |
| 248 | |
| 249 | } |
| 250 | |
| 251 | /** |
| 252 | * Returns all active touch pointers in touch-like format. |
| 253 | * |
| 254 | * @return {Array} |
| 255 | */ |
| 256 | getActiveTouches() { |
| 257 | |
| 258 | return Array.from( this.activePointers.values(), pointer => ({ |
| 259 | clientX: pointer.clientX, |
| 260 | clientY: pointer.clientY |
| 261 | }) ); |
| 262 | |
| 263 | } |
| 264 | |
| 265 | /** |
| 266 | * Convert pointer down to touch start. |
| 267 | * |
| 268 | * @param {object} event |
| 269 | */ |
| 270 | onPointerDown( event ) { |
| 271 | |
| 272 | if( event.pointerType === "touch" ) { |
| 273 | this.activePointers.set( event.pointerId, event ); |
| 274 | event.touches = this.getActiveTouches(); |
| 275 | this.onTouchStart( event ); |
| 276 | } |
| 277 | |
| 278 | } |
| 279 | |
| 280 | /** |
| 281 | * Convert pointer move to touch move. |
| 282 | * |
| 283 | * @param {object} event |
| 284 | */ |
| 285 | onPointerMove( event ) { |
| 286 | |
| 287 | if( event.pointerType === "touch" ) { |
| 288 | this.activePointers.set( event.pointerId, event ); |
| 289 | event.touches = this.getActiveTouches(); |
| 290 | this.onTouchMove( event ); |
| 291 | } |
| 292 | |
| 293 | } |
| 294 | |
| 295 | /** |
| 296 | * Convert pointer up to touch end. |
| 297 | * |
| 298 | * @param {object} event |
| 299 | */ |
| 300 | onPointerUp( event ) { |
| 301 | |
| 302 | if( event.pointerType === "touch" ) { |
| 303 | this.activePointers.delete( event.pointerId ); |
| 304 | event.touches = this.getActiveTouches(); |
| 305 | this.onTouchEnd( event ); |
| 306 | } |
| 307 | |
| 308 | } |
| 309 | |
| 310 | /** |
| 311 | * Convert pointer cancel to touch end. |
| 312 | * |
| 313 | * @param {object} event |
| 314 | */ |
| 315 | onPointerCancel( event ) { |
| 316 | |
| 317 | if( event.pointerType === "touch" ) { |
| 318 | this.activePointers.delete( event.pointerId ); |
| 319 | event.touches = this.getActiveTouches(); |
| 320 | this.onTouchEnd( event ); |
| 321 | } |
| 322 | |
| 323 | } |
| 324 | |
| 325 | } |