From 164b0bea09fcafdbfc22c170f6adf54cdc9fd5bc Mon Sep 17 00:00:00 2001 From: Ansis Brammanis Date: Tue, 7 Apr 2020 17:33:13 -0400 Subject: [PATCH] refactor and expand gesture handling (#9365) Co-authored-by: Anjana Vakil - refactor gesture handling to allow multiple handlers to run simultaneously - add a pitch gesture - add a tap-drag-zoom gesture - fix various bugs (see PR) --- bench/benchmarks/paint.js | 2 +- src/ui/bind_handlers.js | 202 -------- src/ui/camera.js | 99 +++- src/ui/control/navigation_control.js | 125 ++++- src/ui/events.js | 2 +- src/ui/handler/box_zoom.js | 51 +- src/ui/handler/click_zoom.js | 48 ++ src/ui/handler/dblclick_zoom.js | 139 ----- src/ui/handler/drag_pan.js | 416 --------------- src/ui/handler/drag_rotate.js | 378 -------------- src/ui/handler/handler_util.js | 12 + src/ui/handler/keyboard.js | 114 ++-- src/ui/handler/map_event.js | 157 ++++++ src/ui/handler/mouse.js | 144 ++++++ src/ui/handler/scroll_zoom.js | 58 ++- src/ui/handler/shim/dblclick_zoom.js | 62 +++ src/ui/handler/shim/drag_pan.js | 88 ++++ src/ui/handler/shim/drag_rotate.js | 67 +++ src/ui/handler/shim/touch_zoom_rotate.js | 108 ++++ src/ui/handler/tap_drag_zoom.js | 103 ++++ src/ui/handler/tap_recognizer.js | 134 +++++ src/ui/handler/tap_zoom.js | 91 ++++ src/ui/handler/touch_pan.js | 101 ++++ src/ui/handler/touch_zoom_rotate.js | 454 ++++++++-------- src/ui/handler_inertia.js | 158 ++++++ src/ui/handler_manager.js | 486 ++++++++++++++++++ src/ui/map.js | 67 ++- src/util/debug.js | 14 + src/util/dom.js | 8 +- src/util/task_queue.js | 8 +- test/build/dev.test.js | 1 + test/build/min.test.js | 1 + test/unit/ui/camera.test.js | 6 + test/unit/ui/handler/dblclick_zoom.test.js | 20 +- test/unit/ui/handler/drag_pan.test.js | 486 +----------------- test/unit/ui/handler/drag_rotate.test.js | 74 +-- test/unit/ui/handler/keyboard.test.js | 130 +++++ test/unit/ui/handler/scroll_zoom.test.js | 3 + .../unit/ui/handler/touch_zoom_rotate.test.js | 48 +- test/unit/ui/map.test.js | 2 +- test/unit/ui/map/isMoving.test.js | 12 +- test/unit/ui/map/isZooming.test.js | 4 +- test/util/simulate_interaction.js | 12 +- test/util/test.js | 2 +- 44 files changed, 2595 insertions(+), 2102 deletions(-) delete mode 100644 src/ui/bind_handlers.js create mode 100644 src/ui/handler/click_zoom.js delete mode 100644 src/ui/handler/dblclick_zoom.js delete mode 100644 src/ui/handler/drag_pan.js delete mode 100644 src/ui/handler/drag_rotate.js create mode 100644 src/ui/handler/handler_util.js create mode 100644 src/ui/handler/map_event.js create mode 100644 src/ui/handler/mouse.js create mode 100644 src/ui/handler/shim/dblclick_zoom.js create mode 100644 src/ui/handler/shim/drag_pan.js create mode 100644 src/ui/handler/shim/drag_rotate.js create mode 100644 src/ui/handler/shim/touch_zoom_rotate.js create mode 100644 src/ui/handler/tap_drag_zoom.js create mode 100644 src/ui/handler/tap_recognizer.js create mode 100644 src/ui/handler/tap_zoom.js create mode 100644 src/ui/handler/touch_pan.js create mode 100644 src/ui/handler_inertia.js create mode 100644 src/ui/handler_manager.js create mode 100644 test/unit/ui/handler/keyboard.test.js diff --git a/bench/benchmarks/paint.js b/bench/benchmarks/paint.js index 3a3625fffae..2a070444e9e 100644 --- a/bench/benchmarks/paint.js +++ b/bench/benchmarks/paint.js @@ -40,7 +40,7 @@ export default class Paint extends Benchmark { for (const map of this.maps) { map._styleDirty = true; map._sourcesDirty = true; - map._render(); + map._render(Date.now()); } } diff --git a/src/ui/bind_handlers.js b/src/ui/bind_handlers.js deleted file mode 100644 index 3b675ca3979..00000000000 --- a/src/ui/bind_handlers.js +++ /dev/null @@ -1,202 +0,0 @@ -// @flow - -import {MapMouseEvent, MapTouchEvent, MapWheelEvent} from '../ui/events'; -import DOM from '../util/dom'; -import type Map from './map'; -import scrollZoom from './handler/scroll_zoom'; -import boxZoom from './handler/box_zoom'; -import dragRotate from './handler/drag_rotate'; -import dragPan from './handler/drag_pan'; -import keyboard from './handler/keyboard'; -import doubleClickZoom from './handler/dblclick_zoom'; -import touchZoomRotate from './handler/touch_zoom_rotate'; - -const handlers = { - scrollZoom, - boxZoom, - dragRotate, - dragPan, - keyboard, - doubleClickZoom, - touchZoomRotate -}; - -export default function bindHandlers(map: Map, options: {interactive: boolean, clickTolerance: number}) { - const el = map.getCanvasContainer(); - let contextMenuEvent = null; - let mouseDown = false; - let startPos = null; - - for (const name in handlers) { - (map: any)[name] = new handlers[name](map, options); - if (options.interactive && options[name]) { - (map: any)[name].enable(options[name]); - } - } - - DOM.addEventListener(el, 'mouseout', onMouseOut); - DOM.addEventListener(el, 'mousedown', onMouseDown); - DOM.addEventListener(el, 'mouseup', onMouseUp); - DOM.addEventListener(el, 'mousemove', onMouseMove); - DOM.addEventListener(el, 'mouseover', onMouseOver); - - // Bind touchstart and touchmove with passive: false because, even though - // they only fire a map events and therefore could theoretically be - // passive, binding with passive: true causes iOS not to respect - // e.preventDefault() in _other_ handlers, even if they are non-passive - // (see https://bugs.webkit.org/show_bug.cgi?id=184251) - DOM.addEventListener(el, 'touchstart', onTouchStart, {passive: false}); - DOM.addEventListener(el, 'touchmove', onTouchMove, {passive: false}); - - DOM.addEventListener(el, 'touchend', onTouchEnd); - DOM.addEventListener(el, 'touchcancel', onTouchCancel); - DOM.addEventListener(el, 'click', onClick); - DOM.addEventListener(el, 'dblclick', onDblClick); - DOM.addEventListener(el, 'contextmenu', onContextMenu); - DOM.addEventListener(el, 'wheel', onWheel, {passive: false}); - - function onMouseDown(e: MouseEvent) { - mouseDown = true; - startPos = DOM.mousePos(el, e); - - const mapEvent = new MapMouseEvent('mousedown', map, e); - map.fire(mapEvent); - - if (mapEvent.defaultPrevented) { - return; - } - - if (options.interactive && !map.doubleClickZoom.isActive()) { - map.stop(); - } - - map.boxZoom.onMouseDown(e); - - if (!map.boxZoom.isActive() && !map.dragPan.isActive()) { - map.dragRotate.onMouseDown(e); - } - - if (!map.boxZoom.isActive() && !map.dragRotate.isActive()) { - map.dragPan.onMouseDown(e); - } - } - - function onMouseUp(e: MouseEvent) { - const rotating = map.dragRotate.isActive(); - - if (contextMenuEvent && !rotating) { - // This will be the case for Mac - map.fire(new MapMouseEvent('contextmenu', map, contextMenuEvent)); - } - - contextMenuEvent = null; - mouseDown = false; - - map.fire(new MapMouseEvent('mouseup', map, e)); - } - - function onMouseMove(e: MouseEvent) { - if (map.dragPan.isActive()) return; - if (map.dragRotate.isActive()) return; - - let target: ?Node = (e.target: any); - while (target && target !== el) target = target.parentNode; - if (target !== el) return; - - map.fire(new MapMouseEvent('mousemove', map, e)); - } - - function onMouseOver(e: MouseEvent) { - let target: ?Node = (e.target: any); - while (target && target !== el) target = target.parentNode; - if (target !== el) return; - - map.fire(new MapMouseEvent('mouseover', map, e)); - } - - function onMouseOut(e: MouseEvent) { - map.fire(new MapMouseEvent('mouseout', map, e)); - } - - function onTouchStart(e: TouchEvent) { - const mapEvent = new MapTouchEvent('touchstart', map, e); - map.fire(mapEvent); - - if (mapEvent.defaultPrevented) { - return; - } - - if (options.interactive) { - map.stop(); - } - - if (!map.boxZoom.isActive() && !map.dragRotate.isActive()) { - map.dragPan.onTouchStart(e); - } - - map.touchZoomRotate.onStart(e); - map.doubleClickZoom.onTouchStart(mapEvent); - } - - function onTouchMove(e: TouchEvent) { - map.fire(new MapTouchEvent('touchmove', map, e)); - } - - function onTouchEnd(e: TouchEvent) { - map.fire(new MapTouchEvent('touchend', map, e)); - } - - function onTouchCancel(e: TouchEvent) { - map.fire(new MapTouchEvent('touchcancel', map, e)); - } - - function onClick(e: MouseEvent) { - const pos = DOM.mousePos(el, e); - if (!startPos || pos.equals(startPos) || pos.dist(startPos) < options.clickTolerance) { - map.fire(new MapMouseEvent('click', map, e)); - } - } - - function onDblClick(e: MouseEvent) { - const mapEvent = new MapMouseEvent('dblclick', map, e); - map.fire(mapEvent); - - if (mapEvent.defaultPrevented) { - return; - } - - map.doubleClickZoom.onDblClick(mapEvent); - } - - function onContextMenu(e: MouseEvent) { - const rotating = map.dragRotate.isActive(); - if (!mouseDown && !rotating) { - // Windows: contextmenu fired on mouseup, so fire event now - map.fire(new MapMouseEvent('contextmenu', map, e)); - } else if (mouseDown) { - // Mac: contextmenu fired on mousedown; we save it until mouseup for consistency's sake - contextMenuEvent = e; - } - - // prevent browser context menu when necessary; we don't allow it with rotation - // because we can't discern rotation gesture start from contextmenu on Mac - if (map.dragRotate.isEnabled() || map.listens('contextmenu')) { - e.preventDefault(); - } - } - - function onWheel(e: WheelEvent) { - if (options.interactive) { - map.stop(); - } - - const mapEvent = new MapWheelEvent('wheel', map, e); - map.fire(mapEvent); - - if (mapEvent.defaultPrevented) { - return; - } - - map.scrollZoom.onWheel(e); - } -} diff --git a/src/ui/camera.js b/src/ui/camera.js index b830914a782..b5d09d89bc7 100644 --- a/src/ui/camera.js +++ b/src/ui/camera.js @@ -15,6 +15,8 @@ import LngLat from '../geo/lng_lat'; import LngLatBounds from '../geo/lng_lat_bounds'; import Point from '@mapbox/point-geometry'; import {Event, Evented} from '../util/evented'; +import assert from 'assert'; +import {Debug} from '../util/debug'; import type Transform from '../geo/transform'; import type {LngLatLike} from '../geo/lng_lat'; @@ -91,9 +93,10 @@ class Camera extends Evented { _easeEndTimeoutID: TimeoutID; _easeStart: number; _easeOptions: {duration: number, easing: (_: number) => number}; + _easeId: string | void; _onEaseFrame: (_: number) => void; - _onEaseEnd: () => void; + _onEaseEnd: (easeId?: string) => void; _easeFrameId: ?TaskID; +_requestRenderFrame: (() => void) => TaskID; @@ -107,6 +110,8 @@ class Camera extends Evented { this._bearingSnap = options.bearingSnap; bindAll(['_renderFrameCallback'], this); + + //addAssertions(this); } /** @@ -708,8 +713,8 @@ class Camera extends Evented { * @returns {Map} `this` * @see [Navigate the map with game-like controls](https://www.mapbox.com/mapbox-gl-js/example/game-controls/) */ - easeTo(options: CameraOptions & AnimationOptions & {delayEndEvents?: number}, eventData?: Object) { - this.stop(); + easeTo(options: CameraOptions & AnimationOptions & {easeId?: string}, eventData?: Object) { + this._stop(false, options.easeId); options = extend({ offset: [0, 0], @@ -747,12 +752,20 @@ class Camera extends Evented { aroundPoint = tr.locationPoint(around); } - this._zooming = (zoom !== startZoom); - this._rotating = (startBearing !== bearing); - this._pitching = (pitch !== startPitch); + const currently = { + moving: this._moving, + zooming: this._zooming, + rotating: this._rotating, + pitching: this._pitching + }; + + this._zooming = this._zooming || (zoom !== startZoom); + this._rotating = this._rotating || (startBearing !== bearing); + this._pitching = this._pitching || (pitch !== startPitch); this._padding = !tr.isPaddingEqual(padding); - this._prepareEase(eventData, options.noMoveStart); + this._easeId = options.easeId; + this._prepareEase(eventData, options.noMoveStart, currently); clearTimeout(this._easeEndTimeoutID); @@ -787,30 +800,26 @@ class Camera extends Evented { this._fireMoveEvents(eventData); - }, () => { - if (options.delayEndEvents) { - this._easeEndTimeoutID = setTimeout(() => this._afterEase(eventData), options.delayEndEvents); - } else { - this._afterEase(eventData); - } + }, (interruptingEaseId?: string) => { + this._afterEase(eventData, interruptingEaseId); }, options); return this; } - _prepareEase(eventData?: Object, noMoveStart: boolean) { + _prepareEase(eventData?: Object, noMoveStart: boolean, currently: Object = {}) { this._moving = true; - if (!noMoveStart) { + if (!noMoveStart && !currently.moving) { this.fire(new Event('movestart', eventData)); } - if (this._zooming) { + if (this._zooming && !currently.zooming) { this.fire(new Event('zoomstart', eventData)); } - if (this._rotating) { + if (this._rotating && !currently.rotating) { this.fire(new Event('rotatestart', eventData)); } - if (this._pitching) { + if (this._pitching && !currently.pitching) { this.fire(new Event('pitchstart', eventData)); } } @@ -828,7 +837,14 @@ class Camera extends Evented { } } - _afterEase(eventData?: Object) { + _afterEase(eventData?: Object, easeId?: string) { + // if this easing is being stopped to start another easing with + // the same id then don't fire any events to avoid extra start/stop events + if (this._easeId && easeId && this._easeId === easeId) { + return; + } + delete this._easeId; + const wasZooming = this._zooming; const wasRotating = this._rotating; const wasPitching = this._pitching; @@ -1078,6 +1094,10 @@ class Camera extends Evented { * @returns {Map} `this` */ stop(): this { + return this._stop(); + } + + _stop(allowGestures?: boolean, easeId?: string): this { if (this._easeFrameId) { this._cancelRenderFrame(this._easeFrameId); delete this._easeFrameId; @@ -1090,7 +1110,11 @@ class Camera extends Evented { // it unintentionally. const onEaseEnd = this._onEaseEnd; delete this._onEaseEnd; - onEaseEnd.call(this); + onEaseEnd.call(this, easeId); + } + if (!allowGestures) { + const handlers = (this: any).handlers; + if (handlers) handlers.stop(); } return this; } @@ -1143,4 +1167,39 @@ class Camera extends Evented { } } +// In debug builds, check that camera change events are fired in the correct order. +// - ___start events needs to be fired before ___ and ___end events +// - another ___start event can't be fired before a ___end event has been fired for the previous one +function addAssertions(camera: Camera) { //eslint-disable-line + Debug.run(() => { + const inProgress = {}; + + ['drag', 'zoom', 'rotate', 'pitch', 'move'].forEach(name => { + inProgress[name] = false; + + camera.on(`${name}start`, () => { + assert(!inProgress[name], `"${name}start" fired twice without a "${name}end"`); + inProgress[name] = true; + assert(inProgress.move); + }); + + camera.on(name, () => { + assert(inProgress[name]); + assert(inProgress.move); + }); + + camera.on(`${name}end`, () => { + assert(inProgress.move); + assert(inProgress[name]); + inProgress[name] = false; + }); + }); + + // Canary used to test whether this function is stripped in prod build + canary = 'canary debug run'; + }); +} + +let canary; //eslint-disable-line + export default Camera; diff --git a/src/ui/control/navigation_control.js b/src/ui/control/navigation_control.js index 8ef200466e5..63aece262f7 100644 --- a/src/ui/control/navigation_control.js +++ b/src/ui/control/navigation_control.js @@ -2,7 +2,8 @@ import DOM from '../../util/dom'; import {extend, bindAll} from '../../util/util'; -import DragRotateHandler from '../handler/drag_rotate'; +import {MouseRotateHandler, MousePitchHandler} from '../handler/mouse'; +import window from '../../util/window'; import type Map from '../map'; @@ -40,7 +41,7 @@ class NavigationControl { _zoomOutButton: HTMLButtonElement; _compass: HTMLButtonElement; _compassIcon: HTMLElement; - _handler: DragRotateHandler; + _handler: MouseRotateWrapper; constructor(options: Options) { this.options = extend({}, defaultOptions, options); @@ -103,11 +104,7 @@ class NavigationControl { } this._map.on('rotate', this._rotateCompassArrow); this._rotateCompassArrow(); - // Temporary fix with clickTolerance (https://github.com/mapbox/mapbox-gl-js/pull/9015) - this._handler = new DragRotateHandler(map, {button: 'left', element: this._compass, clickTolerance: map.dragRotate._clickTolerance}); - DOM.addEventListener(this._compass, 'mousedown', this._handler.onMouseDown); - DOM.addEventListener(this._compass, 'touchstart', this._handler.onMouseDown, {passive: false}); - this._handler.enable(); + this._handler = new MouseRotateWrapper(this._map, this._compass, this.options.visualizePitch); } return this._container; } @@ -122,9 +119,7 @@ class NavigationControl { this._map.off('pitch', this._rotateCompassArrow); } this._map.off('rotate', this._rotateCompassArrow); - DOM.removeEventListener(this._compass, 'mousedown', this._handler.onMouseDown); - DOM.removeEventListener(this._compass, 'touchstart', this._handler.onMouseDown, {passive: false}); - this._handler.disable(); + this._handler.off(); delete this._handler; } @@ -145,4 +140,114 @@ class NavigationControl { } } +class MouseRotateWrapper { + + map: Map; + _clickTolerance: number; + element: HTMLElement; + mouseRotate: MouseRotateHandler; + mousePitch: MousePitchHandler; + _startPos: Point; + _lastPos: Point; + + constructor(map: Map, element: HTMLElement, pitch?: boolean = false) { + this._clickTolerance = 10; + this.element = element; + this.mouseRotate = new MouseRotateHandler({clickTolerance: map.dragRotate._mouseRotate._clickTolerance}); + this.map = map; + if (pitch) this.mousePitch = new MousePitchHandler({clickTolerance: map.dragRotate._mousePitch._clickTolerance}); + + bindAll(['mousedown', 'mousemove', 'mouseup', 'touchstart', 'touchmove', 'touchend', 'reset'], this); + DOM.addEventListener(element, 'mousedown', this.mousedown); + DOM.addEventListener(element, 'touchstart', this.touchstart, {passive: false}); + DOM.addEventListener(element, 'touchmove', this.touchmove); + DOM.addEventListener(element, 'touchend', this.touchend); + DOM.addEventListener(element, 'touchcancel', this.reset); + } + + down(e: MouseEvent, point: Point) { + this.mouseRotate.mousedown(e, point); + if (this.mousePitch) this.mousePitch.mousedown(e, point); + DOM.disableDrag(); + } + + move(e: MouseEvent, point: Point) { + const map = this.map; + const r = this.mouseRotate.windowMousemove(e, point); + if (r && r.bearingDelta) map.setBearing(map.getBearing() + r.bearingDelta); + if (this.mousePitch) { + const p = this.mousePitch.windowMousemove(e, point); + if (p && p.pitchDelta) map.setPitch(map.getPitch() + p.pitchDelta); + } + } + + off() { + const element = this.element; + DOM.removeEventListener(element, 'mousedown', this.mousedown); + DOM.removeEventListener(element, 'touchstart', this.touchstart, {passive: false}); + DOM.removeEventListener(element, 'touchmove', this.touchmove); + DOM.removeEventListener(element, 'touchend', this.touchend); + DOM.removeEventListener(element, 'touchcancel', this.reset); + this.offTemp(); + } + + offTemp() { + DOM.enableDrag(); + DOM.removeEventListener(window, 'mousemove', this.mousemove); + DOM.removeEventListener(window, 'mouseup', this.mouseup); + } + + mousedown(e: MouseEvent) { + this.down(extend({}, e, {ctrlKey: true, preventDefault: () => e.preventDefault()}), DOM.mousePos(this.element, e)); + DOM.addEventListener(window, 'mousemove', this.mousemove); + DOM.addEventListener(window, 'mouseup', this.mouseup); + } + + mousemove(e: MouseEvent) { + this.move(e, DOM.mousePos(this.element, e)); + } + + mouseup(e: MouseEvent) { + this.mouseRotate.windowMouseup(e); + if (this.mousePitch) this.mousePitch.windowMouseup(e); + this.offTemp(); + } + + touchstart(e: TouchEvent) { + if (e.targetTouches.length !== 1) { + this.reset(); + } else { + this._startPos = this._lastPos = DOM.touchPos(this.element, e.targetTouches)[0]; + this.down((({type: 'mousedown', button: 0, ctrlKey: true, preventDefault: () => e.preventDefault()}: any): MouseEvent), this._startPos); + } + } + + touchmove(e: TouchEvent) { + if (e.targetTouches.length !== 1) { + this.reset(); + } else { + this._lastPos = DOM.touchPos(this.element, e.targetTouches)[0]; + this.move((({preventDefault: () => e.preventDefault()}: any): MouseEvent), this._lastPos); + } + } + + touchend(e: TouchEvent) { + if (e.targetTouches.length === 0 && + this._startPos && + this._lastPos && + this._startPos.dist(this._lastPos) < this._clickTolerance) { + this.element.click(); + } + this.reset(); + } + + reset() { + this.mouseRotate.reset(); + if (this.mousePitch) this.mousePitch.reset(); + delete this._startPos; + delete this._lastPos; + this.offTemp(); + } +} + export default NavigationControl; diff --git a/src/ui/events.js b/src/ui/events.js index e1ae12ad637..2cbe7bb047c 100644 --- a/src/ui/events.js +++ b/src/ui/events.js @@ -157,7 +157,7 @@ export class MapTouchEvent extends Event { * @private */ constructor(type: string, map: Map, originalEvent: TouchEvent) { - const points = DOM.touchPos(map.getCanvasContainer(), originalEvent); + const points = DOM.touchPos(map.getCanvasContainer(), originalEvent.touches); const lngLats = points.map((t) => map.unproject(t)); const point = points.reduce((prev, curr, i, arr) => { return prev.add(curr.div(arr.length)); diff --git a/src/ui/handler/box_zoom.js b/src/ui/handler/box_zoom.js index 5b846a4fde9..5665e9a1d6f 100644 --- a/src/ui/handler/box_zoom.js +++ b/src/ui/handler/box_zoom.js @@ -2,8 +2,6 @@ import DOM from '../../util/dom'; -import {bindAll} from '../../util/util'; -import window from '../../util/window'; import {Event} from '../../util/evented'; import type Map from '../map'; @@ -27,18 +25,12 @@ class BoxZoomHandler { * @private */ constructor(map: Map, options: { - clickTolerance?: number + clickTolerance: number }) { this._map = map; this._el = map.getCanvasContainer(); this._container = map.getContainer(); this._clickTolerance = options.clickTolerance || 1; - - bindAll([ - '_onMouseMove', - '_onMouseUp', - '_onKeyDown' - ], this); } /** @@ -81,21 +73,19 @@ class BoxZoomHandler { this._enabled = false; } - onMouseDown(e: MouseEvent) { + mousedown(e: MouseEvent, point: Point) { if (!this.isEnabled()) return; if (!(e.shiftKey && e.button === 0)) return; - window.document.addEventListener('mousemove', this._onMouseMove, false); - window.document.addEventListener('keydown', this._onKeyDown, false); - window.document.addEventListener('mouseup', this._onMouseUp, false); - DOM.disableDrag(); - this._startPos = this._lastPos = DOM.mousePos(this._el, e); + this._startPos = this._lastPos = point; this._active = true; } - _onMouseMove(e: MouseEvent) { - const pos = DOM.mousePos(this._el, e); + windowMousemove(e: MouseEvent, point: Point) { + if (!this._active) return; + + const pos = point; if (this._lastPos.equals(pos) || (!this._box && pos.dist(this._startPos) < this._clickTolerance)) { return; @@ -121,39 +111,40 @@ class BoxZoomHandler { this._box.style.height = `${maxY - minY}px`; } - _onMouseUp(e: MouseEvent) { + windowMouseup(e: MouseEvent, point: Point) { + if (!this._active) return; + if (e.button !== 0) return; const p0 = this._startPos, - p1 = DOM.mousePos(this._el, e); + p1 = point; - this._finish(); + this.reset(); DOM.suppressClick(); if (p0.x === p1.x && p0.y === p1.y) { this._fireEvent('boxzoomcancel', e); } else { - this._map - .fitScreenCoordinates(p0, p1, this._map.getBearing(), {linear: true}) - .fire(new Event('boxzoomend', {originalEvent: e})); + this._map.fire(new Event('boxzoomend', {originalEvent: e})); + return { + cameraAnimation: map => map.fitScreenCoordinates(p0, p1, this._map.getBearing(), {linear: true}) + }; } } - _onKeyDown(e: KeyboardEvent) { + keydown(e: KeyboardEvent) { + if (!this._active) return; + if (e.keyCode === 27) { - this._finish(); + this.reset(); this._fireEvent('boxzoomcancel', e); } } - _finish() { + reset() { this._active = false; - window.document.removeEventListener('mousemove', this._onMouseMove, false); - window.document.removeEventListener('keydown', this._onKeyDown, false); - window.document.removeEventListener('mouseup', this._onMouseUp, false); - this._container.classList.remove('mapboxgl-crosshair'); if (this._box) { diff --git a/src/ui/handler/click_zoom.js b/src/ui/handler/click_zoom.js new file mode 100644 index 00000000000..822de909395 --- /dev/null +++ b/src/ui/handler/click_zoom.js @@ -0,0 +1,48 @@ +// @flow + +import type Point from '@mapbox/point-geometry'; +import type Map from '../map'; + +export default class ClickZoomHandler { + + _enabled: boolean; + _active: boolean; + + constructor() { + this.reset(); + } + + reset() { + this._active = false; + } + + dblclick(e: MouseEvent, point: Point) { + e.preventDefault(); + return { + cameraAnimation: (map: Map) => { + map.easeTo({ + duration: 300, + zoom: map.getZoom() + (e.shiftKey ? -1 : 1), + around: map.unproject(point) + }, {originalEvent: e}); + } + }; + } + + enable() { + this._enabled = true; + } + + disable() { + this._enabled = false; + this.reset(); + } + + isEnabled() { + return this._enabled; + } + + isActive() { + return this._active; + } +} diff --git a/src/ui/handler/dblclick_zoom.js b/src/ui/handler/dblclick_zoom.js deleted file mode 100644 index ed4f75b5c97..00000000000 --- a/src/ui/handler/dblclick_zoom.js +++ /dev/null @@ -1,139 +0,0 @@ -// @flow - -import {bindAll} from '../../util/util'; - -import type Map from '../map'; -import type {MapMouseEvent, MapTouchEvent} from '../events'; -import type Point from '@mapbox/point-geometry'; - -// maximum distance between two tap Points for them to qualify as a double-tap -const maxDist = 30; - -/** - * The `DoubleClickZoomHandler` allows the user to zoom the map at a point by - * double clicking or double tapping. - */ -class DoubleClickZoomHandler { - _map: Map; - _enabled: boolean; - _active: boolean; - _tapped: ?TimeoutID; - _tappedPoint: ?Point; - - /** - * @private - */ - constructor(map: Map) { - this._map = map; - - bindAll([ - '_onDblClick', - '_onZoomEnd' - ], this); - } - - /** - * Returns a Boolean indicating whether the "double click to zoom" interaction is enabled. - * - * @returns {boolean} `true` if the "double click to zoom" interaction is enabled. - */ - isEnabled() { - return !!this._enabled; - } - - /** - * Returns a Boolean indicating whether the "double click to zoom" interaction is active, i.e. currently being used. - * - * @returns {boolean} `true` if the "double click to zoom" interaction is active. - */ - isActive() { - return !!this._active; - } - - /** - * Enables the "double click to zoom" interaction. - * - * @example - * map.doubleClickZoom.enable(); - */ - enable() { - if (this.isEnabled()) return; - this._enabled = true; - } - - /** - * Disables the "double click to zoom" interaction. - * - * @example - * map.doubleClickZoom.disable(); - */ - disable() { - if (!this.isEnabled()) return; - this._enabled = false; - } - - onTouchStart(e: MapTouchEvent) { - if (!this.isEnabled()) return; - if (e.points.length > 1) return; - - if (!this._tapped) { - this._tappedPoint = e.points[0]; - this._tapped = setTimeout(() => { this._tapped = null; this._tappedPoint = null; }, 300); - } else { - const newTap = e.points[0]; - const firstTap = this._tappedPoint; - - if (firstTap && firstTap.dist(newTap) <= maxDist) { - e.originalEvent.preventDefault(); // prevent duplicate zoom on dblclick - - const onTouchEnd = () => { // ignore the touchend event, as it has no point we can zoom to - if (this._tapped) { // make sure we are still within the timeout window - this._zoom(e); // pass the original touchstart event, with the tapped point - } - this._map.off('touchcancel', onTouchCancel); - this._resetTapped(); - }; - - const onTouchCancel = () => { - this._map.off('touchend', onTouchEnd); - this._resetTapped(); - }; - - this._map.once('touchend', onTouchEnd); - this._map.once('touchcancel', onTouchCancel); - - } else { // touches are too far apart, don't zoom - this._resetTapped(); - } - } - } - - _resetTapped() { - clearTimeout(this._tapped); - this._tapped = null; - this._tappedPoint = null; - } - - onDblClick(e: MapMouseEvent) { - if (!this.isEnabled()) return; - e.originalEvent.preventDefault(); - this._zoom(e); - } - - _zoom(e: MapMouseEvent | MapTouchEvent) { - this._active = true; - this._map.on('zoomend', this._onZoomEnd); - this._map.zoomTo( - this._map.getZoom() + (e.originalEvent.shiftKey ? -1 : 1), - {around: e.lngLat}, - e - ); - } - - _onZoomEnd() { - this._active = false; - this._map.off('zoomend', this._onZoomEnd); - } -} - -export default DoubleClickZoomHandler; diff --git a/src/ui/handler/drag_pan.js b/src/ui/handler/drag_pan.js deleted file mode 100644 index a8170f47e52..00000000000 --- a/src/ui/handler/drag_pan.js +++ /dev/null @@ -1,416 +0,0 @@ -// @flow - -import DOM from '../../util/dom'; -import {bezier, bindAll, extend} from '../../util/util'; -import window from '../../util/window'; -import browser from '../../util/browser'; -import {Event} from '../../util/evented'; -import assert from 'assert'; - -import type Map from '../map'; -import type Point from '@mapbox/point-geometry'; -import type {TaskID} from '../../util/task_queue'; - -const defaultInertia = { - linearity: 0.3, - easing: bezier(0, 0, 0.3, 1), - maxSpeed: 1400, - deceleration: 2500, -}; -export type PanInertiaOptions = typeof defaultInertia; - -export type DragPanOptions = boolean | PanInertiaOptions; - -/** - * The `DragPanHandler` allows the user to pan the map by clicking and dragging - * the cursor. - */ -class DragPanHandler { - _map: Map; - _el: HTMLElement; - _state: 'disabled' | 'enabled' | 'pending' | 'active'; - _startPos: Point; - _mouseDownPos: Point; - _prevPos: Point; - _lastPos: Point; - _startTouch: ?Array; - _lastTouch: ?Array; - _lastMoveEvent: MouseEvent | TouchEvent | void; - _inertia: Array<[number, Point]>; - _frameId: ?TaskID; - _clickTolerance: number; - _shouldStart: ?boolean; - _inertiaOptions: PanInertiaOptions; - - /** - * @private - */ - constructor(map: Map, options: { - clickTolerance?: number - }) { - this._map = map; - this._el = map.getCanvasContainer(); - this._state = 'disabled'; - this._clickTolerance = options.clickTolerance || 1; - this._inertiaOptions = defaultInertia; - - bindAll([ - '_onMove', - '_onMouseUp', - '_onTouchEnd', - '_onBlur', - '_onDragFrame' - ], this); - } - - /** - * Returns a Boolean indicating whether the "drag to pan" interaction is enabled. - * - * @returns {boolean} `true` if the "drag to pan" interaction is enabled. - */ - isEnabled() { - return this._state !== 'disabled'; - } - - /** - * Returns a Boolean indicating whether the "drag to pan" interaction is active, i.e. currently being used. - * - * @returns {boolean} `true` if the "drag to pan" interaction is active. - */ - isActive() { - return this._state === 'active'; - } - - /** - * Enables the "drag to pan" interaction. - * - * @param {Object} [options] Options object - * @param {number} [options.linearity=0] factor used to scale the drag velocity - * @param {Function} [options.easing=bezier(0, 0, 0.3, 1)] easing function applled to `map.panTo` when applying the drag. - * @param {number} [options.maxSpeed=1400] the maximum value of the drag velocity. - * @param {number} [options.deceleration=2500] the rate at which the speed reduces after the pan ends. - * - * @example - * map.dragPan.enable(); - * @example - * map.dragpan.enable({ - * linearity: 0.3, - * easing: bezier(0, 0, 0.3, 1), - * maxSpeed: 1400, - * deceleration: 2500, - * }); - */ - enable(options: DragPanOptions) { - if (this.isEnabled()) return; - this._el.classList.add('mapboxgl-touch-drag-pan'); - this._state = 'enabled'; - this._inertiaOptions = extend(defaultInertia, options); - } - - /** - * Disables the "drag to pan" interaction. - * - * @example - * map.dragPan.disable(); - */ - disable() { - if (!this.isEnabled()) return; - this._el.classList.remove('mapboxgl-touch-drag-pan'); - switch (this._state) { - case 'active': - this._state = 'disabled'; - this._unbind(); - this._deactivate(); - this._fireEvent('dragend'); - this._fireEvent('moveend'); - break; - case 'pending': - this._state = 'disabled'; - this._unbind(); - break; - default: - this._state = 'disabled'; - break; - } - } - - onMouseDown(e: MouseEvent) { - if (this._state !== 'enabled') return; - if (e.ctrlKey || DOM.mouseButton(e) !== 0) return; - - // Bind window-level event listeners for mousemove/up events. In the absence of - // the pointer capture API, which is not supported by all necessary platforms, - // window-level event listeners give us the best shot at capturing events that - // fall outside the map canvas element. Use `{capture: true}` for the move event - // to prevent map move events from being fired during a drag. - DOM.addEventListener(window.document, 'mousemove', this._onMove, {capture: true}); - DOM.addEventListener(window.document, 'mouseup', this._onMouseUp); - - this._start(e); - } - - onTouchStart(e: TouchEvent) { - if (!this.isEnabled()) return; - if (e.touches && e.touches.length > 1) { // multi-finger touch - // If we are already dragging (e.g. with one finger) and add another finger, - // keep the handler active but don't attempt to ._start() again - if (this._state === 'pending' || this._state === 'active') return; - } - - // Bind window-level event listeners for touchmove/end events. In the absence of - // the pointer capture API, which is not supported by all necessary platforms, - // window-level event listeners give us the best shot at capturing events that - // fall outside the map canvas element. Use `{capture: true}` for the move event - // to prevent map move events from being fired during a drag. - DOM.addEventListener(window.document, 'touchmove', this._onMove, {capture: true, passive: false}); - DOM.addEventListener(window.document, 'touchend', this._onTouchEnd); - - this._start(e); - } - - _start(e: MouseEvent | TouchEvent) { - // Deactivate when the window loses focus. Otherwise if a mouseup occurs when the window - // isn't in focus, dragging will continue even though the mouse is no longer pressed. - window.addEventListener('blur', this._onBlur); - - this._state = 'pending'; - this._startPos = this._mouseDownPos = this._prevPos = this._lastPos = DOM.mousePos(this._el, e); - this._startTouch = this._lastTouch = (window.TouchEvent && e instanceof window.TouchEvent) ? DOM.touchPos(this._el, e) : null; - this._inertia = [[browser.now(), this._startPos]]; - } - - _touchesMatch(lastTouch: ?Array, thisTouch: ?Array) { - if (!lastTouch || !thisTouch || lastTouch.length !== thisTouch.length) return false; - return lastTouch.every((pos, i) => thisTouch[i] === pos); - } - - _onMove(e: MouseEvent | TouchEvent) { - e.preventDefault(); - - const touchPos = (window.TouchEvent && e instanceof window.TouchEvent) ? DOM.touchPos(this._el, e) : null; - const pos = DOM.mousePos(this._el, e); - - const matchesLastPos = touchPos ? this._touchesMatch(this._lastTouch, touchPos) : this._lastPos.equals(pos); - - if (matchesLastPos || (this._state === 'pending' && pos.dist(this._mouseDownPos) < this._clickTolerance)) { - return; - } - - this._lastMoveEvent = e; - this._lastPos = pos; - this._lastTouch = touchPos; - this._drainInertiaBuffer(); - this._inertia.push([browser.now(), this._lastPos]); - - if (this._state === 'pending') { - this._state = 'active'; - this._shouldStart = true; - } - - if (!this._frameId) { - this._frameId = this._map._requestRenderFrame(this._onDragFrame); - } - } - - /** - * Called in each render frame while dragging is happening. - * @private - */ - _onDragFrame() { - this._frameId = null; - - const e = this._lastMoveEvent; - if (!e) return; - - if (this._map.touchZoomRotate.isActive()) { - this._abort(e); - return; - } - - if (this._shouldStart) { - // we treat the first drag frame (rather than the mousedown event) - // as the start of the drag - this._fireEvent('dragstart', e); - this._fireEvent('movestart', e); - this._shouldStart = false; - } - - if (!this.isActive()) return; // It's possible for the dragstart event to trigger a disable() call (#2419) so we must account for that - - const tr = this._map.transform; - tr.setLocationAtPoint(tr.pointLocation(this._prevPos), this._lastPos); - this._fireEvent('drag', e); - this._fireEvent('move', e); - - this._prevPos = this._lastPos; - delete this._lastMoveEvent; - } - - _onMouseUp(e: MouseEvent) { - if (DOM.mouseButton(e) !== 0) return; - switch (this._state) { - case 'active': - this._state = 'enabled'; - DOM.suppressClick(); - this._unbind(); - this._deactivate(); - this._inertialPan(e); - break; - case 'pending': - this._state = 'enabled'; - this._unbind(); - break; - default: - assert(false); - break; - } - } - - _onTouchEnd(e: TouchEvent) { - if (!e.touches || e.touches.length === 0) { // only stop drag if all fingers have been removed - switch (this._state) { - case 'active': - this._state = 'enabled'; - this._unbind(); - this._deactivate(); - this._inertialPan(e); - break; - case 'pending': - this._state = 'enabled'; - this._unbind(); - break; - case 'enabled': - this._unbind(); - break; - default: - assert(false); - break; - } - } else { // some finger(s) still touching the screen - switch (this._state) { - case 'pending': - case 'active': - // we are already dragging; continue - break; - case 'enabled': - // not currently dragging; get ready to start a new drag - this.onTouchStart(e); - break; - default: - assert(false); - break; - } - } - } - - _abort(e: FocusEvent | MouseEvent | TouchEvent) { - switch (this._state) { - case 'active': - this._state = 'enabled'; - if (!this._shouldStart) { // If we scheduled the dragstart but never fired, nothing to end - // We already started the drag, end it - this._fireEvent('dragend', e); - this._fireEvent('moveend', e); - } - this._unbind(); - this._deactivate(); - if ((window.TouchEvent && e instanceof window.TouchEvent) && e.touches.length > 1) { - // If there are multiple fingers touching, reattach touchend listener in case - // all but one finger is removed and we need to restart a drag on touchend - DOM.addEventListener(window.document, 'touchend', this._onTouchEnd); - } - break; - case 'pending': - this._state = 'enabled'; - this._unbind(); - break; - case 'enabled': - this._unbind(); - break; - default: - assert(false); - break; - } - } - - _onBlur(e: FocusEvent) { - this._abort(e); - } - - _unbind() { - DOM.removeEventListener(window.document, 'touchmove', this._onMove, {capture: true, passive: false}); - DOM.removeEventListener(window.document, 'touchend', this._onTouchEnd); - DOM.removeEventListener(window.document, 'mousemove', this._onMove, {capture: true}); - DOM.removeEventListener(window.document, 'mouseup', this._onMouseUp); - DOM.removeEventListener(window, 'blur', this._onBlur); - } - - _deactivate() { - if (this._frameId) { - this._map._cancelRenderFrame(this._frameId); - this._frameId = null; - } - delete this._lastMoveEvent; - delete this._startPos; - delete this._prevPos; - delete this._mouseDownPos; - delete this._lastPos; - delete this._startTouch; - delete this._lastTouch; - delete this._shouldStart; - } - - _inertialPan(e: MouseEvent | TouchEvent) { - this._fireEvent('dragend', e); - - this._drainInertiaBuffer(); - const inertia = this._inertia; - if (inertia.length < 2) { - this._fireEvent('moveend', e); - return; - } - - const last = inertia[inertia.length - 1], - first = inertia[0], - flingOffset = last[1].sub(first[1]), - flingDuration = (last[0] - first[0]) / 1000; - - if (flingDuration === 0 || last[1].equals(first[1])) { - this._fireEvent('moveend', e); - return; - } - const {linearity, easing, maxSpeed, deceleration} = this._inertiaOptions; - - // calculate px/s velocity & adjust for increased initial animation speed when easing out - const velocity = flingOffset.mult(linearity / flingDuration); - let speed = velocity.mag(); // px/s - - if (speed > maxSpeed) { - speed = maxSpeed; - velocity._unit()._mult(speed); - } - - const duration = speed / (deceleration * linearity), - offset = velocity.mult(-duration / 2); - - this._map.panBy(offset, { - duration: duration * 1000, - easing, - noMoveStart: true - }, {originalEvent: e}); - } - - _fireEvent(type: string, e: *) { - return this._map.fire(new Event(type, e ? {originalEvent: e} : {})); - } - - _drainInertiaBuffer() { - const inertia = this._inertia, - now = browser.now(), - cutoff = 160; // msec - - while (inertia.length > 0 && now - inertia[0][0] > cutoff) inertia.shift(); - } -} - -export default DragPanHandler; diff --git a/src/ui/handler/drag_rotate.js b/src/ui/handler/drag_rotate.js deleted file mode 100644 index 7cf980f50a7..00000000000 --- a/src/ui/handler/drag_rotate.js +++ /dev/null @@ -1,378 +0,0 @@ -// @flow - -import DOM from '../../util/dom'; - -import {bezier, bindAll} from '../../util/util'; -import window from '../../util/window'; -import browser from '../../util/browser'; -import {Event} from '../../util/evented'; -import assert from 'assert'; - -import type Map from '../map'; -import type Point from '@mapbox/point-geometry'; -import type {TaskID} from '../../util/task_queue'; - -const inertiaLinearity = 0.25, - inertiaEasing = bezier(0, 0, inertiaLinearity, 1), - inertiaMaxSpeed = 180, // deg/s - inertiaDeceleration = 720; // deg/s^2 - -/** - * The `DragRotateHandler` allows the user to rotate the map by clicking and - * dragging the cursor while holding the right mouse button or `ctrl` key. - */ -class DragRotateHandler { - _map: Map; - _el: HTMLElement; - _state: 'disabled' | 'enabled' | 'pending' | 'active'; - _button: 'right' | 'left'; - _eventButton: number; - _bearingSnap: number; - _pitchWithRotate: boolean; - _clickTolerance: number; - - _startPos: Point; - _prevPos: Point; - _lastPos: Point; - _startTime: number; - _lastMoveEvent: MouseEvent; - _inertia: Array<[number, number]>; - _center: Point; - _frameId: ?TaskID; - - /** - * @param {Map} map The Mapbox GL JS map to add the handler to. - * @param {Object} [options] - * @param {number} [options.bearingSnap] The threshold, measured in degrees, that determines when the map's - * bearing will snap to north. - * @param {bool} [options.pitchWithRotate=true] Control the map pitch in addition to the bearing - * @private - */ - constructor(map: Map, options: { - button?: 'right' | 'left', - element?: HTMLElement, - bearingSnap?: number, - pitchWithRotate?: boolean, - clickTolerance?: number - }) { - this._map = map; - this._el = options.element || map.getCanvasContainer(); - this._state = 'disabled'; - this._button = options.button || 'right'; - this._bearingSnap = options.bearingSnap || 0; - this._pitchWithRotate = options.pitchWithRotate !== false; - this._clickTolerance = options.clickTolerance || 1; - - bindAll([ - 'onMouseDown', - '_onMouseMove', - '_onMouseUp', - '_onBlur', - '_onDragFrame' - ], this); - } - - /** - * Returns a Boolean indicating whether the "drag to rotate" interaction is enabled. - * - * @returns {boolean} `true` if the "drag to rotate" interaction is enabled. - */ - isEnabled() { - return this._state !== 'disabled'; - } - - /** - * Returns a Boolean indicating whether the "drag to rotate" interaction is active, i.e. currently being used. - * - * @returns {boolean} `true` if the "drag to rotate" interaction is active. - */ - isActive() { - return this._state === 'active'; - } - - /** - * Enables the "drag to rotate" interaction. - * - * @example - * map.dragRotate.enable(); - */ - enable() { - if (this.isEnabled()) return; - this._state = 'enabled'; - } - - /** - * Disables the "drag to rotate" interaction. - * - * @example - * map.dragRotate.disable(); - */ - disable() { - if (!this.isEnabled()) return; - switch (this._state) { - case 'active': - this._state = 'disabled'; - this._unbind(); - this._deactivate(); - this._fireEvent('rotateend'); - if (this._pitchWithRotate) { - this._fireEvent('pitchend'); - } - this._fireEvent('moveend'); - break; - case 'pending': - this._state = 'disabled'; - this._unbind(); - break; - default: - this._state = 'disabled'; - break; - } - } - - onMouseDown(e: MouseEvent) { - if (this._state !== 'enabled') return; - - const touchEvent = e.type === 'touchstart'; - - if (touchEvent) { - this._startTime = Date.now(); - } else { - if (this._button === 'right') { - this._eventButton = DOM.mouseButton(e); - - if (e.altKey || e.metaKey) return; - if (this._eventButton !== (e.ctrlKey ? 0 : 2)) return; - } else { - if (e.ctrlKey || DOM.mouseButton(e) !== 0) return; - this._eventButton = 0; - } - } - - DOM.disableDrag(); - - // Bind window-level event listeners for move and up/end events. In the absence of - // the pointer capture API, which is not supported by all necessary platforms, - // window-level event listeners give us the best shot at capturing events that - // fall outside the map canvas element. Use `{capture: true}` for the move event - // to prevent map move events from being fired during a drag. - if (touchEvent) { - window.document.addEventListener('touchmove', this._onMouseMove, {capture: true}); - window.document.addEventListener('touchend', this._onMouseUp); - } else { - window.document.addEventListener('mousemove', this._onMouseMove, {capture: true}); - window.document.addEventListener('mouseup', this._onMouseUp); - } - - // Deactivate when the window loses focus. Otherwise if a mouseup occurs when the window - // isn't in focus, dragging will continue even though the mouse is no longer pressed. - window.addEventListener('blur', this._onBlur); - - this._state = 'pending'; - this._inertia = [[browser.now(), this._map.getBearing()]]; - this._startPos = this._prevPos = this._lastPos = DOM.mousePos(this._el, e); - this._center = this._map.transform.centerPoint; // Center of rotation - - e.preventDefault(); - } - - _onMouseMove(e: MouseEvent) { - const pos = DOM.mousePos(this._el, e); - if (this._lastPos.equals(pos) || ((this._state === 'pending') && (pos.dist(this._startPos) < this._clickTolerance))) { - return; - } - - this._lastMoveEvent = e; - this._lastPos = pos; - - if (this._state === 'pending') { - this._state = 'active'; - this._fireEvent('rotatestart', e); - this._fireEvent('movestart', e); - if (this._pitchWithRotate) { - this._fireEvent('pitchstart', e); - } - } - - if (!this._frameId) { - this._frameId = this._map._requestRenderFrame(this._onDragFrame); - } - } - - _onDragFrame() { - this._frameId = null; - - const e = this._lastMoveEvent; - if (!e) return; - const tr = this._map.transform; - - const p1 = this._prevPos, - p2 = this._lastPos, - bearingDiff = (p1.x - p2.x) * 0.8, - pitchDiff = (p1.y - p2.y) * -0.5, - bearing = tr.bearing - bearingDiff, - pitch = tr.pitch - pitchDiff, - inertia = this._inertia, - last = inertia[inertia.length - 1]; - - this._drainInertiaBuffer(); - inertia.push([browser.now(), this._map._normalizeBearing(bearing, last[1])]); - - const prevBearing = tr.bearing; - tr.bearing = bearing; - if (this._pitchWithRotate) { - const prevPitch = tr.pitch; - tr.pitch = pitch; - if (tr.pitch !== prevPitch) { - this._fireEvent('pitch', e); - } - } - - if (tr.bearing !== prevBearing) { - this._fireEvent('rotate', e); - } - this._fireEvent('move', e); - - delete this._lastMoveEvent; - this._prevPos = this._lastPos; - } - - _onMouseUp(e: MouseEvent) { - const touchEvent = e.type === 'touchend'; - - if (touchEvent && (this._startPos === this._lastPos) && (Date.now() - this._startTime) < 300) { - this._el.click(); - } - - if (!touchEvent && DOM.mouseButton(e) !== this._eventButton) return; - switch (this._state) { - case 'active': - this._state = 'enabled'; - DOM.suppressClick(); - this._unbind(); - this._deactivate(); - this._inertialRotate(e); - break; - case 'pending': - this._state = 'enabled'; - this._unbind(); - break; - default: - assert(false); - break; - } - } - - _onBlur(e: FocusEvent) { - switch (this._state) { - case 'active': - this._state = 'enabled'; - this._unbind(); - this._deactivate(); - this._fireEvent('rotateend', e); - if (this._pitchWithRotate) { - this._fireEvent('pitchend', e); - } - this._fireEvent('moveend', e); - break; - case 'pending': - this._state = 'enabled'; - this._unbind(); - break; - default: - assert(false); - break; - } - } - - _unbind() { - window.document.removeEventListener('mousemove', this._onMouseMove, {capture: true}); - window.document.removeEventListener('mouseup', this._onMouseUp); - window.document.removeEventListener('touchmove', this._onMouseMove, {capture: true}); - window.document.removeEventListener('touchend', this._onMouseUp); - window.removeEventListener('blur', this._onBlur); - DOM.enableDrag(); - } - - _deactivate() { - if (this._frameId) { - this._map._cancelRenderFrame(this._frameId); - this._frameId = null; - } - delete this._lastMoveEvent; - delete this._startPos; - delete this._prevPos; - delete this._lastPos; - } - - _inertialRotate(e: MouseEvent) { - this._fireEvent('rotateend', e); - this._drainInertiaBuffer(); - - const map = this._map, - mapBearing = map.getBearing(), - inertia = this._inertia; - - const finish = () => { - if (Math.abs(mapBearing) < this._bearingSnap) { - map.resetNorth({noMoveStart: true}, {originalEvent: e}); - } else { - this._fireEvent('moveend', e); - } - if (this._pitchWithRotate) this._fireEvent('pitchend', e); - }; - - if (inertia.length < 2) { - finish(); - return; - } - - const first = inertia[0], - last = inertia[inertia.length - 1], - previous = inertia[inertia.length - 2]; - let bearing = map._normalizeBearing(mapBearing, previous[1]); - const flingDiff = last[1] - first[1], - sign = flingDiff < 0 ? -1 : 1, - flingDuration = (last[0] - first[0]) / 1000; - - if (flingDiff === 0 || flingDuration === 0) { - finish(); - return; - } - - let speed = Math.abs(flingDiff * (inertiaLinearity / flingDuration)); // deg/s - if (speed > inertiaMaxSpeed) { - speed = inertiaMaxSpeed; - } - - const duration = speed / (inertiaDeceleration * inertiaLinearity), - offset = sign * speed * (duration / 2); - - bearing += offset; - - if (Math.abs(map._normalizeBearing(bearing, 0)) < this._bearingSnap) { - bearing = map._normalizeBearing(0, bearing); - } - - map.rotateTo(bearing, { - duration: duration * 1000, - easing: inertiaEasing, - noMoveStart: true - }, {originalEvent: e}); - } - - _fireEvent(type: string, e: *) { - return this._map.fire(new Event(type, e ? {originalEvent: e} : {})); - } - - _drainInertiaBuffer() { - const inertia = this._inertia, - now = browser.now(), - cutoff = 160; //msec - - while (inertia.length > 0 && now - inertia[0][0] > cutoff) - inertia.shift(); - } -} - -export default DragRotateHandler; diff --git a/src/ui/handler/handler_util.js b/src/ui/handler/handler_util.js new file mode 100644 index 00000000000..e26f8a80d5b --- /dev/null +++ b/src/ui/handler/handler_util.js @@ -0,0 +1,12 @@ +// @flow + +import assert from 'assert'; + +export function indexTouches(touches: TouchList, points: Array) { + assert(touches.length === points.length); + const obj = {}; + for (let i = 0; i < touches.length; i++) { + obj[touches[i].identifier] = points[i]; + } + return obj; +} diff --git a/src/ui/handler/keyboard.js b/src/ui/handler/keyboard.js index 2d98e8b3df2..2cd0bf7d82a 100644 --- a/src/ui/handler/keyboard.js +++ b/src/ui/handler/keyboard.js @@ -1,12 +1,12 @@ // @flow -import {bindAll} from '../../util/util'; - import type Map from '../map'; -const panStep = 100, - bearingStep = 15, - pitchStep = 10; +const defaultOptions = { + panStep: 100, + bearingStep: 15, + pitchStep: 10 +}; /** * The `KeyboardHandler` allows the user to zoom, rotate, and pan the map using @@ -23,56 +23,27 @@ const panStep = 100, * - `Shift+⇣`: Decrease the pitch by 10 degrees. */ class KeyboardHandler { - _map: Map; - _el: HTMLElement; _enabled: boolean; + _active: boolean; + _panStep: number; + _bearingStep: number; + _pitchStep: number; /** - * @private - */ - constructor(map: Map) { - this._map = map; - this._el = map.getCanvasContainer(); - - bindAll([ - '_onKeyDown' - ], this); - } - - /** - * Returns a Boolean indicating whether keyboard interaction is enabled. - * - * @returns {boolean} `true` if keyboard interaction is enabled. - */ - isEnabled() { - return !!this._enabled; - } - - /** - * Enables keyboard interaction. - * - * @example - * map.keyboard.enable(); - */ - enable() { - if (this.isEnabled()) return; - this._el.addEventListener('keydown', this._onKeyDown, false); - this._enabled = true; + * @private + */ + constructor() { + const stepOptions = defaultOptions; + this._panStep = stepOptions.panStep; + this._bearingStep = stepOptions.bearingStep; + this._pitchStep = stepOptions.pitchStep; } - /** - * Disables keyboard interaction. - * - * @example - * map.keyboard.disable(); - */ - disable() { - if (!this.isEnabled()) return; - this._el.removeEventListener('keydown', this._onKeyDown); - this._enabled = false; + reset() { + this._active = false; } - _onKeyDown(e: KeyboardEvent) { + keydown(e: KeyboardEvent) { if (e.altKey || e.ctrlKey || e.metaKey) return; let zoomDir = 0; @@ -126,8 +97,8 @@ class KeyboardHandler { if (e.shiftKey) { pitchDir = -1; } else { - yDir = 1; e.preventDefault(); + yDir = 1; } break; @@ -135,26 +106,43 @@ class KeyboardHandler { return; } - const map = this._map; - const zoom = map.getZoom(); + return { + cameraAnimation: (map: Map) => { + const zoom = map.getZoom(); + map.easeTo({ + duration: 300, + easeId: 'keyboardHandler', + easing: easeOut, + + zoom: zoomDir ? Math.round(zoom) + zoomDir * (e.shiftKey ? 2 : 1) : zoom, + bearing: map.getBearing() + bearingDir * this._bearingStep, + pitch: map.getPitch() + pitchDir * this._pitchStep, + offset: [-xDir * this._panStep, -yDir * this._panStep], + center: map.getCenter() + }, {originalEvent: e}); + } + }; + } + + enable() { + this._enabled = true; + } - const easeOptions = { - duration: 300, - delayEndEvents: 500, - easing: easeOut, + disable() { + this._enabled = false; + this.reset(); + } - zoom: zoomDir ? Math.round(zoom) + zoomDir * (e.shiftKey ? 2 : 1) : zoom, - bearing: map.getBearing() + bearingDir * bearingStep, - pitch: map.getPitch() + pitchDir * pitchStep, - offset: [-xDir * panStep, -yDir * panStep], - center: map.getCenter() - }; + isEnabled() { + return this._enabled; + } - map.easeTo(easeOptions, {originalEvent: e}); + isActive() { + return this._active; } } -function easeOut(t) { +function easeOut(t: number) { return t * (2 - t); } diff --git a/src/ui/handler/map_event.js b/src/ui/handler/map_event.js new file mode 100644 index 00000000000..891fe414f2b --- /dev/null +++ b/src/ui/handler/map_event.js @@ -0,0 +1,157 @@ +// @flow + +import {MapMouseEvent, MapTouchEvent, MapWheelEvent} from '../events'; +import type Map from '../map'; + +export class MapEventHandler { + + _mousedownPos: Point; + _clickTolerance: number; + _map: Map; + + constructor(map: Map, options: { clickTolerance: number }) { + this._map = map; + this._clickTolerance = options.clickTolerance; + } + + reset() { + delete this._mousedownPos; + } + + wheel(e: WheelEvent) { + // If mapEvent.preventDefault() is called by the user, prevent handlers such as: + // - ScrollZoom + return this._firePreventable(new MapWheelEvent(e.type, this._map, e)); + } + + mousedown(e: MouseEvent, point: Point) { + this._mousedownPos = point; + // If mapEvent.preventDefault() is called by the user, prevent handlers such as: + // - MousePan + // - MouseRotate + // - MousePitch + // - DblclickHandler + return this._firePreventable(new MapMouseEvent(e.type, this._map, e)); + } + + mouseup(e: MouseEvent) { + this._map.fire(new MapMouseEvent(e.type, this._map, e)); + } + + click(e: MouseEvent, point: Point) { + if (this._mousedownPos && this._mousedownPos.dist(point) >= this._clickTolerance) return; + this._map.fire(new MapMouseEvent(e.type, this._map, e)); + } + + dblclick(e: MouseEvent) { + // If mapEvent.preventDefault() is called by the user, prevent handlers such as: + // - DblClickZoom + return this._firePreventable(new MapMouseEvent(e.type, this._map, e)); + } + + mouseover(e: MouseEvent) { + this._map.fire(new MapMouseEvent(e.type, this._map, e)); + } + + mouseout(e: MouseEvent) { + this._map.fire(new MapMouseEvent(e.type, this._map, e)); + } + + touchstart(e: TouchEvent) { + // If mapEvent.preventDefault() is called by the user, prevent handlers such as: + // - TouchPan + // - TouchZoom + // - TouchRotate + // - TouchPitch + // - TapZoom + // - SwipeZoom + return this._firePreventable(new MapTouchEvent(e.type, this._map, e)); + } + + touchend(e: TouchEvent) { + this._map.fire(new MapTouchEvent(e.type, this._map, e)); + } + + touchcancel(e: TouchEvent) { + this._map.fire(new MapTouchEvent(e.type, this._map, e)); + } + + _firePreventable(mapEvent: MapMouseEvent | MapTouchEvent | MapWheelEvent) { + this._map.fire(mapEvent); + if (mapEvent.defaultPrevented) { + // returning an object marks the handler as active and resets other handlers + return {}; + } + } + + isEnabled() { + return true; + } + + isActive() { + return false; + } + enable() {} + disable() {} +} + +export class BlockableMapEventHandler { + _map: Map; + _delayContextMenu: boolean; + _contextMenuEvent: MouseEvent; + + constructor(map: Map) { + this._map = map; + } + + reset() { + this._delayContextMenu = false; + delete this._contextMenuEvent; + } + + mousemove(e: MouseEvent) { + // mousemove map events should not be fired when interaction handlers (pan, rotate, etc) are active + this._map.fire(new MapMouseEvent(e.type, this._map, e)); + } + + touchmove(e: TouchEvent) { + // touchmove map events should not be fired when interaction handlers (pan, rotate, etc) are active + this._map.fire(new MapTouchEvent(e.type, this._map, e)); + } + + mousedown() { + this._delayContextMenu = true; + } + + mouseup() { + this._delayContextMenu = false; + if (this._contextMenuEvent) { + this._map.fire(new MapMouseEvent('contextmenu', this._map, this._contextMenuEvent)); + delete this._contextMenuEvent; + } + } + contextmenu(e: MouseEvent) { + if (this._delayContextMenu) { + // Mac: contextmenu fired on mousedown; we save it until mouseup for consistency's sake + this._contextMenuEvent = e; + } else { + // Windows: contextmenu fired on mouseup, so fire event now + this._map.fire(new MapMouseEvent(e.type, this._map, e)); + } + + // prevent browser context menu when necessary + if (this._map.listens('contextmenu')) { + e.preventDefault(); + } + } + + isEnabled() { + return true; + } + + isActive() { + return false; + } + enable() {} + disable() {} +} diff --git a/src/ui/handler/mouse.js b/src/ui/handler/mouse.js new file mode 100644 index 00000000000..8c8d42d2ee1 --- /dev/null +++ b/src/ui/handler/mouse.js @@ -0,0 +1,144 @@ +// @flow + +import DOM from '../../util/dom'; +import type Point from '@mapbox/point-geometry'; + +const LEFT_BUTTON = 0; +const RIGHT_BUTTON = 2; + +class MouseHandler { + + _enabled: boolean; + _active: boolean; + _lastPoint: Point; + _eventButton: number; + _moved: boolean; + _clickTolerance: number; + + constructor(options: { clickTolerance: number }) { + this.reset(); + this._clickTolerance = options.clickTolerance || 1; + } + + reset() { + this._active = false; + this._moved = false; + delete this._lastPoint; + delete this._eventButton; + } + + _correctButton(e: MouseEvent, button: number) { //eslint-disable-line + return false; // implemented by child + } + + _move(lastPoint: Point, point: Point) { //eslint-disable-line + return {}; // implemented by child + } + + mousedown(e: MouseEvent, point: Point) { + if (this._lastPoint) return; + + const eventButton = DOM.mouseButton(e); + if (!this._correctButton(e, eventButton)) return; + + this._lastPoint = point; + this._eventButton = eventButton; + } + + windowMousemove(e: MouseEvent, point: Point) { + const lastPoint = this._lastPoint; + if (!lastPoint) return; + e.preventDefault(); + + if (!this._moved && point.dist(lastPoint) < this._clickTolerance) return; + this._moved = true; + this._lastPoint = point; + + // implemented by child class + return this._move(lastPoint, point); + } + + windowMouseup(e: MouseEvent) { + const eventButton = DOM.mouseButton(e); + if (eventButton !== this._eventButton) return; + if (this._moved) DOM.suppressClick(); + this.reset(); + } + + enable() { + this._enabled = true; + } + + disable() { + this._enabled = false; + this.reset(); + } + + isEnabled() { + return this._enabled; + } + + isActive() { + return this._active; + } +} + +export class MousePanHandler extends MouseHandler { + + mousedown(e: MouseEvent, point: Point) { + super.mousedown(e, point); + if (this._lastPoint) this._active = true; + } + _correctButton(e: MouseEvent, button: number) { + return button === LEFT_BUTTON && !e.ctrlKey; + } + + _move(lastPoint: Point, point: Point) { + return { + around: point, + panDelta: point.sub(lastPoint) + }; + } +} + +export class MouseRotateHandler extends MouseHandler { + _correctButton(e: MouseEvent, button: number) { + return (button === LEFT_BUTTON && e.ctrlKey) || (button === RIGHT_BUTTON); + } + + _move(lastPoint: Point, point: Point) { + const degreesPerPixelMoved = 0.8; + const bearingDelta = (point.x - lastPoint.x) * degreesPerPixelMoved; + if (bearingDelta) { + this._active = true; + return {bearingDelta}; + } + } + + contextmenu(e: MouseEvent) { + // prevent browser context menu when necessary; we don't allow it with rotation + // because we can't discern rotation gesture start from contextmenu on Mac + e.preventDefault(); + } +} + +export class MousePitchHandler extends MouseHandler { + _correctButton(e: MouseEvent, button: number) { + return (button === LEFT_BUTTON && e.ctrlKey) || (button === RIGHT_BUTTON); + } + + _move(lastPoint: Point, point: Point) { + const degreesPerPixelMoved = -0.5; + const pitchDelta = (point.y - lastPoint.y) * degreesPerPixelMoved; + if (pitchDelta) { + this._active = true; + return {pitchDelta}; + } + } + + contextmenu(e: MouseEvent) { + // prevent browser context menu when necessary; we don't allow it with rotation + // because we can't discern rotation gesture start from contextmenu on Mac + e.preventDefault(); + } +} diff --git a/src/ui/handler/scroll_zoom.js b/src/ui/handler/scroll_zoom.js index cd19b8d3cb5..a123a1e711e 100644 --- a/src/ui/handler/scroll_zoom.js +++ b/src/ui/handler/scroll_zoom.js @@ -8,11 +8,10 @@ import browser from '../../util/browser'; import window from '../../util/window'; import {number as interpolate} from '../../style-spec/util/interpolate'; import LngLat from '../../geo/lng_lat'; -import {Event} from '../../util/evented'; import type Map from '../map'; +import type HandlerManager from '../handler_manager'; import type Point from '@mapbox/point-geometry'; -import type {TaskID} from '../../util/task_queue'; // deltaY value for mouse scroll wheel identification const wheelZoomDelta = 4.000244140625; @@ -52,7 +51,8 @@ class ScrollZoomHandler { _easing: ?((number) => number); _prevEase: ?{start: number, duration: number, easing: (_: number) => number}; - _frameId: ?TaskID; + _frameId: ?boolean; + _handler: HandlerManager; _defaultZoomRate: number; _wheelZoomRate: number; @@ -60,9 +60,10 @@ class ScrollZoomHandler { /** * @private */ - constructor(map: Map) { + constructor(map: Map, handler: HandlerManager) { this._map = map; this._el = map.getCanvasContainer(); + this._handler = handler; this._delta = 0; @@ -108,12 +109,13 @@ class ScrollZoomHandler { * progress. */ isActive() { - return !!this._active; + return !!this._active || this._finishTimeout !== undefined; } isZooming() { return !!this._zooming; } + /** * Enables the "scroll to zoom" interaction. * @@ -142,7 +144,7 @@ class ScrollZoomHandler { this._enabled = false; } - onWheel(e: WheelEvent) { + wheel(e: WheelEvent) { if (!this.isEnabled()) return; // Remove `any` cast when https://github.com/facebook/flow/issues/4879 is fixed. @@ -189,7 +191,7 @@ class ScrollZoomHandler { if (this._type) { this._lastWheelEvent = e; this._delta -= value; - if (!this.isActive()) { + if (!this._active) { this._start(e); } } @@ -200,7 +202,7 @@ class ScrollZoomHandler { _onTimeout(initialEvent: any) { this._type = 'wheel'; this._delta -= this._lastValue; - if (!this.isActive()) { + if (!this._active) { this._start(initialEvent); } } @@ -209,19 +211,17 @@ class ScrollZoomHandler { if (!this._delta) return; if (this._frameId) { - this._map._cancelRenderFrame(this._frameId); this._frameId = null; } this._active = true; if (!this.isZooming()) { this._zooming = true; - this._map.fire(new Event('movestart', {originalEvent: e})); - this._map.fire(new Event('zoomstart', {originalEvent: e})); } if (this._finishTimeout) { clearTimeout(this._finishTimeout); + delete this._finishTimeout; } const pos = DOM.mousePos(this._el, e); @@ -229,11 +229,17 @@ class ScrollZoomHandler { this._around = LngLat.convert(this._aroundCenter ? this._map.getCenter() : this._map.unproject(pos)); this._aroundPoint = this._map.transform.locationPoint(this._around); if (!this._frameId) { - this._frameId = this._map._requestRenderFrame(this._onScrollFrame); + this._frameId = true; + this._handler._triggerRenderFrame(); } } + renderFrame() { + return this._onScrollFrame(); + } + _onScrollFrame() { + if (!this._frameId) return; this._frameId = null; if (!this.isActive()) return; @@ -271,38 +277,44 @@ class ScrollZoomHandler { const easing = this._easing; let finished = false; + let zoom; if (this._type === 'wheel' && startZoom && easing) { assert(easing && typeof startZoom === 'number'); const t = Math.min((browser.now() - this._lastWheelEventTime) / 200, 1); const k = easing(t); - tr.zoom = interpolate(startZoom, targetZoom, k); + zoom = interpolate(startZoom, targetZoom, k); if (t < 1) { if (!this._frameId) { - this._frameId = this._map._requestRenderFrame(this._onScrollFrame); + this._frameId = true; } } else { finished = true; } } else { - tr.zoom = targetZoom; + zoom = targetZoom; finished = true; } - tr.setLocationAtPoint(this._around, this._aroundPoint); - - this._map.fire(new Event('move', {originalEvent: this._lastWheelEvent})); - this._map.fire(new Event('zoom', {originalEvent: this._lastWheelEvent})); + this._active = true; if (finished) { this._active = false; this._finishTimeout = setTimeout(() => { this._zooming = false; - this._map.fire(new Event('zoomend', {originalEvent: this._lastWheelEvent})); - this._map.fire(new Event('moveend', {originalEvent: this._lastWheelEvent})); + this._handler._triggerRenderFrame(); delete this._targetZoom; + delete this._finishTimeout; }, 200); } + + return { + noInertia: true, + needsRenderFrame: !finished, + zoomDelta: zoom - tr.zoom, + around: this._aroundPoint, + originalEvent: this._lastWheelEvent + }; } _smoothOutEasing(duration: number) { @@ -328,6 +340,10 @@ class ScrollZoomHandler { return easing; } + + reset() { + this._active = false; + } } export default ScrollZoomHandler; diff --git a/src/ui/handler/shim/dblclick_zoom.js b/src/ui/handler/shim/dblclick_zoom.js new file mode 100644 index 00000000000..b63cfbada67 --- /dev/null +++ b/src/ui/handler/shim/dblclick_zoom.js @@ -0,0 +1,62 @@ +// @flow + +import type ClickZoomHandler from '../click_zoom'; +import type TapZoomHandler from './../tap_zoom'; + +/** + * The `DoubleClickZoomHandler` allows the user to zoom the map at a point by + * double clicking or double tapping. + */ +export default class DoubleClickZoomHandler { + + _clickZoom: ClickZoomHandler; + _tapZoom: TapZoomHandler; + + /** + * @private + */ + constructor(clickZoom: ClickZoomHandler, TapZoom: TapZoomHandler) { + this._clickZoom = clickZoom; + this._tapZoom = TapZoom; + } + + /** + * Enables the "double click to zoom" interaction. + * + * @example + * map.doubleClickZoom.enable(); + */ + enable() { + this._clickZoom.enable(); + this._tapZoom.enable(); + } + + /** + * Disables the "double click to zoom" interaction. + * + * @example + * map.doubleClickZoom.disable(); + */ + disable() { + this._clickZoom.disable(); + this._tapZoom.disable(); + } + + /** + * Returns a Boolean indicating whether the "double click to zoom" interaction is enabled. + * + * @returns {boolean} `true` if the "double click to zoom" interaction is enabled. + */ + isEnabled() { + return this._clickZoom.isEnabled() && this._tapZoom.isEnabled(); + } + + /** + * Returns a Boolean indicating whether the "double click to zoom" interaction is active, i.e. currently being used. + * + * @returns {boolean} `true` if the "double click to zoom" interaction is active. + */ + isActive() { + return this._clickZoom.isActive() || this._tapZoom.isActive(); + } +} diff --git a/src/ui/handler/shim/drag_pan.js b/src/ui/handler/shim/drag_pan.js new file mode 100644 index 00000000000..97e9281b773 --- /dev/null +++ b/src/ui/handler/shim/drag_pan.js @@ -0,0 +1,88 @@ +// @flow + +import type {MousePanHandler} from '../mouse'; +import type TouchPanHandler from './../touch_pan'; + +export type DragPanOptions = { + linearity?: number; + easing?: (t: number) => number; + deceleration?: number; + maxSpeed?: number; +}; + +/** + * The `DragPanHandler` allows the user to pan the map by clicking and dragging + * the cursor. + */ +export default class DragPanHandler { + + _el: HTMLElement; + _mousePan: MousePanHandler; + _touchPan: TouchPanHandler; + _inertiaOptions: DragPanOptions + + /** + * @private + */ + constructor(el: HTMLElement, mousePan: MousePanHandler, touchPan: TouchPanHandler) { + this._el = el; + this._mousePan = mousePan; + this._touchPan = touchPan; + } + + /** + * Enables the "drag to pan" interaction. + * + * @param {Object} [options] Options object + * @param {number} [options.linearity=0] factor used to scale the drag velocity + * @param {Function} [options.easing=bezier(0, 0, 0.3, 1)] easing function applled to `map.panTo` when applying the drag. + * @param {number} [options.maxSpeed=1400] the maximum value of the drag velocity. + * @param {number} [options.deceleration=2500] the rate at which the speed reduces after the pan ends. + * + * @example + * map.dragPan.enable(); + * @example + * map.dragpan.enable({ + * linearity: 0.3, + * easing: bezier(0, 0, 0.3, 1), + * maxSpeed: 1400, + * deceleration: 2500, + * }); + */ + enable(options?: DragPanOptions) { + this._inertiaOptions = options || {}; + this._mousePan.enable(); + this._touchPan.enable(); + this._el.classList.add('mapboxgl-touch-drag-pan'); + } + + /** + * Disables the "drag to pan" interaction. + * + * @example + * map.dragPan.disable(); + */ + disable() { + this._mousePan.disable(); + this._touchPan.disable(); + this._el.classList.remove('mapboxgl-touch-drag-pan'); + } + + /** + * Returns a Boolean indicating whether the "drag to pan" interaction is enabled. + * + * @returns {boolean} `true` if the "drag to pan" interaction is enabled. + */ + isEnabled() { + return this._mousePan.isEnabled() && this._touchPan.isEnabled(); + } + + /** + * Returns a Boolean indicating whether the "drag to pan" interaction is active, i.e. currently being used. + * + * @returns {boolean} `true` if the "drag to pan" interaction is active. + */ + isActive() { + return this._mousePan.isActive() || this._touchPan.isActive(); + } +} diff --git a/src/ui/handler/shim/drag_rotate.js b/src/ui/handler/shim/drag_rotate.js new file mode 100644 index 00000000000..5d3878b9f7d --- /dev/null +++ b/src/ui/handler/shim/drag_rotate.js @@ -0,0 +1,67 @@ +// @flow + +import type {MouseRotateHandler, MousePitchHandler} from '../mouse'; + +/** + * The `DragRotateHandler` allows the user to rotate the map by clicking and + * dragging the cursor while holding the right mouse button or `ctrl` key. + */ +export default class DragRotateHandler { + + _mouseRotate: MouseRotateHandler; + _mousePitch: MousePitchHandler; + _pitchWithRotate: boolean; + + /** + * @param {Object} [options] + * @param {number} [options.bearingSnap] The threshold, measured in degrees, that determines when the map's + * bearing will snap to north. + * @param {bool} [options.pitchWithRotate=true] Control the map pitch in addition to the bearing + * @private + */ + constructor(options: {pitchWithRotate: boolean}, mouseRotate: MouseRotateHandler, mousePitch: MousePitchHandler) { + this._pitchWithRotate = options.pitchWithRotate; + this._mouseRotate = mouseRotate; + this._mousePitch = mousePitch; + } + + /** + * Enables the "drag to rotate" interaction. + * + * @example + * map.dragRotate.enable(); + */ + enable() { + this._mouseRotate.enable(); + if (this._pitchWithRotate) this._mousePitch.enable(); + } + + /** + * Disables the "drag to rotate" interaction. + * + * @example + * map.dragRotate.disable(); + */ + disable() { + this._mouseRotate.disable(); + this._mousePitch.disable(); + } + + /** + * Returns a Boolean indicating whether the "drag to rotate" interaction is enabled. + * + * @returns {boolean} `true` if the "drag to rotate" interaction is enabled. + */ + isEnabled() { + return this._mouseRotate.isEnabled() && (!this._pitchWithRotate || this._mousePitch.isEnabled()); + } + + /** + * Returns a Boolean indicating whether the "drag to rotate" interaction is active, i.e. currently being used. + * + * @returns {boolean} `true` if the "drag to rotate" interaction is active. + */ + isActive() { + return this._mouseRotate.isEnabled() || this._mousePitch.isEnabled(); + } +} diff --git a/src/ui/handler/shim/touch_zoom_rotate.js b/src/ui/handler/shim/touch_zoom_rotate.js new file mode 100644 index 00000000000..b1cbe4ea7bd --- /dev/null +++ b/src/ui/handler/shim/touch_zoom_rotate.js @@ -0,0 +1,108 @@ +// @flow + +import type {TouchZoomHandler, TouchRotateHandler} from '../touch_zoom_rotate'; +import type TapDragZoomHandler from '../tap_drag_zoom'; + +/** + * The `TouchZoomRotateHandler` allows the user to zoom and rotate the map by + * pinching on a touchscreen. + * + * They can zoom with one finger by double tapping and dragging. On the second tap, + * hold the finger down and drag up or down to zoom in or out. + */ +export default class TouchZoomRotateHandler { + + _el: HTMLElement; + _touchZoom: TouchZoomHandler; + _touchRotate: TouchRotateHandler; + _tapDragZoom: TapDragZoomHandler; + _rotationDisabled: boolean; + _enabled: boolean; + + /** + * @private + */ + constructor(el: HTMLElement, touchZoom: TouchZoomHandler, touchRotate: TouchRotateHandler, tapDragZoom: TapDragZoomHandler) { + this._el = el; + this._touchZoom = touchZoom; + this._touchRotate = touchRotate; + this._tapDragZoom = tapDragZoom; + this._rotationDisabled = false; + this._enabled = true; + } + + /** + * Enables the "pinch to rotate and zoom" interaction. + * + * @param {Object} [options] Options object. + * @param {string} [options.around] If "center" is passed, map will zoom around the center + * + * @example + * map.touchZoomRotate.enable(); + * @example + * map.touchZoomRotate.enable({ around: 'center' }); + */ + enable(options: ?{around?: 'center'}) { + this._touchZoom.enable(options); + if (!this._rotationDisabled) this._touchRotate.enable(options); + this._tapDragZoom.enable(); + this._el.classList.add('mapboxgl-touch-zoom-rotate'); + } + + /** + * Disables the "pinch to rotate and zoom" interaction. + * + * @example + * map.touchZoomRotate.disable(); + */ + disable() { + this._touchZoom.disable(); + this._touchRotate.disable(); + this._tapDragZoom.disable(); + this._el.classList.remove('mapboxgl-touch-zoom-rotate'); + } + + /** + * Returns a Boolean indicating whether the "pinch to rotate and zoom" interaction is enabled. + * + * @returns {boolean} `true` if the "pinch to rotate and zoom" interaction is enabled. + */ + isEnabled() { + return this._touchZoom.isEnabled() && + (this._rotationDisabled || this._touchRotate.isEnabled()) && + this._tapDragZoom.isEnabled(); + } + + /** + * Returns true if the handler is enabled and has detected the start of a zoom/rotate gesture. + * + * @returns {boolean} //eslint-disable-line + */ + isActive() { + return this._touchZoom.isActive() || this._touchRotate.isActive() || this._tapDragZoom.isActive(); + } + + /** + * Disables the "pinch to rotate" interaction, leaving the "pinch to zoom" + * interaction enabled. + * + * @example + * map.touchZoomRotate.disableRotation(); + */ + disableRotation() { + this._rotationDisabled = true; + this._touchRotate.disable(); + } + + /** + * Enables the "pinch to rotate" interaction. + * + * @example + * map.touchZoomRotate.enable(); + * map.touchZoomRotate.enableRotation(); + */ + enableRotation() { + this._rotationDisabled = false; + if (this._touchZoom.isEnabled()) this._touchRotate.enable(); + } +} diff --git a/src/ui/handler/tap_drag_zoom.js b/src/ui/handler/tap_drag_zoom.js new file mode 100644 index 00000000000..7f651f6b99e --- /dev/null +++ b/src/ui/handler/tap_drag_zoom.js @@ -0,0 +1,103 @@ +// @flow + +import {TapRecognizer, MAX_TAP_INTERVAL} from './tap_recognizer'; +import type Point from '@mapbox/point-geometry'; + +export default class TapDragZoomHandler { + + _enabled: boolean; + _active: boolean; + _swipePoint: Point; + _swipeTouch: number; + _tapTime: number; + _tap: TapRecognizer; + + constructor() { + + this._tap = new TapRecognizer({ + numTouches: 1, + numTaps: 1 + }); + + this.reset(); + } + + reset() { + this._active = false; + delete this._swipePoint; + delete this._swipeTouch; + delete this._tapTime; + this._tap.reset(); + } + + touchstart(e: TouchEvent, points: Array) { + if (this._swipePoint) return; + + if (this._tapTime && e.timeStamp - this._tapTime > MAX_TAP_INTERVAL) { + this.reset(); + } + + if (!this._tapTime) { + this._tap.touchstart(e, points); + } else if (e.targetTouches.length > 0) { + this._swipePoint = points[0]; + this._swipeTouch = e.targetTouches[0].identifier; + } + + } + + touchmove(e: TouchEvent, points: Array) { + if (!this._tapTime) { + this._tap.touchmove(e, points); + } else if (this._swipePoint) { + if (e.targetTouches[0].identifier !== this._swipeTouch) { + return; + } + + const newSwipePoint = points[0]; + const dist = newSwipePoint.y - this._swipePoint.y; + this._swipePoint = newSwipePoint; + + e.preventDefault(); + this._active = true; + + return { + zoomDelta: dist / -128 + }; + } + } + + touchend(e: TouchEvent) { + if (!this._tapTime) { + const point = this._tap.touchend(e); + if (point) { + this._tapTime = e.timeStamp; + } + } else if (this._swipePoint) { + if (e.targetTouches.length === 0) { + this.reset(); + } + } + } + + touchcancel() { + this.reset(); + } + + enable() { + this._enabled = true; + } + + disable() { + this._enabled = false; + this.reset(); + } + + isEnabled() { + return this._enabled; + } + + isActive() { + return this._active; + } +} diff --git a/src/ui/handler/tap_recognizer.js b/src/ui/handler/tap_recognizer.js new file mode 100644 index 00000000000..1d9f11121a1 --- /dev/null +++ b/src/ui/handler/tap_recognizer.js @@ -0,0 +1,134 @@ +// @flow + +import Point from '@mapbox/point-geometry'; +import {indexTouches} from './handler_util'; + +function getCentroid(points: Array) { + const sum = new Point(0, 0); + for (const point of points) { + sum._add(point); + } + return sum.div(points.length); +} + +export const MAX_TAP_INTERVAL = 500; +const MAX_TOUCH_TIME = 500; +const MAX_DIST = 30; + +export class SingleTapRecognizer { + + numTouches: number; + centroid: Point; + startTime: number; + aborted: boolean; + touches: { [number | string]: Point }; + + constructor(options: { numTouches: number }) { + this.reset(); + this.numTouches = options.numTouches; + } + + reset() { + delete this.centroid; + delete this.startTime; + delete this.touches; + this.aborted = false; + } + + touchstart(e: TouchEvent, points: Array) { + + if (this.centroid || e.targetTouches.length > this.numTouches) { + this.aborted = true; + } + if (this.aborted) { + return; + } + + if (this.startTime === undefined) { + this.startTime = e.timeStamp; + } + + if (e.targetTouches.length === this.numTouches) { + this.centroid = getCentroid(points); + this.touches = indexTouches(e.targetTouches, points); + } + } + + touchmove(e: TouchEvent, points: Array) { + if (this.aborted || !this.centroid) return; + + const newTouches = indexTouches(e.targetTouches, points); + for (const id in this.touches) { + const prevPos = this.touches[id]; + const pos = newTouches[id]; + if (!pos || pos.dist(prevPos) > MAX_DIST) { + this.aborted = true; + } + } + } + + touchend(e: TouchEvent) { + if (!this.centroid || e.timeStamp - this.startTime > MAX_TOUCH_TIME) { + this.aborted = true; + } + + if (e.targetTouches.length === 0) { + const centroid = !this.aborted && this.centroid; + this.reset(); + if (centroid) return centroid; + } + } + +} + +export class TapRecognizer { + + singleTap: SingleTapRecognizer; + numTaps: number; + lastTime: number; + lastTap: Point; + count: number; + + constructor(options: { numTaps: number, numTouches: number }) { + this.singleTap = new SingleTapRecognizer(options); + this.numTaps = options.numTaps; + this.reset(); + } + + reset() { + this.lastTime = Infinity; + delete this.lastTap; + this.count = 0; + this.singleTap.reset(); + } + + touchstart(e: TouchEvent, points: Array) { + this.singleTap.touchstart(e, points); + } + + touchmove(e: TouchEvent, points: Array) { + this.singleTap.touchmove(e, points); + } + + touchend(e: TouchEvent) { + const tap = this.singleTap.touchend(e); + if (tap) { + const soonEnough = e.timeStamp - this.lastTime < MAX_TAP_INTERVAL; + const closeEnough = !this.lastTap || this.lastTap.dist(tap) < MAX_DIST; + + if (!soonEnough || !closeEnough) { + this.reset(); + } + + this.count++; + this.lastTime = e.timeStamp; + this.lastTap = tap; + + if (this.count === this.numTaps) { + e.preventDefault(); + this.reset(); + return tap; + } + } + } +} diff --git a/src/ui/handler/tap_zoom.js b/src/ui/handler/tap_zoom.js new file mode 100644 index 00000000000..060a40af9d8 --- /dev/null +++ b/src/ui/handler/tap_zoom.js @@ -0,0 +1,91 @@ +// @flow + +import {TapRecognizer} from './tap_recognizer'; +import type Point from '@mapbox/point-geometry'; +import type Map from '../map'; + +export default class TapZoomHandler { + + _enabled: boolean; + _active: boolean; + _zoomIn: TapRecognizer; + _zoomOut: TapRecognizer; + + constructor() { + this._zoomIn = new TapRecognizer({ + numTouches: 1, + numTaps: 2 + }); + + this._zoomOut = new TapRecognizer({ + numTouches: 2, + numTaps: 1 + }); + + this.reset(); + } + + reset() { + this._active = false; + this._zoomIn.reset(); + this._zoomOut.reset(); + } + + touchstart(e: TouchEvent, points: Array) { + this._zoomIn.touchstart(e, points); + this._zoomOut.touchstart(e, points); + } + + touchmove(e: TouchEvent, points: Array) { + this._zoomIn.touchmove(e, points); + this._zoomOut.touchmove(e, points); + } + + touchend(e: TouchEvent) { + const zoomInPoint = this._zoomIn.touchend(e); + const zoomOutPoint = this._zoomOut.touchend(e); + + if (zoomInPoint) { + this._active = true; + setTimeout(() => this.reset(), 0); + return { + cameraAnimation: (map: Map) => map.easeTo({ + duration: 300, + zoom: map.getZoom() + 1, + around: map.unproject(zoomInPoint) + }, {originalEvent: e}) + }; + } else if (zoomOutPoint) { + this._active = true; + setTimeout(() => this.reset(), 0); + return { + cameraAnimation: (map: Map) => map.easeTo({ + duration: 300, + zoom: map.getZoom() - 1, + around: map.unproject(zoomOutPoint) + }, {originalEvent: e}) + }; + } + } + + touchcancel() { + this.reset(); + } + + enable() { + this._enabled = true; + } + + disable() { + this._enabled = false; + this.reset(); + } + + isEnabled() { + return this._enabled; + } + + isActive() { + return this._active; + } +} diff --git a/src/ui/handler/touch_pan.js b/src/ui/handler/touch_pan.js new file mode 100644 index 00000000000..a04a7f7f1d6 --- /dev/null +++ b/src/ui/handler/touch_pan.js @@ -0,0 +1,101 @@ +// @flow + +import Point from '@mapbox/point-geometry'; +import {indexTouches} from './handler_util'; + +export default class TouchPanHandler { + + _enabled: boolean; + _active: boolean; + _touches: { [string | number]: Point }; + _minTouches: number; + _clickTolerance: number; + _sum: Point; + + constructor(options: { clickTolerance: number }) { + this._minTouches = 1; + this._clickTolerance = options.clickTolerance || 1; + this.reset(); + } + + reset() { + this._active = false; + this._touches = {}; + this._sum = new Point(0, 0); + } + + touchstart(e: TouchEvent, points: Array) { + return this._calculateTransform(e, points); + } + + touchmove(e: TouchEvent, points: Array) { + if (!this._active) return; + e.preventDefault(); + return this._calculateTransform(e, points); + } + + touchend(e: TouchEvent, points: Array) { + this._calculateTransform(e, points); + + if (this._active && e.targetTouches.length < this._minTouches) { + this.reset(); + } + } + + touchcancel() { + this.reset(); + } + + _calculateTransform(e: TouchEvent, points: Array) { + if (e.targetTouches.length > 0) this._active = true; + + const touches = indexTouches(e.targetTouches, points); + + const touchPointSum = new Point(0, 0); + const touchDeltaSum = new Point(0, 0); + let touchDeltaCount = 0; + + for (const identifier in touches) { + const point = touches[identifier]; + const prevPoint = this._touches[identifier]; + if (prevPoint) { + touchPointSum._add(point); + touchDeltaSum._add(point.sub(prevPoint)); + touchDeltaCount++; + touches[identifier] = point; + } + } + + this._touches = touches; + + if (touchDeltaCount < this._minTouches || !touchDeltaSum.mag()) return; + + const panDelta = touchDeltaSum.div(touchDeltaCount); + this._sum._add(panDelta); + if (this._sum.mag() < this._clickTolerance) return; + + const around = touchPointSum.div(touchDeltaCount); + + return { + around, + panDelta + }; + } + + enable() { + this._enabled = true; + } + + disable() { + this._enabled = false; + this.reset(); + } + + isEnabled() { + return this._enabled; + } + + isActive() { + return this._active; + } +} diff --git a/src/ui/handler/touch_zoom_rotate.js b/src/ui/handler/touch_zoom_rotate.js index cd70b94367d..d7b7743b5b9 100644 --- a/src/ui/handler/touch_zoom_rotate.js +++ b/src/ui/handler/touch_zoom_rotate.js @@ -1,293 +1,303 @@ // @flow +import Point from '@mapbox/point-geometry'; import DOM from '../../util/dom'; -import {bezier, bindAll} from '../../util/util'; -import window from '../../util/window'; -import browser from '../../util/browser'; -import {Event} from '../../util/evented'; - -import type Map from '../map'; -import type Point from '@mapbox/point-geometry'; -import type LngLat from '../../geo/lng_lat'; -import type {TaskID} from '../../util/task_queue'; - -const inertiaLinearity = 0.15, - inertiaEasing = bezier(0, 0, inertiaLinearity, 1), - inertiaDeceleration = 12, // scale / s^2 - inertiaMaxSpeed = 2.5, // scale / s - significantScaleThreshold = 0.15, - significantRotateThreshold = 10; -/** - * The `TouchZoomRotateHandler` allows the user to zoom and rotate the map by - * pinching on a touchscreen. - */ -class TouchZoomRotateHandler { - _map: Map; - _el: HTMLElement; +class TwoTouchHandler { + _enabled: boolean; + _active: boolean; + _firstTwoTouches: [number, number]; + _vector: Point; + _startVector: Point; _aroundCenter: boolean; - _rotationDisabled: boolean; - _startVec: Point; - _startAround: LngLat; - _startScale: number; - _startBearing: number; - _gestureIntent: 'rotate' | 'zoom' | void; - _inertia: Array<[number, number, Point]>; - _lastTouchEvent: TouchEvent; - _frameId: ?TaskID; - /** - * @private - */ - constructor(map: Map) { - this._map = map; - this._el = map.getCanvasContainer(); - - bindAll([ - '_onMove', - '_onEnd', - '_onTouchFrame' - ], this); + constructor() { + this.reset(); } - /** - * Returns a Boolean indicating whether the "pinch to rotate and zoom" interaction is enabled. - * - * @returns {boolean} `true` if the "pinch to rotate and zoom" interaction is enabled. - */ - isEnabled() { - return !!this._enabled; + reset() { + this._active = false; + delete this._firstTwoTouches; + } + + _start(points: [Point, Point]) {} //eslint-disable-line + _move(points: [Point, Point], pinchAround: Point, e: TouchEvent) { return {}; } //eslint-disable-line + + touchstart(e: TouchEvent, points: Array) { + if (this._firstTwoTouches || e.targetTouches.length < 2) return; + + this._firstTwoTouches = [ + e.targetTouches[0].identifier, + e.targetTouches[1].identifier + ]; + + // implemented by child classes + this._start([points[0], points[1]]); + } + + touchmove(e: TouchEvent, points: Array) { + if (!this._firstTwoTouches) return; + + e.preventDefault(); + + const [idA, idB] = this._firstTwoTouches; + const a = getTouchById(e, points, idA); + const b = getTouchById(e, points, idB); + if (!a || !b) return; + const pinchAround = this._aroundCenter ? null : a.add(b).div(2); + + // implemented by child classes + return this._move([a, b], pinchAround, e); + + } + + touchend(e: TouchEvent, points: Array) { + if (!this._firstTwoTouches) return; + + const [idA, idB] = this._firstTwoTouches; + const a = getTouchById(e, points, idA); + const b = getTouchById(e, points, idB); + if (a && b) return; + + if (this._active) DOM.suppressClick(); + + this.reset(); + } + + touchcancel() { + this.reset(); } - /** - * Enables the "pinch to rotate and zoom" interaction. - * - * @param {Object} [options] Options object. - * @param {string} [options.around] If "center" is passed, map will zoom around the center - * - * @example - * map.touchZoomRotate.enable(); - * @example - * map.touchZoomRotate.enable({ around: 'center' }); - */ enable(options: ?{around?: 'center'}) { - if (this.isEnabled()) return; - this._el.classList.add('mapboxgl-touch-zoom-rotate'); this._enabled = true; this._aroundCenter = !!options && options.around === 'center'; } - /** - * Disables the "pinch to rotate and zoom" interaction. - * - * @example - * map.touchZoomRotate.disable(); - */ disable() { - if (!this.isEnabled()) return; - this._el.classList.remove('mapboxgl-touch-zoom-rotate'); this._enabled = false; + this.reset(); } - /** - * Disables the "pinch to rotate" interaction, leaving the "pinch to zoom" - * interaction enabled. - * - * @example - * map.touchZoomRotate.disableRotation(); - */ - disableRotation() { - this._rotationDisabled = true; + isEnabled() { + return this._enabled; } - /** - * Enables the "pinch to rotate" interaction. - * - * @example - * map.touchZoomRotate.enable(); - * map.touchZoomRotate.enableRotation(); - */ - enableRotation() { - this._rotationDisabled = false; + isActive() { + return this._active; } +} - /** - * Returns true if the handler is enabled and has detected the start of a zoom/rotate gesture. - * @returns {boolean} //eslint-disable-line - * @memberof TouchZoomRotateHandler - */ - isActive(): boolean { - return this.isEnabled() && !!this._gestureIntent; +function getTouchById(e: TouchEvent, points: Array, identifier: number) { + for (let i = 0; i < e.targetTouches.length; i++) { + if (e.targetTouches[i].identifier === identifier) return points[i]; } +} - onStart(e: TouchEvent) { - if (!this.isEnabled()) return; - if (e.touches.length !== 2) return; +/* ZOOM */ - const p0 = DOM.mousePos(this._el, e.touches[0]), - p1 = DOM.mousePos(this._el, e.touches[1]), - center = p0.add(p1).div(2); +const ZOOM_THRESHOLD = 0.1; - this._startVec = p0.sub(p1); - this._startAround = this._map.transform.pointLocation(center); - this._gestureIntent = undefined; - this._inertia = []; +function getZoomDelta(distance, lastDistance) { + return Math.log(distance / lastDistance) / Math.LN2; +} + +export class TouchZoomHandler extends TwoTouchHandler { - DOM.addEventListener(window.document, 'touchmove', this._onMove, {passive: false}); - DOM.addEventListener(window.document, 'touchend', this._onEnd); + _distance: number; + _startDistance: number; + + reset() { + super.reset(); + delete this._distance; + delete this._startDistance; } - _getTouchEventData(e: TouchEvent) { - const p0 = DOM.mousePos(this._el, e.touches[0]), - p1 = DOM.mousePos(this._el, e.touches[1]); + _start(points: [Point, Point]) { + this._startDistance = this._distance = points[0].dist(points[1]); + } - const vec = p0.sub(p1); + _move(points: [Point, Point], pinchAround: Point) { + const lastDistance = this._distance; + this._distance = points[0].dist(points[1]); + if (!this._active && Math.abs(getZoomDelta(this._distance, this._startDistance)) < ZOOM_THRESHOLD) return; + this._active = true; return { - vec, - center: p0.add(p1).div(2), - scale: vec.mag() / this._startVec.mag(), - bearing: this._rotationDisabled ? 0 : vec.angleWith(this._startVec) * 180 / Math.PI + zoomDelta: getZoomDelta(this._distance, lastDistance), + pinchAround }; } +} - _onMove(e: TouchEvent) { - if (e.touches.length !== 2) return; - - const {vec, scale, bearing} = this._getTouchEventData(e); +/* ROTATE */ - // Determine 'intent' by whichever threshold is surpassed first, - // then keep that state for the duration of this gesture. - if (!this._gestureIntent) { - // when rotation is disabled, any scale change triggers the zoom gesture to start - const scalingSignificantly = (this._rotationDisabled && scale !== 1) || (Math.abs(1 - scale) > significantScaleThreshold), - rotatingSignificantly = (Math.abs(bearing) > significantRotateThreshold); +const ROTATION_THRESHOLD = 25; // pixels along circumference of touch circle - if (rotatingSignificantly) { - this._gestureIntent = 'rotate'; - } else if (scalingSignificantly) { - this._gestureIntent = 'zoom'; - } +function getBearingDelta(a, b) { + return a.angleWith(b) * 180 / Math.PI; +} - if (this._gestureIntent) { - this._map.fire(new Event(`${this._gestureIntent}start`, {originalEvent: e})); - this._map.fire(new Event('movestart', {originalEvent: e})); - this._startVec = vec; - } - } +export class TouchRotateHandler extends TwoTouchHandler { + _minDiameter: number; - this._lastTouchEvent = e; - if (!this._frameId) { - this._frameId = this._map._requestRenderFrame(this._onTouchFrame); - } + reset() { + super.reset(); + delete this._minDiameter; + delete this._startVector; + delete this._vector; + } - e.preventDefault(); + _start(points: [Point, Point]) { + this._startVector = this._vector = points[0].sub(points[1]); + this._minDiameter = points[0].dist(points[1]); } - _onTouchFrame() { - this._frameId = null; + _move(points: [Point, Point], pinchAround: Point) { + const lastVector = this._vector; + this._vector = points[0].sub(points[1]); - const gestureIntent = this._gestureIntent; - if (!gestureIntent) return; + if (!this._active && this._isBelowThreshold(this._vector)) return; + this._active = true; - const tr = this._map.transform; + return { + bearingDelta: getBearingDelta(this._vector, lastVector), + pinchAround + }; + } - if (!this._startScale) { - this._startScale = tr.scale; - this._startBearing = tr.bearing; - } + _isBelowThreshold(vector: Point) { + /* + * The threshold before a rotation actually happens is configured in + * pixels alongth circumference of the circle formed by the two fingers. + * This makes the threshold in degrees larger when the fingers are close + * together and smaller when the fingers are far apart. + * + * Use the smallest diameter from the whole gesture to reduce sensitivity + * when pinching in and out. + */ + + this._minDiameter = Math.min(this._minDiameter, vector.mag()); + const circumference = Math.PI * this._minDiameter; + const threshold = ROTATION_THRESHOLD / circumference * 360; + + const bearingDeltaSinceStart = getBearingDelta(vector, this._startVector); + return Math.abs(bearingDeltaSinceStart) < threshold; + } +} - const {center, bearing, scale} = this._getTouchEventData(this._lastTouchEvent); - const around = tr.pointLocation(center); - const aroundPoint = tr.locationPoint(around); +/* PITCH */ - if (gestureIntent === 'rotate') { - tr.bearing = this._startBearing + bearing; - } +function isVertical(vector) { + return Math.abs(vector.y) > Math.abs(vector.x); +} + +const ALLOWED_SINGLE_TOUCH_TIME = 100; - tr.zoom = tr.scaleZoom(this._startScale * scale); - tr.setLocationAtPoint(this._startAround, aroundPoint); +/** + * The `TouchPitchHandler` allows the user to pitch the map by dragging up and down with two fingers. + */ +export class TouchPitchHandler extends TwoTouchHandler { - this._map.fire(new Event(gestureIntent, {originalEvent: this._lastTouchEvent})); - this._map.fire(new Event('move', {originalEvent: this._lastTouchEvent})); + _valid: boolean | void; + _firstMove: number; + _lastPoints: [Point, Point]; - this._drainInertiaBuffer(); - this._inertia.push([browser.now(), scale, center]); + reset() { + super.reset(); + this._valid = undefined; + delete this._firstMove; + delete this._lastPoints; } - _onEnd(e: TouchEvent) { - DOM.removeEventListener(window.document, 'touchmove', this._onMove, {passive: false}); - DOM.removeEventListener(window.document, 'touchend', this._onEnd); - - const gestureIntent = this._gestureIntent; - const startScale = this._startScale; + _start(points: [Point, Point]) { + this._lastPoints = points; + if (isVertical(points[0].sub(points[1]))) { + // fingers are more horizontal than vertical + this._valid = false; - if (this._frameId) { - this._map._cancelRenderFrame(this._frameId); - this._frameId = null; } - delete this._gestureIntent; - delete this._startScale; - delete this._startBearing; - delete this._lastTouchEvent; + } - if (!gestureIntent) return; + _move(points: [Point, Point], center: Point, e: TouchEvent) { + const vectorA = points[0].sub(this._lastPoints[0]); + const vectorB = points[1].sub(this._lastPoints[1]); - this._map.fire(new Event(`${gestureIntent}end`, {originalEvent: e})); + this._valid = this.gestureBeginsVertically(vectorA, vectorB, e.timeStamp); + if (!this._valid) return; - this._drainInertiaBuffer(); + this._lastPoints = points; + this._active = true; + const yDeltaAverage = (vectorA.y + vectorB.y) / 2; + const degreesPerPixelMoved = -0.5; + return { + pitchDelta: yDeltaAverage * degreesPerPixelMoved + }; + } - const inertia = this._inertia, - map = this._map; + gestureBeginsVertically(vectorA: Point, vectorB: Point, timeStamp: number) { + if (this._valid !== undefined) return this._valid; - if (inertia.length < 2) { - map.snapToNorth({}, {originalEvent: e}); - return; - } + const threshold = 2; + const movedA = vectorA.mag() >= threshold; + const movedB = vectorB.mag() >= threshold; - const last = inertia[inertia.length - 1], - first = inertia[0], - lastScale = map.transform.scaleZoom(startScale * last[1]), - firstScale = map.transform.scaleZoom(startScale * first[1]), - scaleOffset = lastScale - firstScale, - scaleDuration = (last[0] - first[0]) / 1000, - p = last[2]; - - if (scaleDuration === 0 || lastScale === firstScale) { - map.snapToNorth({}, {originalEvent: e}); - return; - } + // neither finger has moved a meaningful amount, wait + if (!movedA && !movedB) return; - // calculate scale/s speed and adjust for increased initial animation speed when easing - let speed = scaleOffset * inertiaLinearity / scaleDuration; // scale/s + // One finger has moved and the other has not. + // If enough time has passed, decide it is not a pitch. + if (!movedA || !movedB) { + if (this._firstMove === undefined) { + this._firstMove = timeStamp; + } - if (Math.abs(speed) > inertiaMaxSpeed) { - if (speed > 0) { - speed = inertiaMaxSpeed; + if (timeStamp - this._firstMove < ALLOWED_SINGLE_TOUCH_TIME) { + // still waiting for a movement from the second finger + return undefined; } else { - speed = -inertiaMaxSpeed; + return false; } } - const duration = Math.abs(speed / (inertiaDeceleration * inertiaLinearity)) * 1000; - const targetScale = lastScale + speed * duration / 2000; - - map.easeTo({ - zoom: targetScale, - duration, - easing: inertiaEasing, - around: this._aroundCenter ? map.getCenter() : map.unproject(p), - noMoveStart: true - }, {originalEvent: e}); + const isSameDirection = vectorA.y > 0 === vectorB.y > 0; + return isVertical(vectorA) && isVertical(vectorB) && isSameDirection; } - _drainInertiaBuffer() { - const inertia = this._inertia, - now = browser.now(), - cutoff = 160; // msec + /** + * Returns a Boolean indicating whether the "drag to pitch" interaction is enabled. + * + * @memberof TouchPitchHandler + * @name isEnabled + * @instance + * @returns {boolean} `true` if the "drag to pitch" interaction is enabled. + */ - while (inertia.length > 2 && now - inertia[0][0] > cutoff) inertia.shift(); - } -} + /** + * Returns a Boolean indicating whether the "drag to pitch" interaction is active, i.e. currently being used. + * + * @memberof TouchPitchHandler + * @name isActive + * @instance + * @returns {boolean} `true` if the "drag to pitch" interaction is active. + */ + + /** + * Enables the "drag to pitch" interaction. + * + * @memberof TouchPitchHandler + * @name enable + * @instance + * @example + * map.touchPitch.enable(); + */ -export default TouchZoomRotateHandler; + /** + * Disables the "drag to pitch" interaction. + * + * @memberof TouchPitchHandler + * @name disable + * @instance + * @example + * map.touchPitch.disable(); + */ +} diff --git a/src/ui/handler_inertia.js b/src/ui/handler_inertia.js new file mode 100644 index 00000000000..7c3fc13dfca --- /dev/null +++ b/src/ui/handler_inertia.js @@ -0,0 +1,158 @@ +// @flow + +import browser from '../util/browser'; +import type Map from './map'; +import {bezier, clamp, extend} from '../util/util'; +import Point from '@mapbox/point-geometry'; +import type {DragPanOptions} from './handler/shim/drag_pan'; + +const defaultInertiaOptions = { + linearity: 0.3, + easing: bezier(0, 0, 0.3, 1), +}; + +const defaultPanInertiaOptions = extend({ + deceleration: 2500, + maxSpeed: 1400 +}, defaultInertiaOptions); + +const defaultZoomInertiaOptions = extend({ + deceleration: 20, + maxSpeed: 1400 +}, defaultInertiaOptions); + +const defaultBearingInertiaOptions = extend({ + deceleration: 1000, + maxSpeed: 360 +}, defaultInertiaOptions); + +const defaultPitchInertiaOptions = extend({ + deceleration: 1000, + maxSpeed: 90 +}, defaultInertiaOptions); + +export type InertiaOptions = { + linearity: number; + easing: (t: number) => number; + deceleration: number; + maxSpeed: number; +}; + +export type InputEvent = MouseEvent | TouchEvent | KeyboardEvent | WheelEvent; + +export default class HandlerInertia { + _map: Map; + _inertiaBuffer: Array<{ time: number, settings: Object }>; + + constructor(map: Map) { + this._map = map; + this.clear(); + } + + clear() { + this._inertiaBuffer = []; + } + + record(settings: any) { + this._drainInertiaBuffer(); + this._inertiaBuffer.push({time: browser.now(), settings}); + } + + _drainInertiaBuffer() { + const inertia = this._inertiaBuffer, + now = browser.now(), + cutoff = 160; //msec + + while (inertia.length > 0 && now - inertia[0].time > cutoff) + inertia.shift(); + } + + _onMoveEnd(panInertiaOptions?: DragPanOptions) { + this._drainInertiaBuffer(); + if (this._inertiaBuffer.length < 2) { + return; + } + + const deltas = { + zoom: 0, + bearing: 0, + pitch: 0, + pan: new Point(0, 0), + pinchAround: undefined, + around: undefined + }; + + for (const {settings} of this._inertiaBuffer) { + deltas.zoom += settings.zoomDelta || 0; + deltas.bearing += settings.bearingDelta || 0; + deltas.pitch += settings.pitchDelta || 0; + if (settings.panDelta) deltas.pan._add(settings.panDelta); + if (settings.around) deltas.around = settings.around; + if (settings.pinchAround) deltas.pinchAround = settings.pinchAround; + } + + const lastEntry = this._inertiaBuffer[this._inertiaBuffer.length - 1]; + const duration = (lastEntry.time - this._inertiaBuffer[0].time); + + const easeOptions = {}; + + if (deltas.pan.mag()) { + const result = calculateEasing(deltas.pan.mag(), duration, extend({}, defaultPanInertiaOptions, panInertiaOptions || {})); + easeOptions.offset = deltas.pan.mult(result.amount / deltas.pan.mag()); + easeOptions.center = this._map.transform.center; + extendDuration(easeOptions, result); + } + + if (deltas.zoom) { + const result = calculateEasing(deltas.zoom, duration, defaultZoomInertiaOptions); + easeOptions.zoom = this._map.transform.zoom + result.amount; + extendDuration(easeOptions, result); + } + + if (deltas.bearing) { + const result = calculateEasing(deltas.bearing, duration, defaultBearingInertiaOptions); + easeOptions.bearing = this._map.transform.bearing + clamp(result.amount, -179, 179); + extendDuration(easeOptions, result); + } + + if (deltas.pitch) { + const result = calculateEasing(deltas.pitch, duration, defaultPitchInertiaOptions); + easeOptions.pitch = this._map.transform.pitch + result.amount; + extendDuration(easeOptions, result); + } + + if (easeOptions.zoom || easeOptions.bearing) { + const last = deltas.pinchAround === undefined ? deltas.around : deltas.pinchAround; + easeOptions.around = last ? this._map.unproject(last) : this._map.getCenter(); + } + + this.clear(); + return extend(easeOptions, { + noMoveStart: true + }); + + } +} + +// Unfortunately zoom, bearing, etc can't have different durations and easings so +// we need to choose one. We use the longest duration and it's corresponding easing. +function extendDuration(easeOptions, result) { + if (!easeOptions.duration || easeOptions.duration < result.duration) { + easeOptions.duration = result.duration; + easeOptions.easing = result.easing; + } +} + +function calculateEasing(amount, inertiaDuration: number, inertiaOptions) { + const {maxSpeed, linearity, deceleration} = inertiaOptions; + const speed = clamp( + amount * linearity / (inertiaDuration / 1000), + -maxSpeed, + maxSpeed); + const duration = Math.abs(speed) / (deceleration * linearity); + return { + easing: inertiaOptions.easing, + duration: duration * 1000, + amount: speed * (duration / 2) + }; +} diff --git a/src/ui/handler_manager.js b/src/ui/handler_manager.js new file mode 100644 index 00000000000..0a617bd41aa --- /dev/null +++ b/src/ui/handler_manager.js @@ -0,0 +1,486 @@ +// @flow + +import {Event} from '../util/evented'; +import DOM from '../util/dom'; +import type Map from './map'; +import HandlerInertia from './handler_inertia'; +import {MapEventHandler, BlockableMapEventHandler} from './handler/map_event'; +import BoxZoomHandler from './handler/box_zoom'; +import TapZoomHandler from './handler/tap_zoom'; +import {MousePanHandler, MouseRotateHandler, MousePitchHandler} from './handler/mouse'; +import TouchPanHandler from './handler/touch_pan'; +import {TouchZoomHandler, TouchRotateHandler, TouchPitchHandler} from './handler/touch_zoom_rotate'; +import KeyboardHandler from './handler/keyboard'; +import ScrollZoomHandler from './handler/scroll_zoom'; +import DoubleClickZoomHandler from './handler/shim/dblclick_zoom'; +import ClickZoomHandler from './handler/click_zoom'; +import TapDragZoomHandler from './handler/tap_drag_zoom'; +import DragPanHandler from './handler/shim/drag_pan'; +import DragRotateHandler from './handler/shim/drag_rotate'; +import TouchZoomRotateHandler from './handler/shim/touch_zoom_rotate'; +import {extend} from '../util/util'; +import window from '../util/window'; +import Point from '@mapbox/point-geometry'; +import assert from 'assert'; + +export type InputEvent = MouseEvent | TouchEvent | KeyboardEvent | WheelEvent; + +const isMoving = p => p.zoom || p.drag || p.pitch || p.rotate; + +class RenderFrameEvent extends Event { + type: 'renderFrame'; + timeStamp: number; +} + +// Handlers interpret dom events and return camera changes that should be +// applied to the map (`HandlerResult`s). The camera changes are all deltas. +// The handler itself should have no knowledge of the map's current state. +// This makes it easier to merge multiple results and keeps handlers simpler. +// For example, if there is a mousedown and mousemove, the mousePan handler +// would return a `panDelta` on the mousemove. +export interface Handler { + enable(): void; + disable(): void; + isEnabled(): boolean; + isActive(): boolean; + + // `reset` can be called by the manager at any time and must reset everything to it's original state + reset(): void; + + // Handlers can optionally implement these methods. + // They are called with dom events whenever those dom evens are received. + +touchstart?: (e: TouchEvent, points: Array) => HandlerResult | void; + +touchmove?: (e: TouchEvent, points: Array) => HandlerResult | void; + +touchend?: (e: TouchEvent, points: Array) => HandlerResult | void; + +touchcancel?: (e: TouchEvent, points: Array) => HandlerResult | void; + +mousedown?: (e: MouseEvent, point: Point) => HandlerResult | void; + +mousemove?: (e: MouseEvent, point: Point) => HandlerResult | void; + +mouseup?: (e: MouseEvent, point: Point) => HandlerResult | void; + +dblclick?: (e: MouseEvent, point: Point) => HandlerResult | void; + +wheel?: (e: WheelEvent, point: Point) => HandlerResult | void; + +keydown?: (e: KeyboardEvent) => HandlerResult | void; + +keyup?: (e: KeyboardEvent) => HandlerResult | void; + + // `renderFrame` is the only non-dom event. It is called during render + // frames and can be used to smooth camera changes (see scroll handler). + +renderFrame?: () => HandlerResult | void; +} + +// All handler methods that are called with events can optionally return a `HandlerResult`. +export type HandlerResult = {| + panDelta?: Point, + zoomDelta?: number, + bearingDelta?: number, + pitchDelta?: number, + // the point to not move when changing the camera + around?: Point | null, + // same as above, except for pinch actions, which are given higher priority + pinchAround?: Point | null, + // A method that can fire a one-off easing by directly changing the map's camera. + cameraAnimation?: (map: Map) => any; + + // The last three properties are needed by only one handler: scrollzoom. + // The DOM event to be used as the `originalEvent` on any camera change events. + originalEvent?: any, + // Makes the manager trigger a frame, allowing the handler to return multiple results over time (see scrollzoom). + needsRenderFrame?: boolean, + // The camera changes won't get recorded for inertial zooming. + noInertia?: boolean +|}; + +function hasChange(result: HandlerResult) { + return (result.panDelta && result.panDelta.mag()) || result.zoomDelta || result.bearingDelta || result.pitchDelta; +} + +class HandlerManager { + _map: Map; + _el: HTMLElement; + _handlers: Array<{ handlerName: string, handler: Handler, allowed: any }>; + _eventsInProgress: Object; + _frameId: number; + _inertia: HandlerInertia; + _bearingSnap: number; + _handlersById: { [string]: Handler }; + _updatingCamera: boolean; + _changes: Array<[HandlerResult, Object, any]>; + _previousActiveHandlers: { [string]: Handler }; + _bearingChanged: boolean; + + constructor(map: Map, options: { interactive: boolean, pitchWithRotate: boolean, clickTolerance: number, bearingSnap: number}) { + this._map = map; + this._el = this._map.getCanvasContainer(); + this._handlers = []; + this._handlersById = {}; + this._changes = []; + + this._inertia = new HandlerInertia(map); + this._bearingSnap = options.bearingSnap; + this._previousActiveHandlers = {}; + + // Track whether map is currently moving, to compute start/move/end events + this._eventsInProgress = {}; + + this._addDefaultHandlers(options); + + // Bind touchstart and touchmove with passive: false because, even though + // they only fire a map events and therefore could theoretically be + // passive, binding with passive: true causes iOS not to respect + // e.preventDefault() in _other_ handlers, even if they are non-passive + // (see https://bugs.webkit.org/show_bug.cgi?id=184251) + this._addListener(this._el, 'touchstart', {passive: false}); + this._addListener(this._el, 'touchmove', {passive: false}); + this._addListener(this._el, 'touchend'); + this._addListener(this._el, 'touchcancel'); + + this._addListener(this._el, 'mousedown'); + this._addListener(this._el, 'mousemove'); + this._addListener(this._el, 'mouseup'); + + // Bind window-level event listeners for move and up/end events. In the absence of + // the pointer capture API, which is not supported by all necessary platforms, + // window-level event listeners give us the best shot at capturing events that + // fall outside the map canvas element. Use `{capture: true}` for the move event + // to prevent map move events from being fired during a drag. + this._addListener(window.document, 'mousemove', {capture: true}, 'windowMousemove'); + this._addListener(window.document, 'mouseup', undefined, 'windowMouseup'); + + this._addListener(this._el, 'mouseover'); + this._addListener(this._el, 'mouseout'); + this._addListener(this._el, 'dblclick'); + this._addListener(this._el, 'click'); + + this._addListener(this._el, 'keydown', {capture: false}); + this._addListener(this._el, 'keyup'); + + this._addListener(this._el, 'wheel', {passive: false}); + this._addListener(this._el, 'contextmenu'); + + DOM.addEventListener(window, 'blur', () => this.stop()); + } + + _addListener(element: Element, eventType: string, options: Object, name_?: string) { + const name = name_ || eventType; + DOM.addEventListener(element, eventType, e => this._processInputEvent(e, name), options); + } + + _addDefaultHandlers(options: { interactive: boolean, pitchWithRotate: boolean, clickTolerance: number }) { + const map = this._map; + const el = map.getCanvasContainer(); + this._add('mapEvent', new MapEventHandler(map, options)); + + const boxZoom = map.boxZoom = new BoxZoomHandler(map, options); + this._add('boxZoom', boxZoom); + + const tapZoom = new TapZoomHandler(); + const clickZoom = new ClickZoomHandler(); + map.doubleClickZoom = new DoubleClickZoomHandler(clickZoom, tapZoom); + this._add('tapZoom', tapZoom); + this._add('clickZoom', clickZoom); + + const tapDragZoom = new TapDragZoomHandler(); + this._add('tapDragZoom', tapDragZoom); + + const touchPitch = map.touchPitch = new TouchPitchHandler(); + this._add('touchPitch', touchPitch); + + const mouseRotate = new MouseRotateHandler(options); + const mousePitch = new MousePitchHandler(options); + map.dragRotate = new DragRotateHandler(options, mouseRotate, mousePitch); + this._add('mouseRotate', mouseRotate, ['mousePitch']); + this._add('mousePitch', mousePitch, ['mouseRotate']); + + const mousePan = new MousePanHandler(options); + const touchPan = new TouchPanHandler(options); + map.dragPan = new DragPanHandler(el, mousePan, touchPan); + this._add('mousePan', mousePan); + this._add('touchPan', touchPan, ['touchZoom', 'touchRotate']); + + const touchRotate = new TouchRotateHandler(); + const touchZoom = new TouchZoomHandler(); + map.touchZoomRotate = new TouchZoomRotateHandler(el, touchZoom, touchRotate, tapDragZoom); + this._add('touchRotate', touchRotate, ['touchPan', 'touchZoom']); + this._add('touchZoom', touchZoom, ['touchPan', 'touchRotate']); + + const scrollZoom = map.scrollZoom = new ScrollZoomHandler(map, this); + this._add('scrollZoom', scrollZoom, ['mousePan']); + + const keyboard = map.keyboard = new KeyboardHandler(); + this._add('keyboard', keyboard); + + this._add('blockableMapEvent', new BlockableMapEventHandler(map)); + + for (const name of ['boxZoom', 'doubleClickZoom', 'tapDragZoom', 'touchPitch', 'dragRotate', 'dragPan', 'touchZoomRotate', 'scrollZoom', 'keyboard']) { + if (options.interactive && (options: any)[name]) { + (map: any)[name].enable((options: any)[name]); + } + } + } + + _add(handlerName: string, handler: Handler, allowed?: Array) { + this._handlers.push({handlerName, handler, allowed}); + this._handlersById[handlerName] = handler; + } + + stop() { + // do nothing if this method was triggered by a gesture update + if (this._updatingCamera) return; + + for (const {handler} of this._handlers) { + handler.reset(); + } + this._inertia.clear(); + this._fireEvents({}, {}); + this._changes = []; + } + + isActive() { + for (const {handler} of this._handlers) { + if (handler.isActive()) return true; + } + return false; + } + + isZooming() { + return !!this._eventsInProgress.zoom || this._map.scrollZoom.isZooming(); + } + isRotating() { + return !!this._eventsInProgress.rotate; + } + + _blockedByActive(activeHandlers: { [string]: Handler }, allowed: Array, myName: string) { + for (const name in activeHandlers) { + if (name === myName) continue; + if (!allowed || allowed.indexOf(name) < 0) { + return true; + } + } + return false; + } + + _processInputEvent(e: InputEvent | RenderFrameEvent, eventName?: string) { + + this._updatingCamera = true; + assert(e.timeStamp !== undefined); + + const inputEvent = e.type === 'renderFrame' ? undefined : ((e: any): InputEvent); + + /* + * We don't call e.preventDefault() for any events by default. + * Handlers are responsible for calling it where necessary. + */ + + const mergedHandlerResult: HandlerResult = {needsRenderFrame: false}; + const eventsInProgress = {}; + const activeHandlers = {}; + + const points = e ? (e.targetTouches ? + DOM.touchPos(this._el, ((e: any): TouchEvent).targetTouches) : + DOM.mousePos(this._el, ((e: any): MouseEvent))) : null; + + for (const {handlerName, handler, allowed} of this._handlers) { + if (!handler.isEnabled()) continue; + + let data: HandlerResult | void; + if (this._blockedByActive(activeHandlers, allowed, handlerName)) { + handler.reset(); + + } else { + if ((handler: any)[eventName || e.type]) { + data = (handler: any)[eventName || e.type](e, points); + this.mergeHandlerResult(mergedHandlerResult, eventsInProgress, data, handlerName, inputEvent); + if (data && data.needsRenderFrame) { + this._triggerRenderFrame(); + } + } + } + + if (data || handler.isActive()) { + activeHandlers[handlerName] = handler; + } + } + + const deactivatedHandlers = {}; + for (const name in this._previousActiveHandlers) { + if (!activeHandlers[name]) { + deactivatedHandlers[name] = inputEvent; + } + } + this._previousActiveHandlers = activeHandlers; + + if (Object.keys(deactivatedHandlers).length || hasChange(mergedHandlerResult)) { + this._changes.push([mergedHandlerResult, eventsInProgress, deactivatedHandlers]); + this._triggerRenderFrame(); + } + + if (Object.keys(activeHandlers).length || hasChange(mergedHandlerResult)) { + this._map._stop(true); + } + + this._updatingCamera = false; + + const {cameraAnimation} = mergedHandlerResult; + if (cameraAnimation) { + this._inertia.clear(); + this._fireEvents({}, {}); + this._changes = []; + cameraAnimation(this._map); + } + } + + mergeHandlerResult(mergedHandlerResult: HandlerResult, eventsInProgress: Object, handlerResult: HandlerResult, name: string, e?: InputEvent) { + if (!handlerResult) return; + + extend(mergedHandlerResult, handlerResult); + + const eventData = {handlerName: name, originalEvent: handlerResult.originalEvent || e}; + + // track which handler changed which camera property + if (handlerResult.zoomDelta !== undefined) { + eventsInProgress.zoom = eventData; + } + if (handlerResult.panDelta !== undefined) { + eventsInProgress.drag = eventData; + } + if (handlerResult.pitchDelta !== undefined) { + eventsInProgress.pitch = eventData; + } + if (handlerResult.bearingDelta !== undefined) { + eventsInProgress.rotate = eventData; + } + + } + + _applyChanges() { + const combined = {}; + const combinedEventsInProgress = {}; + const combinedDeactivatedHandlers = {}; + + for (const [change, eventsInProgress, deactivatedHandlers] of this._changes) { + + if (change.panDelta) combined.panDelta = (combined.panDelta || new Point(0, 0))._add(change.panDelta); + if (change.zoomDelta) combined.zoomDelta = (combined.zoomDelta || 0) + change.zoomDelta; + if (change.bearingDelta) combined.bearingDelta = (combined.bearingDelta || 0) + change.bearingDelta; + if (change.pitchDelta) combined.pitchDelta = (combined.pitchDelta || 0) + change.pitchDelta; + if (change.around !== undefined) combined.around = change.around; + if (change.pinchAround !== undefined) combined.pinchAround = change.pinchAround; + if (change.noInertia) combined.noInertia = change.noInertia; + + extend(combinedEventsInProgress, eventsInProgress); + extend(combinedDeactivatedHandlers, deactivatedHandlers); + } + + this._updateMapTransform(combined, combinedEventsInProgress, combinedDeactivatedHandlers); + this._changes = []; + } + + _updateMapTransform(combinedResult: any, combinedEventsInProgress: Object, deactivatedHandlers: Object) { + + const map = this._map; + const tr = map.transform; + + if (!hasChange(combinedResult)) { + return this._fireEvents(combinedEventsInProgress, deactivatedHandlers); + } + + let {panDelta, zoomDelta, bearingDelta, pitchDelta, around, pinchAround} = combinedResult; + + if (pinchAround !== undefined) { + around = pinchAround; + } + + // stop any ongoing camera animations (easeTo, flyTo) + map._stop(true); + + around = around || map.transform.centerPoint; + const loc = tr.pointLocation(panDelta ? around.sub(panDelta) : around); + if (bearingDelta) tr.bearing += bearingDelta; + if (pitchDelta) tr.pitch += pitchDelta; + if (zoomDelta) tr.zoom += zoomDelta; + tr.setLocationAtPoint(loc, around); + + this._map._update(); + if (!combinedResult.noInertia) this._inertia.record(combinedResult); + this._fireEvents(combinedEventsInProgress, deactivatedHandlers); + + } + + _fireEvents(newEventsInProgress: { [string]: Object }, deactivatedHandlers: Object) { + + const wasMoving = isMoving(this._eventsInProgress); + const nowMoving = isMoving(newEventsInProgress); + + if (!wasMoving && nowMoving) { + this._fireEvent('movestart', nowMoving.originalEvent); + } + + for (const eventName in newEventsInProgress) { + const {originalEvent} = newEventsInProgress[eventName]; + const isStart = !this._eventsInProgress[eventName]; + this._eventsInProgress[eventName] = newEventsInProgress[eventName]; + if (isStart) { + this._fireEvent(`${eventName}start`, originalEvent); + } + } + + if (newEventsInProgress.rotate) this._bearingChanged = true; + + if (nowMoving) { + this._fireEvent('move', nowMoving.originalEvent); + } + + for (const eventName in newEventsInProgress) { + const {originalEvent} = newEventsInProgress[eventName]; + this._fireEvent(eventName, originalEvent); + } + + let originalEndEvent; + for (const eventName in this._eventsInProgress) { + const {handlerName, originalEvent} = this._eventsInProgress[eventName]; + if (!this._handlersById[handlerName].isActive()) { + delete this._eventsInProgress[eventName]; + originalEndEvent = deactivatedHandlers[handlerName] || originalEvent; + this._fireEvent(`${eventName}end`, originalEndEvent); + } + } + + const stillMoving = isMoving(this._eventsInProgress); + if ((wasMoving || nowMoving) && !stillMoving) { + this._updatingCamera = true; + const inertialEase = this._inertia._onMoveEnd(this._map.dragPan._inertiaOptions); + + const shouldSnapToNorth = bearing => bearing !== 0 && -this._bearingSnap < bearing && bearing < this._bearingSnap; + + if (inertialEase) { + if (shouldSnapToNorth(inertialEase.bearing || this._map.getBearing())) { + inertialEase.bearing = 0; + } + this._map.easeTo(inertialEase, {originalEvent: originalEndEvent}); + } else { + this._map.fire(new Event('moveend', {originalEvent: originalEndEvent})); + if (shouldSnapToNorth(this._map.getBearing())) { + this._map.resetNorth(); + } + } + this._bearingChanged = false; + this._updatingCamera = false; + } + + } + + _fireEvent(type: string, e: *) { + this._map.fire(new Event(type, e ? {originalEvent: e} : {})); + } + + _triggerRenderFrame() { + if (this._frameId === undefined) { + this._frameId = this._map._requestRenderFrame(timeStamp => { + delete this._frameId; + this._processInputEvent(new RenderFrameEvent('renderFrame', {timeStamp})); + this._applyChanges(); + }); + } + } + +} + +export default HandlerManager; diff --git a/src/ui/map.js b/src/ui/map.js index 771dea2395d..cac2ff4b32b 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -13,7 +13,7 @@ import EvaluationParameters from '../style/evaluation_parameters'; import Painter from '../render/painter'; import Transform from '../geo/transform'; import Hash from './hash'; -import bindHandlers from './bind_handlers'; +import HandlerManager from './handler_manager'; import Camera from './camera'; import LngLat from '../geo/lng_lat'; import LngLatBounds from '../geo/lng_lat_bounds'; @@ -41,11 +41,12 @@ import type {StyleImageInterface, StyleImageMetadata} from '../style/style_image import type ScrollZoomHandler from './handler/scroll_zoom'; import type BoxZoomHandler from './handler/box_zoom'; -import type DragRotateHandler from './handler/drag_rotate'; -import type DragPanHandler, {DragPanOptions} from './handler/drag_pan'; +import type {TouchPitchHandler} from './handler/touch_zoom_rotate'; +import type DragRotateHandler from './handler/shim/drag_rotate'; +import type DragPanHandler, {DragPanOptions} from './handler/shim/drag_pan'; import type KeyboardHandler from './handler/keyboard'; -import type DoubleClickZoomHandler from './handler/dblclick_zoom'; -import type TouchZoomRotateHandler from './handler/touch_zoom_rotate'; +import type DoubleClickZoomHandler from './handler/shim/dblclick_zoom'; +import type TouchZoomRotateHandler from './handler/shim/touch_zoom_rotate'; import defaultLocale from './default_locale'; import type {TaskID} from '../util/task_queue'; import type {Cancelable} from '../types/cancelable'; @@ -91,6 +92,7 @@ type MapOptions = { keyboard?: boolean, doubleClickZoom?: boolean, touchZoomRotate?: boolean, + touchPitch?: boolean, trackResize?: boolean, center?: LngLatLike, zoom?: number, @@ -130,9 +132,11 @@ const defaultOptions = { keyboard: true, doubleClickZoom: true, touchZoomRotate: true, + touchPitch: true, bearingSnap: 7, clickTolerance: 3, + pitchWithRotate: true, hash: false, attributionControl: true, @@ -215,6 +219,7 @@ const defaultOptions = { * @param {boolean} [options.keyboard=true] If `true`, keyboard shortcuts are enabled (see {@link KeyboardHandler}). * @param {boolean} [options.doubleClickZoom=true] If `true`, the "double click to zoom" interaction is enabled (see {@link DoubleClickZoomHandler}). * @param {boolean|Object} [options.touchZoomRotate=true] If `true`, the "pinch to rotate and zoom" interaction is enabled. An `Object` value is passed as options to {@link TouchZoomRotateHandler#enable}. + * @param {boolean|Object} [options.touchPitch=true] If `true`, the "drag to pitch" interaction is enabled. An `Object` value is passed as options to {@link TouchPitchHandler#enable}. * @param {boolean} [options.trackResize=true] If `true`, the map will automatically resize when the browser window resizes. * @param {LngLatLike} [options.center=[0, 0]] The inital geographical centerpoint of the map. If `center` is not specified in the constructor options, Mapbox GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `[0, 0]` Note: Mapbox GL uses longitude, latitude coordinate order (as opposed to latitude, longitude) to match GeoJSON. * @param {number} [options.zoom=0] The initial zoom level of the map. If `zoom` is not specified in the constructor options, Mapbox GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `0`. @@ -262,6 +267,7 @@ const defaultOptions = { class Map extends Camera { style: Style; painter: Painter; + handlers: HandlerManager; _container: HTMLElement; _missingCSSCanary: HTMLElement; @@ -301,6 +307,7 @@ class Map extends Camera { _localIdeographFontFamily: string; _requestManager: RequestManager; _locale: Object; + _removed: boolean; /** * The map's {@link ScrollZoomHandler}, which implements zooming in and out with a scroll wheel or trackpad. @@ -345,6 +352,12 @@ class Map extends Camera { */ touchZoomRotate: TouchZoomRotateHandler; + /** + * The map's {@link TouchPitchHandler}, which allows the user to pitch the map with touch gestures. + * Find more details and examples using `touchPitch` in the {@link TouchPitchHandler} section. + */ + touchPitch: TouchPitchHandler; + constructor(options: MapOptions) { PerformanceUtils.mark(PerformanceMarkers.create); @@ -425,7 +438,7 @@ class Map extends Camera { window.addEventListener('resize', this._onWindowResize, false); } - bindHandlers(this, options); + this.handlers = new HandlerManager(this, options); const hashName = (typeof options.hash === 'string' && options.hash) || undefined; this._hash = options.hash && (new Hash(hashName)).addTo(this); @@ -564,10 +577,17 @@ class Map extends Camera { this.transform.resize(width, height); this.painter.resize(width, height); - this.fire(new Event('movestart', eventData)) - .fire(new Event('move', eventData)) - .fire(new Event('resize', eventData)) - .fire(new Event('moveend', eventData)); + const fireMoving = !this._moving; + if (fireMoving) { + this.stop(); + this.fire(new Event('movestart', eventData)) + .fire(new Event('move', eventData)); + } + + this.fire(new Event('resize', eventData)); + + if (fireMoving) this.fire(new Event('moveend', eventData)); + return this; } @@ -833,10 +853,7 @@ class Map extends Camera { * var isMoving = map.isMoving(); */ isMoving(): boolean { - return this._moving || - this.dragPan.isActive() || - this.dragRotate.isActive() || - this.scrollZoom.isActive(); + return this._moving || this.handlers.isActive(); } /** @@ -846,8 +863,7 @@ class Map extends Camera { * var isZooming = map.isZooming(); */ isZooming(): boolean { - return this._zooming || - this.scrollZoom.isZooming(); + return this._zooming || this.handlers.isZooming(); } /** @@ -857,8 +873,7 @@ class Map extends Camera { * map.isRotating(); */ isRotating(): boolean { - return this._rotating || - this.dragRotate.isActive(); + return this._rotating || this.handlers.isRotating(); } _createDelegatedListener(type: MapEvent, layerId: any, listener: any) { @@ -2146,10 +2161,12 @@ class Map extends Camera { * - The map has is moving (or just finished moving) * - A transition is in progress * + * @param {number} paintStartTimeStamp The time when the animation frame began executing. + * * @returns {Map} this * @private */ - _render() { + _render(paintStartTimeStamp: number) { let gpuTimer, frameStartTime = 0; const extTimerQuery = this.painter.context.extTimerQuery; if (this.listens('gpu-timing-frame')) { @@ -2162,7 +2179,9 @@ class Map extends Camera { this.painter.context.setDirty(); this.painter.setBaseState(); - this._renderTaskQueue.run(); + this._renderTaskQueue.run(paintStartTimeStamp); + // A task queue callback may have fired a user event which may have removed the map + if (this._removed) return; let crossFading = false; @@ -2314,6 +2333,8 @@ class Map extends Camera { this._container.classList.remove('mapboxgl-map'); PerformanceUtils.clearMetrics(); + + this._removed = true; this.fire(new Event('remove')); } @@ -2324,10 +2345,10 @@ class Map extends Camera { */ triggerRepaint() { if (this.style && !this._frame) { - this._frame = browser.frame((paintStartTimestamp: number) => { - PerformanceUtils.frame(paintStartTimestamp); + this._frame = browser.frame((paintStartTimeStamp: number) => { + PerformanceUtils.frame(paintStartTimeStamp); this._frame = null; - this._render(); + this._render(paintStartTimeStamp); }); } } diff --git a/src/util/debug.js b/src/util/debug.js index e4e1d9007fe..0255172726f 100644 --- a/src/util/debug.js +++ b/src/util/debug.js @@ -1,5 +1,6 @@ // @flow import {extend} from './util'; +import window from './window'; /** * This is a private namespace for utility functions that will get automatically stripped @@ -8,5 +9,18 @@ import {extend} from './util'; export const Debug = { extend(dest: Object, ...sources: Array): Object { return extend(dest, ...sources); + }, + + run(fn: () => any) { + fn(); + }, + + logToElement(message: string, overwrite: boolean = false, id: string = "log") { + const el = window.document.getElementById(id); + if (el) { + if (overwrite) el.innerHTML = ''; + el.innerHTML += `
${message}`; + } + } }; diff --git a/src/util/dom.js b/src/util/dom.js index 9d9915d276f..fd5d3e91498 100644 --- a/src/util/dom.js +++ b/src/util/dom.js @@ -105,17 +105,15 @@ DOM.suppressClick = function() { DOM.mousePos = function (el: HTMLElement, e: MouseEvent | window.TouchEvent | Touch) { const rect = el.getBoundingClientRect(); - const t = window.TouchEvent && (e instanceof window.TouchEvent) ? e.touches[0] : e; return new Point( - t.clientX - rect.left - el.clientLeft, - t.clientY - rect.top - el.clientTop + e.clientX - rect.left - el.clientLeft, + e.clientY - rect.top - el.clientTop ); }; -DOM.touchPos = function (el: HTMLElement, e: TouchEvent) { +DOM.touchPos = function (el: HTMLElement, touches: TouchList) { const rect = el.getBoundingClientRect(), points = []; - const touches = (e.type === 'touchend') ? e.changedTouches : e.touches; for (let i = 0; i < touches.length; i++) { points.push(new Point( touches[i].clientX - rect.left - el.clientLeft, diff --git a/src/util/task_queue.js b/src/util/task_queue.js index a45a3deb6c1..25b28d250f9 100644 --- a/src/util/task_queue.js +++ b/src/util/task_queue.js @@ -3,7 +3,7 @@ import assert from 'assert'; export type TaskID = number; // can't mark opaque due to https://github.com/flowtype/flow-remove-types/pull/61 type Task = { - callback: () => void; + callback: (timeStamp: number) => void; id: TaskID; cancelled: boolean; }; @@ -21,7 +21,7 @@ class TaskQueue { this._currentlyRunning = false; } - add(callback: () => void): TaskID { + add(callback: (timeStamp: number) => void): TaskID { const id = ++this._id; const queue = this._queue; queue.push({callback, id, cancelled: false}); @@ -39,7 +39,7 @@ class TaskQueue { } } - run() { + run(timeStamp: number = 0) { assert(!this._currentlyRunning); const queue = this._currentlyRunning = this._queue; @@ -49,7 +49,7 @@ class TaskQueue { for (const task of queue) { if (task.cancelled) continue; - task.callback(); + task.callback(timeStamp); if (this._cleared) break; } diff --git a/test/build/dev.test.js b/test/build/dev.test.js index 6f5f6876522..6001146d504 100644 --- a/test/build/dev.test.js +++ b/test/build/dev.test.js @@ -3,5 +3,6 @@ import fs from 'fs'; test('dev build contains asserts', (t) => { t.assert(fs.readFileSync('dist/mapbox-gl-dev.js', 'utf8').indexOf('canary assert') !== -1); + t.assert(fs.readFileSync('dist/mapbox-gl-dev.js', 'utf8').indexOf('canary debug run') !== -1); t.end(); }); diff --git a/test/build/min.test.js b/test/build/min.test.js index 75c5d64511e..5cdcda50406 100644 --- a/test/build/min.test.js +++ b/test/build/min.test.js @@ -9,6 +9,7 @@ const minBundle = fs.readFileSync('dist/mapbox-gl.js', 'utf8'); test('production build removes asserts', (t) => { t.assert(minBundle.indexOf('canary assert') === -1); + t.assert(minBundle.indexOf('canary debug run') === -1); t.end(); }); diff --git a/test/unit/ui/camera.test.js b/test/unit/ui/camera.test.js index ba23f8b9c9f..b3014f92952 100644 --- a/test/unit/ui/camera.test.js +++ b/test/unit/ui/camera.test.js @@ -392,6 +392,9 @@ test('camera', (t) => { const camera = createCamera(); let started; + // fire once in advance to satisfy assertions that moveend only comes after movestart + camera.fire('movestart'); + camera .on('movestart', () => { started = true; }) .on('moveend', () => { @@ -457,6 +460,9 @@ test('camera', (t) => { const camera = createCamera(); let started; + // fire once in advance to satisfy assertions that moveend only comes after movestart + camera.fire('movestart'); + camera .on('movestart', () => { started = true; }) .on('moveend', () => { diff --git a/test/unit/ui/handler/dblclick_zoom.test.js b/test/unit/ui/handler/dblclick_zoom.test.js index f5014c0f811..a3adb4a63f4 100644 --- a/test/unit/ui/handler/dblclick_zoom.test.js +++ b/test/unit/ui/handler/dblclick_zoom.test.js @@ -12,10 +12,10 @@ function createMap(t) { function simulateDoubleTap(map, delay = 100) { const canvas = map.getCanvas(); return new Promise(resolve => { - simulate.touchstart(canvas); + simulate.touchstart(canvas, {targetTouches: [{clientX: 0, clientY: 0}]}); simulate.touchend(canvas); setTimeout(() => { - simulate.touchstart(canvas); + simulate.touchstart(canvas, {targetTouches: [{clientX: 0, clientY: 0}]}); simulate.touchend(canvas); map._renderTaskQueue.run(); resolve(); @@ -27,7 +27,7 @@ test('DoubleClickZoomHandler zooms on dblclick event', (t) => { const map = createMap(t); const zoom = t.spy(); - map.on('zoom', zoom); + map.on('zoomstart', zoom); simulate.dblclick(map.getCanvas()); map._renderTaskQueue.run(); @@ -44,7 +44,7 @@ test('DoubleClickZoomHandler does not zoom if preventDefault is called on the db map.on('dblclick', e => e.preventDefault()); const zoom = t.spy(); - map.on('zoom', zoom); + map.on('zoomstart', zoom); simulate.dblclick(map.getCanvas()); map._renderTaskQueue.run(); @@ -59,7 +59,7 @@ test('DoubleClickZoomHandler zooms on double tap if touchstart events are < 300m const map = createMap(t); const zoom = t.spy(); - map.on('zoom', zoom); + map.on('zoomstart', zoom); simulateDoubleTap(map, 100).then(() => { t.ok(zoom.called); @@ -70,13 +70,13 @@ test('DoubleClickZoomHandler zooms on double tap if touchstart events are < 300m }); -test('DoubleClickZoomHandler does not zoom on double tap if touchstart events are > 300ms apart', (t) => { +test('DoubleClickZoomHandler does not zoom on double tap if touchstart events are > 500ms apart', (t) => { const map = createMap(t); const zoom = t.spy(); map.on('zoom', zoom); - simulateDoubleTap(map, 300).then(() => { + simulateDoubleTap(map, 500).then(() => { t.equal(zoom.callCount, 0); map.remove(); @@ -119,15 +119,16 @@ test('DoubleClickZoomHandler zooms on the second touchend event of a double tap' const map = createMap(t); const zoom = t.spy(); - map.on('zoom', zoom); + map.on('zoomstart', zoom); const canvas = map.getCanvas(); - const touchOptions = {touches: [{clientX: 0.5, clientY: 0.5}]}; + const touchOptions = {targetTouches: [{clientX: 0.5, clientY: 0.5}]}; simulate.touchstart(canvas, touchOptions); simulate.touchend(canvas); simulate.touchstart(canvas, touchOptions); map._renderTaskQueue.run(); + map._renderTaskQueue.run(); t.notOk(zoom.called, 'should not trigger zoom before second touchend'); simulate.touchcancel(canvas); @@ -145,7 +146,6 @@ test('DoubleClickZoomHandler zooms on the second touchend event of a double tap' map._renderTaskQueue.run(); t.ok(zoom.called, 'should trigger zoom after second touchend'); - t.deepEquals(zoom.getCall(0).args[0].point, {x: 0.5, y: 0.5}, 'should zoom to correct point'); t.end(); }); diff --git a/test/unit/ui/handler/drag_pan.test.js b/test/unit/ui/handler/drag_pan.test.js index e866e5f1199..971c981186c 100644 --- a/test/unit/ui/handler/drag_pan.test.js +++ b/test/unit/ui/handler/drag_pan.test.js @@ -3,7 +3,6 @@ import window from '../../../../src/util/window'; import Map from '../../../../src/ui/map'; import DOM from '../../../../src/util/dom'; import simulate from '../../../util/simulate_interaction'; -import DragPanHandler from '../../../../src/ui/handler/drag_pan'; function createMap(t, clickTolerance, dragPan) { t.stub(Map.prototype, '_detectMissingCSS'); @@ -91,46 +90,13 @@ test('DragPanHandler fires dragstart, drag, and dragend events at appropriate ti map.on('drag', drag); map.on('dragend', dragend); - simulate.touchstart(map.getCanvas()); + simulate.touchstart(map.getCanvas(), {targetTouches: [{clientX: 0, clientY: 0}]}); map._renderTaskQueue.run(); t.equal(dragstart.callCount, 0); t.equal(drag.callCount, 0); t.equal(dragend.callCount, 0); - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}]}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 1); - t.equal(dragend.callCount, 0); - - simulate.touchend(map.getCanvas()); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 1); - t.equal(dragend.callCount, 1); - - map.remove(); - t.end(); -}); - -test('DragPanHandler captures touchmove events during a mouse-triggered drag (receives them even if they occur outside the map)', (t) => { - const map = createMap(t); - - const dragstart = t.spy(); - const drag = t.spy(); - const dragend = t.spy(); - - map.on('dragstart', dragstart); - map.on('drag', drag); - map.on('dragend', dragend); - - simulate.touchstart(map.getCanvas()); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.touchmove(window.document.body, {touches: [{clientX: 10, clientY: 10}]}); + simulate.touchmove(map.getCanvas(), {targetTouches: [{clientX: 10, clientY: 10}]}); map._renderTaskQueue.run(); t.equal(dragstart.callCount, 1); t.equal(drag.callCount, 1); @@ -192,10 +158,10 @@ test('DragPanHandler ends a touch-triggered drag if the window blurs', (t) => { const dragend = t.spy(); map.on('dragend', dragend); - simulate.touchstart(map.getCanvas()); + simulate.touchstart(map.getCanvas(), {targetTouches: [{clientX: 0, clientY: 0}]}); map._renderTaskQueue.run(); - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}]}); + simulate.touchmove(map.getCanvas(), {targetTouches: [{clientX: 10, clientY: 10}]}); map._renderTaskQueue.run(); simulate.blur(window); @@ -274,7 +240,7 @@ test('DragPanHandler can interleave with another handler', (t) => { ['ctrl', 'shift'].forEach((modifier) => { test(`DragPanHandler does not begin a drag if the ${modifier} key is down on mousedown`, (t) => { const map = createMap(t); - map.dragRotate.disable(); + t.ok(map.dragRotate.isEnabled()); const dragstart = t.spy(); const drag = t.spy(); @@ -308,7 +274,7 @@ test('DragPanHandler can interleave with another handler', (t) => { test(`DragPanHandler still ends a drag if the ${modifier} key is down on mouseup`, (t) => { const map = createMap(t); - map.dragRotate.disable(); + t.ok(map.dragRotate.isEnabled()); const dragstart = t.spy(); const drag = t.spy(); @@ -470,10 +436,10 @@ test('DragPanHandler does not begin a drag if preventDefault is called on the to map.on('drag', drag); map.on('dragend', dragend); - simulate.touchstart(map.getCanvas()); + simulate.touchstart(map.getCanvas(), {targetTouches: [{clientX: 0, clientY: 0}]}); map._renderTaskQueue.run(); - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}]}); + simulate.touchmove(map.getCanvas(), {targetTouches: [{clientX: 10, clientY: 10}]}); map._renderTaskQueue.run(); simulate.touchend(map.getCanvas()); @@ -507,451 +473,19 @@ test('DragPanHandler does not begin a drag if preventDefault is called on the to map.on('drag', drag); map.on('dragend', dragend); - simulate.touchstart(map.getCanvas()); - map._renderTaskQueue.run(); - - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}]}); - map._renderTaskQueue.run(); - - simulate.touchend(map.getCanvas()); - map._renderTaskQueue.run(); - - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - map.remove(); - t.end(); -}); - -['dragstart', 'drag'].forEach(event => { - test(`DragPanHandler can be disabled on ${event} (#2419)`, (t) => { - const map = createMap(t); - - map.on(event, () => map.dragPan.disable()); - - const dragstart = t.spy(); - const drag = t.spy(); - const dragend = t.spy(); - - map.on('dragstart', dragstart); - map.on('drag', drag); - map.on('dragend', dragend); - - simulate.mousedown(map.getCanvas()); - map._renderTaskQueue.run(); - - simulate.mousemove(map.getCanvas(), {clientX: 10, clientY: 10}); - map._renderTaskQueue.run(); - - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, event === 'dragstart' ? 0 : 1); - t.equal(dragend.callCount, 1); - t.equal(map.isMoving(), false); - t.equal(map.dragPan.isEnabled(), false); - - simulate.mouseup(map.getCanvas()); - map._renderTaskQueue.run(); - - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, event === 'dragstart' ? 0 : 1); - t.equal(dragend.callCount, 1); - t.equal(map.isMoving(), false); - t.equal(map.dragPan.isEnabled(), false); - - map.remove(); - t.end(); - }); -}); - -test(`DragPanHandler can be disabled after mousedown (#2419)`, (t) => { - const map = createMap(t); - - const dragstart = t.spy(); - const drag = t.spy(); - const dragend = t.spy(); - - map.on('dragstart', dragstart); - map.on('drag', drag); - map.on('dragend', dragend); - - simulate.mousedown(map.getCanvas()); - map._renderTaskQueue.run(); - - map.dragPan.disable(); - - simulate.mousemove(map.getCanvas(), {clientX: 10, clientY: 10}); - map._renderTaskQueue.run(); - - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - t.equal(map.isMoving(), false); - t.equal(map.dragPan.isEnabled(), false); - - simulate.mouseup(map.getCanvas()); - map._renderTaskQueue.run(); - - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - t.equal(map.isMoving(), false); - t.equal(map.dragPan.isEnabled(), false); - - map.remove(); - t.end(); -}); - -test('DragPanHandler does not begin a drag on spurious mousemove events', (t) => { - const map = createMap(t); - map.dragRotate.disable(); - - const dragstart = t.spy(); - const drag = t.spy(); - const dragend = t.spy(); - - map.on('dragstart', dragstart); - map.on('drag', drag); - map.on('dragend', dragend); - - simulate.mousedown(map.getCanvas(), {clientX: 10, clientY: 10}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.mousemove(map.getCanvas(), {clientX: 10, clientY: 10}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.mouseup(map.getCanvas(), {clientX: 10, clientY: 10}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - map.remove(); - t.end(); -}); - -test('DragPanHandler does not begin a drag on spurious touchmove events', (t) => { - const map = createMap(t); - - const dragstart = t.spy(); - const drag = t.spy(); - const dragend = t.spy(); - - map.on('dragstart', dragstart); - map.on('drag', drag); - map.on('dragend', dragend); - - simulate.touchstart(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}]}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}]}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.touchend(map.getCanvas(), {touches: []}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - map.remove(); - t.end(); -}); - -test('DragPanHandler does not begin a mouse drag if moved less than click tolerance', (t) => { - const map = createMap(t, 4); - - const dragstart = t.spy(); - const drag = t.spy(); - const dragend = t.spy(); - - map.on('dragstart', dragstart); - map.on('drag', drag); - map.on('dragend', dragend); - - simulate.mousedown(map.getCanvas(), {clientX: 10, clientY: 10}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.mousemove(map.getCanvas(), {clientX: 13, clientY: 10}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.mousemove(map.getCanvas(), {clientX: 10, clientY: 13}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.mousemove(map.getCanvas(), {clientX: 14, clientY: 10}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 1); - t.equal(dragend.callCount, 0); - - map.remove(); - t.end(); -}); - -test('DragPanHandler does not begin a touch drag if moved less than click tolerance', (t) => { - const map = createMap(t, 4); - - const dragstart = t.spy(); - const drag = t.spy(); - const dragend = t.spy(); - - map.on('dragstart', dragstart); - map.on('drag', drag); - map.on('dragend', dragend); - - simulate.touchstart(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}]}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 13, clientY: 10}]}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 13}]}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 14, clientY: 10}]}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 1); - t.equal(dragend.callCount, 0); - - map.remove(); - t.end(); -}); - -test('DragPanHandler does not begin a touch drag on multi-finger touch event if zooming', (t) => { - const map = createMap(t); - - const dragstart = t.spy(); - const drag = t.spy(); - const dragend = t.spy(); - - map.on('dragstart', dragstart); - map.on('drag', drag); - map.on('dragend', dragend); - - simulate.touchstart(map.getCanvas(), {touches: [{clientX: 10, clientY: 0}, {clientX: 20, clientY: 0}]}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 5, clientY: 10}, {clientX: 25, clientY: 10}]}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.touchend(map.getCanvas()); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - map.remove(); - t.end(); -}); - -test('DragPanHandler starts a drag on a multi-finger no-zoom touch, and continues if it becomes a single-finger touch', (t) => { - const map = createMap(t); - - const dragstart = t.spy(); - map.on('dragstart', dragstart); - - const drag = t.spy(); - map.on('drag', drag); - - simulate.touchstart(map.getCanvas(), {touches: [{clientX: 20, clientY: 20}, {clientX: 30, clientY: 30}]}); - map._renderTaskQueue.run(); - t.notOk(map.dragPan.isActive()); - t.equals(map.dragPan._state, 'pending'); - t.notOk(dragstart.called); - t.notOk(drag.called); - - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}, {clientX: 20, clientY: 20}]}); - map._renderTaskQueue.run(); - t.ok(map.dragPan.isActive()); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 1); - - simulate.touchend(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}]}); - map._renderTaskQueue.run(); - t.ok(map.dragPan.isActive()); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 1); - - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 0, clientY: 0}]}); - map._renderTaskQueue.run(); - t.ok(map.dragPan.isActive()); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 2); - - map.remove(); - t.end(); -}); - -test('DragPanHandler stops/starts touch-triggered drag appropriately when transitioning between single- and multi-finger touch', (t) => { - const map = createMap(t); - - const dragstart = t.spy(); - const drag = t.spy(); - const dragend = t.spy(); - - map.on('dragstart', dragstart); - map.on('drag', drag); - map.on('dragend', dragend); - - // Single-finger touch starts drag - simulate.touchstart(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}]}); - map._renderTaskQueue.run(); - t.notOk(map.dragPan.isActive()); - t.equal(dragstart.callCount, 0); - t.equal(drag.callCount, 0); - t.equal(dragend.callCount, 0); - - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 20, clientY: 20}]}); - map._renderTaskQueue.run(); - t.ok(map.dragPan.isActive()); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 1); - t.equal(dragend.callCount, 0); - - // Adding a second finger and panning (without zoom/rotate) continues the drag - simulate.touchstart(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}, {clientX: 20, clientY: 20}]}); - map._renderTaskQueue.run(); - t.ok(map.dragPan.isActive()); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 1); - t.equal(dragend.callCount, 0); - - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 20}, {clientX: 20, clientY: 30}]}); - map._renderTaskQueue.run(); - t.ok(map.dragPan.isActive()); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 2); - t.equal(dragend.callCount, 0); - - // Starting a two-finger zoom/rotate stops drag (will trigger touchZoomRotate instead) - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 20}, {clientX: 30, clientY: 30}]}); - map._renderTaskQueue.run(); - t.notOk(map.dragPan.isActive()); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 2); - t.equal(dragend.callCount, 1); - - // Continuing to pan with two fingers does not start a drag (handled by touchZoomRotate instead) - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 10, clientY: 10}, {clientX: 30, clientY: 20}]}); - map._renderTaskQueue.run(); - t.notOk(map.dragPan.isActive()); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 2); - t.equal(dragend.callCount, 1); - - // Removing all but one finger starts another drag - simulate.touchend(map.getCanvas(), {touches: [{clientX: 30, clientY: 20}]}); + simulate.touchstart(map.getCanvas(), {targetTouches: [{clientX: 0, clientY: 0}]}); map._renderTaskQueue.run(); - t.notOk(map.dragPan.isActive()); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 2); - t.equal(dragend.callCount, 1); - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 20, clientY: 20}]}); + simulate.touchmove(map.getCanvas(), {targetTouches: [{clientX: 10, clientY: 10}]}); map._renderTaskQueue.run(); - t.ok(map.dragPan.isActive()); - t.equal(dragstart.callCount, 2); - t.equal(drag.callCount, 3); - t.equal(dragend.callCount, 1); - // Removing last finger stops drag simulate.touchend(map.getCanvas()); map._renderTaskQueue.run(); - t.notOk(map.dragPan.isActive()); - t.equal(dragstart.callCount, 2); - t.equal(drag.callCount, 3); - t.equal(dragend.callCount, 2); - map.remove(); - t.end(); -}); - -test('DragPanHandler fires dragstart, drag, dragend events in response to multi-touch pan', (t) => { - const map = createMap(t); - - const dragstart = t.spy(); - const drag = t.spy(); - const dragend = t.spy(); - - map.on('dragstart', dragstart); - map.on('drag', drag); - map.on('dragend', dragend); - - simulate.touchstart(map.getCanvas(), {touches: [{clientX: 0, clientY: 0}, {clientX: 5, clientY: 0}]}); - map._renderTaskQueue.run(); t.equal(dragstart.callCount, 0); t.equal(drag.callCount, 0); t.equal(dragend.callCount, 0); - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 0, clientY: 10}, {clientX: 5, clientY: 10}]}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 1); - t.equal(dragend.callCount, 0); - - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 0, clientY: 5}, {clientX: 5, clientY: 5}]}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 2); - t.equal(dragend.callCount, 0); - - simulate.touchend(map.getCanvas(), {touches: []}); - map._renderTaskQueue.run(); - t.equal(dragstart.callCount, 1); - t.equal(drag.callCount, 2); - t.equal(dragend.callCount, 1); - map.remove(); t.end(); }); - -test('DragPanHander#enable gets called with dragPan map option parameters', (t) => { - const enableSpy = t.spy(DragPanHandler.prototype, 'enable'); - const customParams = { - linearity: 0.5, - easing: (t) => t, - maxSpeed: 1500, - deceleration: 1900 - }; - const map = createMap(t, null, customParams); - - t.ok(enableSpy.calledWith(customParams)); - t.deepEqual(map.dragPan._inertiaOptions, customParams); - t.end(); -}); diff --git a/test/unit/ui/handler/drag_rotate.test.js b/test/unit/ui/handler/drag_rotate.test.js index 6c6cc0994f9..84235f1d20c 100644 --- a/test/unit/ui/handler/drag_rotate.test.js +++ b/test/unit/ui/handler/drag_rotate.test.js @@ -62,6 +62,7 @@ test('DragRotateHandler stops firing events after mouseup', (t) => { simulate.mousemove(map.getCanvas(), {buttons: 2, clientX: 10, clientY: 10}); map._renderTaskQueue.run(); simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2}); + map._renderTaskQueue.run(); t.equal(spy.callCount, 3); spy.resetHistory(); @@ -130,6 +131,7 @@ test('DragRotateHandler pitches in response to a right-click drag by default', ( t.equal(pitch.callCount, 1); simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2}); + map._renderTaskQueue.run(); t.equal(pitchend.callCount, 1); map.remove(); @@ -150,14 +152,14 @@ test('DragRotateHandler doesn\'t fire pitch event when rotating only', (t) => { map.on('pitch', pitch); map.on('pitchend', pitchend); - simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2}); + simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 0, clientY: 10}); simulate.mousemove(map.getCanvas(), {buttons: 2, clientX: 10, clientY: 10}); map._renderTaskQueue.run(); - t.equal(pitchstart.callCount, 1); + t.equal(pitchstart.callCount, 0); t.equal(pitch.callCount, 0); simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2}); - t.equal(pitchend.callCount, 1); + t.equal(pitchend.callCount, 0); map.remove(); t.end(); @@ -184,6 +186,7 @@ test('DragRotateHandler pitches in response to a control-left-click drag', (t) = t.equal(pitch.callCount, 1); simulate.mouseup(map.getCanvas(), {buttons: 0, button: 0, ctrlKey: true}); + map._renderTaskQueue.run(); t.equal(pitchend.callCount, 1); map.remove(); @@ -277,6 +280,7 @@ test('DragRotateHandler fires move events', (t) => { t.equal(move.callCount, 1); simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2}); + map._renderTaskQueue.run(); t.equal(moveend.callCount, 1); map.remove(); @@ -295,20 +299,20 @@ test('DragRotateHandler doesn\'t fire rotate event when pitching only', (t) => { const pitch = t.spy(); const rotateend = t.spy(); - map.on('movestart', rotatestart); + map.on('rotatestart', rotatestart); map.on('rotate', rotate); map.on('pitch', pitch); map.on('rotateend', rotateend); - simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2}); + simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2, clientX: 0, clientY: 0}); simulate.mousemove(map.getCanvas(), {buttons: 2, clientX: 0, clientY: -10}); map._renderTaskQueue.run(); - t.equal(rotatestart.callCount, 1); + t.equal(rotatestart.callCount, 0); t.equal(rotate.callCount, 0); t.equal(pitch.callCount, 1); simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2}); - t.equal(rotateend.callCount, 1); + t.equal(rotateend.callCount, 0); map.remove(); t.end(); @@ -346,6 +350,7 @@ test('DragRotateHandler includes originalEvent property in triggered events', (t simulate.mousemove(map.getCanvas(), {buttons: 2, clientX: 10, clientY: -10}); map._renderTaskQueue.run(); simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2}); + map._renderTaskQueue.run(); t.ok(rotatestart.firstCall.args[0].originalEvent.type, 'mousemove'); t.ok(pitchstart.firstCall.args[0].originalEvent.type, 'mousemove'); @@ -384,6 +389,7 @@ test('DragRotateHandler responds to events on the canvas container (#1301)', (t) t.equal(rotate.callCount, 1); simulate.mouseup(map.getCanvasContainer(), {buttons: 0, button: 2}); + map._renderTaskQueue.run(); t.equal(rotateend.callCount, 1); map.remove(); @@ -400,7 +406,7 @@ test('DragRotateHandler prevents mousemove events from firing during a drag (#15 map.on('mousemove', mousemove); simulate.mousedown(map.getCanvasContainer(), {buttons: 2, button: 2}); - simulate.mousemove(map.getCanvasContainer(), {buttons: 2, clientX: 10, clientY: 10}); + simulate.mousemove(map.getCanvasContainer(), {buttons: 2, clientX: 100, clientY: 100}); map._renderTaskQueue.run(); simulate.mouseup(map.getCanvasContainer(), {buttons: 0, button: 2}); @@ -431,6 +437,7 @@ test('DragRotateHandler ends a control-left-click drag on mouseup even when the t.equal(rotate.callCount, 1); simulate.mouseup(map.getCanvas(), {buttons: 0, button: 0, ctrlKey: false}); + map._renderTaskQueue.run(); t.equal(rotateend.callCount, 1); map.remove(); @@ -529,7 +536,7 @@ test('DragRotateHandler can interleave with another handler', (t) => { simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2}); map._renderTaskQueue.run(); - t.equal(rotatestart.callCount, 1); + // Ignore second rotatestart triggered by inertia t.equal(rotate.callCount, 2); t.equal(rotateend.callCount, 1); @@ -618,7 +625,7 @@ test('DragRotateHandler does not end a right-button drag on left-button mouseup' simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2}); map._renderTaskQueue.run(); - t.equal(rotatestart.callCount, 1); + // Ignore second rotatestart triggered by inertia t.equal(rotate.callCount, 2); t.equal(rotateend.callCount, 1); @@ -673,7 +680,7 @@ test('DragRotateHandler does not end a control-left-button drag on right-button simulate.mouseup(map.getCanvas(), {buttons: 0, button: 0, ctrlKey: true}); map._renderTaskQueue.run(); - t.equal(rotatestart.callCount, 1); + // Ignore second rotatestart triggered by inertia t.equal(rotate.callCount, 2); t.equal(rotateend.callCount, 1); @@ -711,49 +718,6 @@ test('DragRotateHandler does not begin a drag if preventDefault is called on the t.end(); }); -['rotatestart', 'rotate'].forEach(event => { - test(`DragRotateHandler can be disabled on ${event} (#2419)`, (t) => { - const map = createMap(t); - - // Prevent inertial rotation. - t.stub(browser, 'now').returns(0); - - map.on(event, () => map.dragRotate.disable()); - - const rotatestart = t.spy(); - const rotate = t.spy(); - const rotateend = t.spy(); - - map.on('rotatestart', rotatestart); - map.on('rotate', rotate); - map.on('rotateend', rotateend); - - simulate.mousedown(map.getCanvas(), {buttons: 2, button: 2}); - map._renderTaskQueue.run(); - - simulate.mousemove(map.getCanvas(), {buttons: 2, clientX: 10, clientY: 10}); - map._renderTaskQueue.run(); - - t.equal(rotatestart.callCount, 1); - t.equal(rotate.callCount, event === 'rotatestart' ? 0 : 1); - t.equal(rotateend.callCount, 1); - t.equal(map.isMoving(), false); - t.equal(map.dragRotate.isEnabled(), false); - - simulate.mouseup(map.getCanvas(), {buttons: 0, button: 2}); - map._renderTaskQueue.run(); - - t.equal(rotatestart.callCount, 1); - t.equal(rotate.callCount, event === 'rotatestart' ? 0 : 1); - t.equal(rotateend.callCount, 1); - t.equal(map.isMoving(), false); - t.equal(map.dragRotate.isEnabled(), false); - - map.remove(); - t.end(); - }); -}); - test(`DragRotateHandler can be disabled after mousedown (#2419)`, (t) => { const map = createMap(t); @@ -875,7 +839,7 @@ test('DragRotateHandler does not begin a mouse drag if moved less than click tol t.equal(pitch.callCount, 0); t.equal(pitchend.callCount, 0); - simulate.mousemove(map.getCanvas(), {buttons: 2, clientX: 14, clientY: 13 - 4}); + simulate.mousemove(map.getCanvas(), {buttons: 2, clientX: 14, clientY: 10 - 4}); map._renderTaskQueue.run(); t.equal(rotatestart.callCount, 1); t.equal(rotate.callCount, 1); diff --git a/test/unit/ui/handler/keyboard.test.js b/test/unit/ui/handler/keyboard.test.js new file mode 100644 index 00000000000..8e31ac6b15b --- /dev/null +++ b/test/unit/ui/handler/keyboard.test.js @@ -0,0 +1,130 @@ +import {test} from '../../../util/test'; +import Map from '../../../../src/ui/map'; +import DOM from '../../../../src/util/dom'; +import window from '../../../../src/util/window'; +import simulate from '../../../util/simulate_interaction'; +import {extend} from '../../../../src/util/util'; + +function createMap(t, options) { + t.stub(Map.prototype, '_detectMissingCSS'); + return new Map(extend({ + container: DOM.create('div', '', window.document.body), + }, options)); +} + +test('KeyboardHandler responds to keydown events', (t) => { + const map = createMap(t); + const h = map.keyboard; + t.spy(h, 'keydown'); + + simulate.keydown(map.getCanvas(), {keyCode: 32, key: " "}); + t.ok(h.keydown.called, 'handler keydown method should be called on keydown event'); + t.equal(h.keydown.getCall(0).args[0].keyCode, 32, 'keydown method should be called with the correct event'); + t.end(); +}); + +test('KeyboardHandler pans map in response to arrow keys', (t) => { + const map = createMap(t, {zoom: 10, center: [0, 0]}); + t.spy(map, 'easeTo'); + + simulate.keydown(map.getCanvas(), {keyCode: 32, key: " "}); + t.notOk(map.easeTo.called, 'pressing a non-arrow key should have no effect'); + + simulate.keydown(map.getCanvas(), {keyCode: 37, key: "ArrowLeft"}); + t.ok(map.easeTo.called, 'pressing the left arrow key should trigger an easeTo animation'); + let easeToArgs = map.easeTo.getCall(0).args[0]; + t.equal(easeToArgs.offset[0], 100, 'pressing the left arrow key should offset map positively in X direction'); + t.equal(easeToArgs.offset[1], 0, 'pressing the left arrow key should not offset map in Y direction'); + + simulate.keydown(map.getCanvas(), {keyCode: 39, key: "ArrowRight"}); + t.ok(map.easeTo.callCount === 2, 'pressing the right arrow key should trigger an easeTo animation'); + easeToArgs = map.easeTo.getCall(1).args[0]; + t.equal(easeToArgs.offset[0], -100, 'pressing the right arrow key should offset map negatively in X direction'); + t.equal(easeToArgs.offset[1], 0, 'pressing the right arrow key should not offset map in Y direction'); + + simulate.keydown(map.getCanvas(), {keyCode: 40, key: "ArrowDown"}); + t.ok(map.easeTo.callCount === 3, 'pressing the down arrow key should trigger an easeTo animation'); + easeToArgs = map.easeTo.getCall(2).args[0]; + t.equal(easeToArgs.offset[0], 0, 'pressing the down arrow key should not offset map in X direction'); + t.equal(easeToArgs.offset[1], -100, 'pressing the down arrow key should offset map negatively in Y direction'); + + simulate.keydown(map.getCanvas(), {keyCode: 38, key: "ArrowUp"}); + t.ok(map.easeTo.callCount === 4, 'pressing the up arrow key should trigger an easeTo animation'); + easeToArgs = map.easeTo.getCall(3).args[0]; + t.equal(easeToArgs.offset[0], 0, 'pressing the up arrow key should not offset map in X direction'); + t.equal(easeToArgs.offset[1], 100, 'pressing the up arrow key should offset map positively in Y direction'); + + t.end(); +}); + +test('KeyboardHandler rotates map in response to Shift+left/right arrow keys', async (t) => { + const map = createMap(t, {zoom: 10, center: [0, 0], bearing: 0}); + t.spy(map, 'easeTo'); + + simulate.keydown(map.getCanvas(), {keyCode: 32, key: " "}); + t.notOk(map.easeTo.called, 'pressing a non-arrow key should have no effect'); + + simulate.keydown(map.getCanvas(), {keyCode: 37, key: "ArrowLeft", shiftKey: true}); + t.ok(map.easeTo.called, 'pressing Shift + left arrow key should trigger an easeTo animation'); + let easeToArgs = map.easeTo.getCall(0).args[0]; + t.equal(easeToArgs.bearing, -15, 'pressing Shift + left arrow key should rotate map clockwise'); + t.equal(easeToArgs.offset[0], 0, 'pressing Shift + left arrow key should not offset map in X direction'); + + map.setBearing(0); + simulate.keydown(map.getCanvas(), {keyCode: 39, key: "ArrowRight", shiftKey: true}); + t.ok(map.easeTo.callCount === 2, 'pressing Shift + right arrow key should trigger an easeTo animation'); + easeToArgs = map.easeTo.getCall(1).args[0]; + t.equal(easeToArgs.bearing, 15, 'pressing Shift + right arrow key should rotate map counterclockwise'); + t.equal(easeToArgs.offset[0], 0, 'pressing Shift + right arrow key should not offset map in X direction'); + + t.end(); +}); + +test('KeyboardHandler pitches map in response to Shift+up/down arrow keys', async (t) => { + const map = createMap(t, {zoom: 10, center: [0, 0], pitch: 30}); + t.spy(map, 'easeTo'); + + simulate.keydown(map.getCanvas(), {keyCode: 32, key: " "}); + t.notOk(map.easeTo.called, 'pressing a non-arrow key should have no effect'); + + simulate.keydown(map.getCanvas(), {keyCode: 40, key: "ArrowDown", shiftKey: true}); + t.ok(map.easeTo.called, 'pressing Shift + down arrow key should trigger an easeTo animation'); + let easeToArgs = map.easeTo.getCall(0).args[0]; + t.equal(easeToArgs.pitch, 20, 'pressing Shift + down arrow key should pitch map less'); + t.equal(easeToArgs.offset[1], 0, 'pressing Shift + down arrow key should not offset map in Y direction'); + + map.setPitch(30); + simulate.keydown(map.getCanvas(), {keyCode: 38, key: "ArrowUp", shiftKey: true}); + t.ok(map.easeTo.callCount === 2, 'pressing Shift + up arrow key should trigger an easeTo animation'); + easeToArgs = map.easeTo.getCall(1).args[0]; + t.equal(easeToArgs.pitch, 40, 'pressing Shift + up arrow key should pitch map more'); + t.equal(easeToArgs.offset[1], 0, 'pressing Shift + up arrow key should not offset map in Y direction'); + + t.end(); +}); + +test('KeyboardHandler zooms map in response to -/+ keys', (t) => { + const map = createMap(t, {zoom: 10, center: [0, 0]}); + t.spy(map, 'easeTo'); + + simulate.keydown(map.getCanvas(), {keyCode: 187, key: "Equal"}); + t.equal(map.easeTo.callCount, 1, 'pressing the +/= key should trigger an easeTo animation'); + t.equal(map.easeTo.getCall(0).args[0].zoom, 11, 'pressing the +/= key should zoom map in'); + + map.setZoom(10); + simulate.keydown(map.getCanvas(), {keyCode: 187, key: "Equal", shiftKey: true}); + t.equal(map.easeTo.callCount, 2, 'pressing Shift + +/= key should trigger an easeTo animation'); + t.equal(map.easeTo.getCall(1).args[0].zoom, 12, 'pressing Shift + +/= key should zoom map in more'); + + map.setZoom(10); + simulate.keydown(map.getCanvas(), {keyCode: 189, key: "Minus"}); + t.equal(map.easeTo.callCount, 3, 'pressing the -/_ key should trigger an easeTo animation'); + t.equal(map.easeTo.getCall(2).args[0].zoom, 9, 'pressing the -/_ key should zoom map out'); + + map.setZoom(10); + simulate.keydown(map.getCanvas(), {keyCode: 189, key: "Minus", shiftKey: true}); + t.equal(map.easeTo.callCount, 4, 'pressing Shift + -/_ key should trigger an easeTo animation'); + t.equal(map.easeTo.getCall(3).args[0].zoom, 8, 'pressing Shift + -/_ key should zoom map out more'); + + t.end(); +}); diff --git a/test/unit/ui/handler/scroll_zoom.test.js b/test/unit/ui/handler/scroll_zoom.test.js index 4e6932ce5b5..5bde7c568e5 100644 --- a/test/unit/ui/handler/scroll_zoom.test.js +++ b/test/unit/ui/handler/scroll_zoom.test.js @@ -186,6 +186,8 @@ test('ScrollZoomHandler', (t) => { clock.tick(200); + map._renderTaskQueue.run(); + t.equal(startCount, 1); t.equal(endCount, 1); @@ -229,6 +231,7 @@ test('ScrollZoomHandler', (t) => { } clock.tick(200); + map._renderTaskQueue.run(); t.equal(startCount, 1); t.equal(endCount, 1); diff --git a/test/unit/ui/handler/touch_zoom_rotate.test.js b/test/unit/ui/handler/touch_zoom_rotate.test.js index 92838edec33..a70ce438c4f 100644 --- a/test/unit/ui/handler/touch_zoom_rotate.test.js +++ b/test/unit/ui/handler/touch_zoom_rotate.test.js @@ -16,32 +16,36 @@ test('TouchZoomRotateHandler fires zoomstart, zoom, and zoomend events at approp const zoom = t.spy(); const zoomend = t.spy(); + map.handlers._handlersById.tapZoom.disable(); + map.touchPitch.disable(); map.on('zoomstart', zoomstart); map.on('zoom', zoom); map.on('zoomend', zoomend); - simulate.touchstart(map.getCanvas(), {touches: [{clientX: 0, clientY: -5}, {clientX: 0, clientY: 5}]}); + simulate.touchstart(map.getCanvas(), {targetTouches: [{identifier: 1, clientX: 0, clientY: -50}, {identifier: 2, clientX: 0, clientY: 50}]}); map._renderTaskQueue.run(); t.equal(zoomstart.callCount, 0); t.equal(zoom.callCount, 0); t.equal(zoomend.callCount, 0); - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 0, clientY: -10}, {clientX: 0, clientY: 10}]}); + simulate.touchmove(map.getCanvas(), {targetTouches: [{identifier: 1, clientX: 0, clientY: -100}, {identifier: 2, clientX: 0, clientY: 100}]}); map._renderTaskQueue.run(); t.equal(zoomstart.callCount, 1); t.equal(zoom.callCount, 1); t.equal(zoomend.callCount, 0); - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 0, clientY: -5}, {clientX: 0, clientY: 5}]}); + simulate.touchmove(map.getCanvas(), {targetTouches: [{identifier: 1, clientX: 0, clientY: -60}, {identifier: 2, clientX: 0, clientY: 60}]}); map._renderTaskQueue.run(); t.equal(zoomstart.callCount, 1); t.equal(zoom.callCount, 2); t.equal(zoomend.callCount, 0); - simulate.touchend(map.getCanvas(), {touches: []}); + simulate.touchend(map.getCanvas(), {targetTouches: []}); map._renderTaskQueue.run(); + // incremented because inertia starts a second zoom t.equal(zoomstart.callCount, 2); + map._renderTaskQueue.run(); t.equal(zoom.callCount, 3); t.equal(zoomend.callCount, 1); @@ -60,25 +64,25 @@ test('TouchZoomRotateHandler fires rotatestart, rotate, and rotateend events at map.on('rotate', rotate); map.on('rotateend', rotateend); - simulate.touchstart(map.getCanvas(), {touches: [{clientX: 0, clientY: -5}, {clientX: 0, clientY: 5}]}); + simulate.touchstart(map.getCanvas(), {targetTouches: [{identifier: 0, clientX: 0, clientY: -50}, {identifier: 1, clientX: 0, clientY: 50}]}); map._renderTaskQueue.run(); t.equal(rotatestart.callCount, 0); t.equal(rotate.callCount, 0); t.equal(rotateend.callCount, 0); - simulate.touchmove(map.getCanvas(), {touches: [{clientX: -5, clientY: 0}, {clientX: 5, clientY: 0}]}); + simulate.touchmove(map.getCanvas(), {targetTouches: [{identifier: 0, clientX: -50, clientY: 0}, {identifier: 1, clientX: 50, clientY: 0}]}); map._renderTaskQueue.run(); t.equal(rotatestart.callCount, 1); t.equal(rotate.callCount, 1); t.equal(rotateend.callCount, 0); - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 0, clientY: -5}, {clientX: 0, clientY: 5}]}); + simulate.touchmove(map.getCanvas(), {targetTouches: [{identifier: 0, clientX: 0, clientY: -50}, {identifier: 1, clientX: 0, clientY: 50}]}); map._renderTaskQueue.run(); t.equal(rotatestart.callCount, 1); t.equal(rotate.callCount, 2); t.equal(rotateend.callCount, 0); - simulate.touchend(map.getCanvas(), {touches: []}); + simulate.touchend(map.getCanvas(), {targetTouches: []}); map._renderTaskQueue.run(); t.equal(rotatestart.callCount, 1); t.equal(rotate.callCount, 2); @@ -96,13 +100,13 @@ test('TouchZoomRotateHandler does not begin a gesture if preventDefault is calle const move = t.spy(); map.on('move', move); - simulate.touchstart(map.getCanvas(), {touches: [{clientX: 0, clientY: 0}, {clientX: 5, clientY: 0}]}); + simulate.touchstart(map.getCanvas(), {targetTouches: [{clientX: 0, clientY: 0}, {clientX: 5, clientY: 0}]}); map._renderTaskQueue.run(); - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 0, clientY: 0}, {clientX: 0, clientY: 5}]}); + simulate.touchmove(map.getCanvas(), {targetTouches: [{clientX: 0, clientY: 0}, {clientX: 0, clientY: 5}]}); map._renderTaskQueue.run(); - simulate.touchend(map.getCanvas(), {touches: []}); + simulate.touchend(map.getCanvas(), {targetTouches: []}); map._renderTaskQueue.run(); t.equal(move.callCount, 0); @@ -114,6 +118,7 @@ test('TouchZoomRotateHandler does not begin a gesture if preventDefault is calle test('TouchZoomRotateHandler starts zoom immediately when rotation disabled', (t) => { const map = createMap(t); map.touchZoomRotate.disableRotation(); + map.handlers._handlersById.tapZoom.disable(); const zoomstart = t.spy(); const zoom = t.spy(); @@ -123,31 +128,44 @@ test('TouchZoomRotateHandler starts zoom immediately when rotation disabled', (t map.on('zoom', zoom); map.on('zoomend', zoomend); - simulate.touchstart(map.getCanvas(), {touches: [{clientX: 0, clientY: -5}, {clientX: 0, clientY: 5}]}); + simulate.touchstart(map.getCanvas(), {targetTouches: [{identifier: 0, clientX: 0, clientY: -5}, {identifier: 2, clientX: 0, clientY: 5}]}); map._renderTaskQueue.run(); t.equal(zoomstart.callCount, 0); t.equal(zoom.callCount, 0); t.equal(zoomend.callCount, 0); - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 0, clientY: -5}, {clientX: 0, clientY: 6}]}); + simulate.touchmove(map.getCanvas(), {targetTouches: [{identifier: 0, clientX: 0, clientY: -5}, {identifier: 2, clientX: 0, clientY: 6}]}); map._renderTaskQueue.run(); t.equal(zoomstart.callCount, 1); t.equal(zoom.callCount, 1); t.equal(zoomend.callCount, 0); - simulate.touchmove(map.getCanvas(), {touches: [{clientX: 0, clientY: -5}, {clientX: 0, clientY: 5}]}); + simulate.touchmove(map.getCanvas(), {targetTouches: [{identifier: 0, clientX: 0, clientY: -5}, {identifier: 2, clientX: 0, clientY: 4}]}); map._renderTaskQueue.run(); t.equal(zoomstart.callCount, 1); t.equal(zoom.callCount, 2); t.equal(zoomend.callCount, 0); - simulate.touchend(map.getCanvas(), {touches: []}); + simulate.touchend(map.getCanvas(), {targetTouches: []}); map._renderTaskQueue.run(); // incremented because inertia starts a second zoom t.equal(zoomstart.callCount, 2); + map._renderTaskQueue.run(); t.equal(zoom.callCount, 3); t.equal(zoomend.callCount, 1); map.remove(); t.end(); }); + +test('TouchZoomRotateHandler adds css class used for disabling default touch behavior in some browsers', (t) => { + const map = createMap(t); + + const className = 'mapboxgl-touch-zoom-rotate'; + t.ok(map.getCanvasContainer().classList.contains(className)); + map.touchZoomRotate.disable(); + t.notOk(map.getCanvasContainer().classList.contains(className)); + map.touchZoomRotate.enable(); + t.ok(map.getCanvasContainer().classList.contains(className)); + t.end(); +}); diff --git a/test/unit/ui/map.test.js b/test/unit/ui/map.test.js index cb3f3766214..6e312951bd7 100755 --- a/test/unit/ui/map.test.js +++ b/test/unit/ui/map.test.js @@ -1999,7 +1999,7 @@ test('Map', (t) => { const map = createMap(t, {interactive: true}); map.flyTo({center: [200, 0], duration: 100}); - simulate.touchstart(map.getCanvasContainer()); + simulate.touchstart(map.getCanvasContainer(), {targetTouches: [{clientX: 0, clientY: 0}]}); t.equal(map.isEasing(), false); map.remove(); diff --git a/test/unit/ui/map/isMoving.test.js b/test/unit/ui/map/isMoving.test.js index adfd483dc9e..ffc7d1ad324 100644 --- a/test/unit/ui/map/isMoving.test.js +++ b/test/unit/ui/map/isMoving.test.js @@ -103,7 +103,9 @@ test('Map#isMoving returns true when scroll zooming', (t) => { map._renderTaskQueue.run(); now += 400; - map._renderTaskQueue.run(); + setTimeout(() => { + map._renderTaskQueue.run(); + }, 400); }); test('Map#isMoving returns true when drag panning and scroll zooming interleave', (t) => { @@ -120,7 +122,9 @@ test('Map#isMoving returns true when drag panning and scroll zooming interleave' map.on('zoomend', () => { t.equal(map.isMoving(), true); simulate.mouseup(map.getCanvas()); - map._renderTaskQueue.run(); + setTimeout(() => { + map._renderTaskQueue.run(); + }); }); map.on('dragend', () => { @@ -146,5 +150,7 @@ test('Map#isMoving returns true when drag panning and scroll zooming interleave' map._renderTaskQueue.run(); now += 400; - map._renderTaskQueue.run(); + setTimeout(() => { + map._renderTaskQueue.run(); + }, 400); }); diff --git a/test/unit/ui/map/isZooming.test.js b/test/unit/ui/map/isZooming.test.js index 7e2d6436a65..6cd6c76b41b 100644 --- a/test/unit/ui/map/isZooming.test.js +++ b/test/unit/ui/map/isZooming.test.js @@ -53,7 +53,9 @@ test('Map#isZooming returns true when scroll zooming', (t) => { map._renderTaskQueue.run(); now += 400; - map._renderTaskQueue.run(); + setTimeout(() => { + map._renderTaskQueue.run(); + }, 400); }); test('Map#isZooming returns true when double-click zooming', (t) => { diff --git a/test/util/simulate_interaction.js b/test/util/simulate_interaction.js index 7889621c0e1..032bbe4547c 100644 --- a/test/util/simulate_interaction.js +++ b/test/util/simulate_interaction.js @@ -40,11 +40,13 @@ events.dblclick = function (target, options) { target.dispatchEvent(new MouseEvent('dblclick', options)); }; -events.keypress = function (target, options) { - options = Object.assign({bubbles: true}, options); - const KeyboardEvent = window(target).KeyboardEvent; - target.dispatchEvent(new KeyboardEvent('keypress', options)); -}; +['keydown', 'keyup', 'keypress'].forEach((event) => { + events[event] = function (target, options) { + options = Object.assign({bubbles: true}, options); + const KeyboardEvent = window(target).KeyboardEvent; + target.dispatchEvent(new KeyboardEvent(event, options)); + }; +}); [ 'mouseup', 'mousedown', 'mouseover', 'mousemove', 'mouseout' ].forEach((event) => { events[event] = function (target, options) { diff --git a/test/util/test.js b/test/util/test.js index 962d651af5a..49218e59ad2 100644 --- a/test/util/test.js +++ b/test/util/test.js @@ -38,7 +38,7 @@ tap.beforeEach(function (done) { }); // $FlowFixMe the assignment is intentional - console.error = () => this.fail(`console.error called -- please adjust your test (maybe stub console.error?)`); + console.error = (msg) => this.fail(`console.error called -- please adjust your test (maybe stub console.error?)\n${msg}`); // $FlowFixMe the assignment is intentional console.warn = () => this.fail(`console.warn called -- please adjust your test (maybe stub console.warn?)`);