UNPKG

photoswipe

Version:
575 lines (485 loc) 16.4 kB
import { equalizePoints, pointsEqual, getDistanceBetween } from '../util/util.js'; import DragHandler from './drag-handler.js'; import ZoomHandler from './zoom-handler.js'; import TapHandler from './tap-handler.js'; /** @typedef {import('../photoswipe.js').default} PhotoSwipe */ /** @typedef {import('../photoswipe.js').Point} Point */ // How far should user should drag // until we can determine that the gesture is swipe and its direction const AXIS_SWIPE_HYSTERISIS = 10; //const PAN_END_FRICTION = 0.35; const DOUBLE_TAP_DELAY = 300; // ms const MIN_TAP_DISTANCE = 25; // px /** * Gestures class bind touch, pointer or mouse events * and emits drag to drag-handler and zoom events zoom-handler. * * Drag and zoom events are emited in requestAnimationFrame, * and only when one of pointers was actually changed. */ class Gestures { /** * @param {PhotoSwipe} pswp */ constructor(pswp) { this.pswp = pswp; /** @type {'x' | 'y'} */ this.dragAxis = undefined; // point objects are defined once and reused // PhotoSwipe keeps track only of two pointers, others are ignored /** @type {Point} */ this.p1 = {}; // the first pressed pointer /** @type {Point} */ this.p2 = {}; // the second pressed pointer /** @type {Point} */ this.prevP1 = {}; /** @type {Point} */ this.prevP2 = {}; /** @type {Point} */ this.startP1 = {}; /** @type {Point} */ this.startP2 = {}; /** @type {Point} */ this.velocity = {}; /** @type {Point} */ this._lastStartP1 = {}; /** @type {Point} */ this._intervalP1 = {}; this._numActivePoints = 0; /** @type {Point[]} */ this._ongoingPointers = []; this._touchEventEnabled = 'ontouchstart' in window; this._pointerEventEnabled = !!(window.PointerEvent); this.supportsTouch = this._touchEventEnabled || (this._pointerEventEnabled && navigator.maxTouchPoints > 1); if (!this.supportsTouch) { // disable pan to next slide for non-touch devices pswp.options.allowPanToNext = false; } this.drag = new DragHandler(this); this.zoomLevels = new ZoomHandler(this); this.tapHandler = new TapHandler(this); pswp.on('bindEvents', () => { pswp.events.add(pswp.scrollWrap, 'click', e => this._onClick(e)); if (this._pointerEventEnabled) { this._bindEvents('pointer', 'down', 'up', 'cancel'); } else if (this._touchEventEnabled) { this._bindEvents('touch', 'start', 'end', 'cancel'); // In previous versions we also bound mouse event here, // in case device supports both touch and mouse events, // but newer versions of browsers now support PointerEvent. // on iOS10 if you bind touchmove/end after touchstart, // and you don't preventDefault touchstart (which PhotoSwipe does), // preventDefault will have no effect on touchmove and touchend. // Unless you bind it previously. pswp.scrollWrap.ontouchmove = () => {}; // eslint-disable-line pswp.scrollWrap.ontouchend = () => {}; // eslint-disable-line } else { this._bindEvents('mouse', 'down', 'up'); } }); } /** * * @param {'mouse' | 'touch' | 'pointer'} pref * @param {'down' | 'start'} down * @param {'up' | 'end'} up * @param {'cancel'} [cancel] */ _bindEvents(pref, down, up, cancel) { const { pswp } = this; const { events } = pswp; const cancelEvent = cancel ? pref + cancel : ''; events.add(pswp.scrollWrap, pref + down, this.onPointerDown.bind(this)); events.add(window, pref + 'move', this.onPointerMove.bind(this)); events.add(window, pref + up, this.onPointerUp.bind(this)); if (cancelEvent) { events.add(pswp.scrollWrap, cancelEvent, this.onPointerUp.bind(this)); } } /** * @param {PointerEvent} e */ onPointerDown(e) { // We do not call preventDefault for touch events // to allow browser to show native dialog on longpress // (the one that allows to save image or open it in new tab). // // Desktop Safari allows to drag images when preventDefault isn't called on mousedown, // even though preventDefault IS called on mousemove. That's why we preventDefault mousedown. let isMousePointer; if (e.type === 'mousedown' || e.pointerType === 'mouse') { isMousePointer = true; } // Allow dragging only via left mouse button. // http://www.quirksmode.org/js/events_properties.html // https://developer.mozilla.org/en-US/docs/Web/API/event.button if (isMousePointer && e.button > 0) { return; } const { pswp } = this; // if PhotoSwipe is opening or closing if (!pswp.opener.isOpen) { e.preventDefault(); return; } if (pswp.dispatch('pointerDown', { originalEvent: e }).defaultPrevented) { return; } if (isMousePointer) { pswp.mouseDetected(); // preventDefault mouse event to prevent // browser image drag feature this._preventPointerEventBehaviour(e); } pswp.animations.stopAll(); this._updatePoints(e, 'down'); this.pointerDown = true; if (this._numActivePoints === 1) { this.dragAxis = null; // we need to store initial point to determine the main axis, // drag is activated only after the axis is determined equalizePoints(this.startP1, this.p1); } if (this._numActivePoints > 1) { // Tap or double tap should not trigger if more than one pointer this._clearTapTimer(); this.isMultitouch = true; } else { this.isMultitouch = false; } } /** * @param {PointerEvent} e */ onPointerMove(e) { e.preventDefault(); // always preventDefault move event if (!this._numActivePoints) { return; } this._updatePoints(e, 'move'); if (this.pswp.dispatch('pointerMove', { originalEvent: e }).defaultPrevented) { return; } if (this._numActivePoints === 1 && !this.isDragging) { if (!this.dragAxis) { this._calculateDragDirection(); } // Drag axis was detected, emit drag.start if (this.dragAxis && !this.isDragging) { if (this.isZooming) { this.isZooming = false; this.zoomLevels.end(); } this.isDragging = true; this._clearTapTimer(); // Tap can not trigger after drag // Adjust starting point this._updateStartPoints(); this._intervalTime = Date.now(); //this._startTime = this._intervalTime; this._velocityCalculated = false; equalizePoints(this._intervalP1, this.p1); this.velocity.x = 0; this.velocity.y = 0; this.drag.start(); this._rafStopLoop(); this._rafRenderLoop(); } } else if (this._numActivePoints > 1 && !this.isZooming) { this._finishDrag(); this.isZooming = true; // Adjust starting points this._updateStartPoints(); this.zoomLevels.start(); this._rafStopLoop(); this._rafRenderLoop(); } } /** * @private */ _finishDrag() { if (this.isDragging) { this.isDragging = false; // Try to calculate velocity, // if it wasn't calculated yet in drag.change if (!this._velocityCalculated) { this._updateVelocity(true); } this.drag.end(); this.dragAxis = null; } } /** * @param {PointerEvent} e */ onPointerUp(e) { if (!this._numActivePoints) { return; } this._updatePoints(e, 'up'); if (this.pswp.dispatch('pointerUp', { originalEvent: e }).defaultPrevented) { return; } if (this._numActivePoints === 0) { this.pointerDown = false; this._rafStopLoop(); if (this.isDragging) { this._finishDrag(); } else if (!this.isZooming && !this.isMultitouch) { //this.zoomLevels.correctZoomPan(); this._finishTap(e); } } if (this._numActivePoints < 2 && this.isZooming) { this.isZooming = false; this.zoomLevels.end(); if (this._numActivePoints === 1) { // Since we have 1 point left, we need to reinitiate drag this.dragAxis = null; this._updateStartPoints(); } } } /** * @private */ _rafRenderLoop() { if (this.isDragging || this.isZooming) { this._updateVelocity(); if (this.isDragging) { // make sure that pointer moved since the last update if (!pointsEqual(this.p1, this.prevP1)) { this.drag.change(); } } else /* if (this.isZooming) */ { if (!pointsEqual(this.p1, this.prevP1) || !pointsEqual(this.p2, this.prevP2)) { this.zoomLevels.change(); } } this._updatePrevPoints(); this.raf = requestAnimationFrame(this._rafRenderLoop.bind(this)); } } /** * Update velocity at 50ms interval * * @param {boolean=} force */ _updateVelocity(force) { const time = Date.now(); const duration = time - this._intervalTime; if (duration < 50 && !force) { return; } this.velocity.x = this._getVelocity('x', duration); this.velocity.y = this._getVelocity('y', duration); this._intervalTime = time; equalizePoints(this._intervalP1, this.p1); this._velocityCalculated = true; } /** * @private * @param {PointerEvent} e */ _finishTap(e) { const { mainScroll } = this.pswp; // Do not trigger tap events if main scroll is shifted if (mainScroll.isShifted()) { // restore main scroll position // (usually happens if stopped in the middle of animation) mainScroll.moveIndexBy(0, true); return; } // Do not trigger tap for touchcancel or pointercancel if (e.type.indexOf('cancel') > 0) { return; } // Trigger click instead of tap for mouse events if (e.type === 'mouseup' || e.pointerType === 'mouse') { this.tapHandler.click(this.startP1, e); return; } // Disable delay if there is no doubleTapAction const tapDelay = this.pswp.options.doubleTapAction ? DOUBLE_TAP_DELAY : 0; // If tapTimer is defined - we tapped recently, // check if the current tap is close to the previous one, // if yes - trigger double tap if (this._tapTimer) { this._clearTapTimer(); // Check if two taps were more or less on the same place if (getDistanceBetween(this._lastStartP1, this.startP1) < MIN_TAP_DISTANCE) { this.tapHandler.doubleTap(this.startP1, e); } } else { equalizePoints(this._lastStartP1, this.startP1); this._tapTimer = setTimeout(() => { this.tapHandler.tap(this.startP1, e); this._clearTapTimer(); }, tapDelay); } } /** * @private */ _clearTapTimer() { if (this._tapTimer) { clearTimeout(this._tapTimer); this._tapTimer = null; } } /** * Get velocity for axis * * @private * @param {'x' | 'y'} axis * @param {number} duration */ _getVelocity(axis, duration) { // displacement is like distance, but can be negative. const displacement = this.p1[axis] - this._intervalP1[axis]; if (Math.abs(displacement) > 1 && duration > 5) { return displacement / duration; } return 0; } /** * @private */ _rafStopLoop() { if (this.raf) { cancelAnimationFrame(this.raf); this.raf = null; } } /** * @private * @param {PointerEvent} e */ _preventPointerEventBehaviour(e) { // TODO find a way to disable e.preventDefault on some elements // via event or some class or something e.preventDefault(); return true; } /** * Parses and normalizes points from the touch, mouse or pointer event. * Updates p1 and p2. * * @private * @param {PointerEvent | TouchEvent} e * @param {'up' | 'down' | 'move'} pointerType Normalized pointer type */ _updatePoints(e, pointerType) { if (this._pointerEventEnabled) { const pointerEvent = /** @type {PointerEvent} */ (e); // Try to find the current pointer in ongoing pointers by its ID const pointerIndex = this._ongoingPointers.findIndex((ongoingPoiner) => { return ongoingPoiner.id === pointerEvent.pointerId; }); if (pointerType === 'up' && pointerIndex > -1) { // release the pointer - remove it from ongoing this._ongoingPointers.splice(pointerIndex, 1); } else if (pointerType === 'down' && pointerIndex === -1) { // add new pointer this._ongoingPointers.push(this._convertEventPosToPoint(pointerEvent, {})); } else if (pointerIndex > -1) { // update existing pointer this._convertEventPosToPoint(pointerEvent, this._ongoingPointers[pointerIndex]); } this._numActivePoints = this._ongoingPointers.length; // update points that PhotoSwipe uses // to calculate position and scale if (this._numActivePoints > 0) { equalizePoints(this.p1, this._ongoingPointers[0]); } if (this._numActivePoints > 1) { equalizePoints(this.p2, this._ongoingPointers[1]); } } else { const touchEvent = /** @type {TouchEvent} */ (e); this._numActivePoints = 0; if (touchEvent.type.indexOf('touch') > -1) { // Touch Event // https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent if (touchEvent.touches && touchEvent.touches.length > 0) { this._convertEventPosToPoint(touchEvent.touches[0], this.p1); this._numActivePoints++; if (touchEvent.touches.length > 1) { this._convertEventPosToPoint(touchEvent.touches[1], this.p2); this._numActivePoints++; } } } else { // Mouse Event this._convertEventPosToPoint(/** @type {PointerEvent} */ (e), this.p1); if (pointerType === 'up') { // clear all points on mouseup this._numActivePoints = 0; } else { this._numActivePoints++; } } } } // update points that were used during previous rAF tick _updatePrevPoints() { equalizePoints(this.prevP1, this.p1); equalizePoints(this.prevP2, this.p2); } // update points at the start of gesture _updateStartPoints() { equalizePoints(this.startP1, this.p1); equalizePoints(this.startP2, this.p2); this._updatePrevPoints(); } _calculateDragDirection() { if (this.pswp.mainScroll.isShifted()) { // if main scroll position is shifted – direction is always horizontal this.dragAxis = 'x'; } else { // calculate delta of the last touchmove tick const diff = Math.abs(this.p1.x - this.startP1.x) - Math.abs(this.p1.y - this.startP1.y); if (diff !== 0) { // check if pointer was shifted horizontally or vertically const axisToCheck = diff > 0 ? 'x' : 'y'; if (Math.abs(this.p1[axisToCheck] - this.startP1[axisToCheck]) >= AXIS_SWIPE_HYSTERISIS) { this.dragAxis = axisToCheck; } } } } /** * Converts touch, pointer or mouse event * to PhotoSwipe point. * * @private * @param {Touch | PointerEvent} e * @param {Point} p */ _convertEventPosToPoint(e, p) { p.x = e.pageX - this.pswp.offset.x; p.y = e.pageY - this.pswp.offset.y; if ('pointerId' in e) { p.id = e.pointerId; } else if (e.identifier !== undefined) { p.id = e.identifier; } return p; } /** * @private * @param {PointerEvent} e */ _onClick(e) { // Do not allow click event to pass through after drag if (this.pswp.mainScroll.isShifted()) { e.preventDefault(); e.stopPropagation(); } } } export default Gestures;