diff --git a/debug/scroll_zoom_blocker.html b/debug/scroll_zoom_blocker.html new file mode 100644 index 00000000000..5bb785705db --- /dev/null +++ b/debug/scroll_zoom_blocker.html @@ -0,0 +1,34 @@ + + + + Scroll Zoom Blocker Control + + + + + + + +
+ +
+ + + + + diff --git a/src/css/mapbox-gl.css b/src/css/mapbox-gl.css index e16509ee8d7..62dbfd5142d 100644 --- a/src/css/mapbox-gl.css +++ b/src/css/mapbox-gl.css @@ -769,3 +769,27 @@ a.mapboxgl-ctrl-logo.mapboxgl-compact { display: none; } } + +.mapboxgl-scroll-zoom-blocker { + color: #fff; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + justify-content: center; + text-align: center; + position: absolute; + display: flex; + align-items: center; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + opacity: 0; + pointer-events: none; + transition: opacity 0.75s ease-in-out; + transition-delay: 1s; +} + +.mapboxgl-scroll-zoom-blocker-show { + opacity: 1; + transition: opacity 0.1s ease-in-out; +} diff --git a/src/ui/default_locale.js b/src/ui/default_locale.js index 92c548d4504..520109bf1a0 100644 --- a/src/ui/default_locale.js +++ b/src/ui/default_locale.js @@ -15,8 +15,9 @@ const defaultLocale = { 'ScaleControl.Meters': 'm', 'ScaleControl.Kilometers': 'km', 'ScaleControl.Miles': 'mi', - 'ScaleControl.NauticalMiles': 'nm' - + 'ScaleControl.NauticalMiles': 'nm', + 'ScrollZoomBlocker.CtrlMessage': 'Use ctrl + scroll to zoom the map', + 'ScrollZoomBlocker.CmdMessage': 'Use ⌘ + scroll to zoom the map' }; export default defaultLocale; diff --git a/src/ui/handler/scroll_zoom.js b/src/ui/handler/scroll_zoom.js index e7e6f232b51..74084468297 100644 --- a/src/ui/handler/scroll_zoom.js +++ b/src/ui/handler/scroll_zoom.js @@ -60,6 +60,9 @@ class ScrollZoomHandler { _defaultZoomRate: number; _wheelZoomRate: number; + _alertContainer: HTMLElement; // used to display the scroll zoom blocker alert + _alertTimer: TimeoutID; + /** * @private */ @@ -73,7 +76,8 @@ class ScrollZoomHandler { this._defaultZoomRate = defaultZoomRate; this._wheelZoomRate = wheelZoomRate; - bindAll(['_onTimeout'], this); + bindAll(['_onTimeout', '_addScrollZoomBlocker', '_showBlockerAlert', '_isFullscreen'], this); + } /** @@ -139,6 +143,7 @@ class ScrollZoomHandler { if (this.isEnabled()) return; this._enabled = true; this._aroundCenter = options && options.around === 'center'; + if (this._map._gestureHandling) this._addScrollZoomBlocker(); } /** @@ -150,11 +155,23 @@ class ScrollZoomHandler { disable() { if (!this.isEnabled()) return; this._enabled = false; + if (this._map._gestureHandling) this._alertContainer.remove(); } wheel(e: WheelEvent) { if (!this.isEnabled()) return; + if (this._map._gestureHandling) { + if (!e.ctrlKey && !e.metaKey && !this.isZooming() && !this._isFullscreen()) { + this._showBlockerAlert(); + return; + } else if (this._alertContainer.style.visibility !== 'hidden') { + // immediately hide alert if it is visible when ctrl or ⌘ is pressed while scroll zooming. + this._alertContainer.style.visibility = 'hidden'; + clearTimeout(this._alertTimer); + } + } + // Remove `any` cast when https://github.com/facebook/flow/issues/4879 is fixed. let value = e.deltaMode === (window.WheelEvent: any).DOM_DELTA_LINE ? e.deltaY * 40 : e.deltaY; const now = browser.now(), @@ -248,6 +265,7 @@ class ScrollZoomHandler { this._frameId = null; if (!this.isActive()) return; + const tr = this._map.transform; const startingZoom = () => { @@ -356,6 +374,37 @@ class ScrollZoomHandler { reset() { this._active = false; } + + _addScrollZoomBlocker() { + if (this._map && !this._alertContainer) { + this._alertContainer = DOM.create('div', 'mapboxgl-scroll-zoom-blocker', this._map._container); + + if (/(Mac|iPad)/i.test(window.navigator.userAgent)) { + this._alertContainer.textContent = this._map._getUIString('ScrollZoomBlocker.CmdMessage'); + } else { + this._alertContainer.textContent = this._map._getUIString('ScrollZoomBlocker.CtrlMessage'); + } + + // dynamically set the font size of the scroll zoom blocker alert message + this._alertContainer.style.fontSize = `${Math.max(10, Math.min(24, Math.floor(this._el.clientWidth * 0.05)))}px`; + } + } + + _isFullscreen() { + return window.document.fullscreenElement !== null; + } + + _showBlockerAlert() { + if (this._alertContainer.style.visibility === 'hidden') this._alertContainer.style.visibility = 'visible'; + this._alertContainer.classList.add('mapboxgl-scroll-zoom-blocker-show'); + + clearTimeout(this._alertTimer); + + this._alertTimer = setTimeout(() => { + this._alertContainer.classList.remove('mapboxgl-scroll-zoom-blocker-show'); + }, 200); + } + } export default ScrollZoomHandler; diff --git a/src/ui/map.js b/src/ui/map.js index eee0bbef0e0..da183a2d39b 100755 --- a/src/ui/map.js +++ b/src/ui/map.js @@ -106,6 +106,7 @@ type MapOptions = { doubleClickZoom?: boolean, touchZoomRotate?: boolean, touchPitch?: boolean, + gestureHandling?: boolean, trackResize?: boolean, center?: LngLatLike, zoom?: number, @@ -148,6 +149,7 @@ const defaultOptions = { doubleClickZoom: true, touchZoomRotate: true, touchPitch: true, + gestureHandling: false, bearingSnap: 7, clickTolerance: 3, @@ -234,6 +236,7 @@ const defaultOptions = { * @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.gestureHandling=false] If `true`, scroll zoom will require pressing the ctrl or ⌘ key while scrolling to zoom map. * @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`. @@ -342,6 +345,7 @@ class Map extends Camera { _removed: boolean; _speedIndexTiming: boolean; _clickTolerance: number; + _gestureHandling: boolean; _silenceAuthErrors: boolean; _averageElevationLastSampledAt: number; _averageElevation: EasedVariable; @@ -442,6 +446,7 @@ class Map extends Camera { this._mapId = uniqueId(); this._locale = extend({}, defaultLocale, options.locale); this._clickTolerance = options.clickTolerance; + this._gestureHandling = options.gestureHandling; this._averageElevationLastSampledAt = -Infinity; this._averageElevation = new EasedVariable(0); diff --git a/test/unit/ui/handler/scroll_zoom.test.js b/test/unit/ui/handler/scroll_zoom.test.js index 84d62e88646..3fabc22e563 100644 --- a/test/unit/ui/handler/scroll_zoom.test.js +++ b/test/unit/ui/handler/scroll_zoom.test.js @@ -24,6 +24,15 @@ function createMap(t) { }); } +function createMapWithGestureHandling(t) { + t.stub(Map.prototype, '_detectMissingCSS'); + t.stub(Map.prototype, '_authenticate'); + return new Map({ + container: DOM.create('div', '', window.document.body), + gestureHandling: true + }); +} + test('ScrollZoomHandler', (t) => { const browserNow = t.stub(browser, 'now'); let now = 1555555555555; @@ -366,3 +375,59 @@ test('ScrollZoomHandler', (t) => { t.end(); }); + +test('When gestureHandling option is set to true, a .mapboxgl-scroll-zoom-blocker element is added to map', (t) => { + const map = createMapWithGestureHandling(t); + + t.equal(map.getContainer().querySelectorAll('.mapboxgl-scroll-zoom-blocker').length, 1); + t.end(); +}); + +test('When gestureHandling option is set to true, scroll zoom is prevented when the ctrl key or meta key is not pressed during wheel event', (t) => { + const map = createMapWithGestureHandling(t); + + const zoomSpy = t.spy(); + map.on('zoom', zoomSpy); + + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta}); + + t.equal(zoomSpy.callCount, 0); + t.end(); +}); + +test('When gestureHandling option is set to true, scroll zoom is activated when ctrl key is pressed during wheel event', (t) => { + const map = createMapWithGestureHandling(t); + + const zoomSpy = t.spy(); + map.on('zoom', zoomSpy); + + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta, ctrlKey: true}); + + map._renderTaskQueue.run(); + + t.equal(zoomSpy.callCount, 1); + t.end(); +}); + +test('When gestureHandling option is set to true, scroll zoom is activated when meta key is pressed during wheel event', (t) => { + const map = createMapWithGestureHandling(t); + + const zoomSpy = t.spy(); + map.on('zoom', zoomSpy); + + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta, metaKey: true}); + + map._renderTaskQueue.run(); + + t.equal(zoomSpy.callCount, 1); + t.end(); +}); + +test('Disabling scrollZoom removes scroll zoom blocker container', (t) => { + const map = createMapWithGestureHandling(t); + + map.scrollZoom.disable(); + + t.equal(map.getContainer().querySelectorAll('.mapboxgl-scroll-zoom-blocker').length, 0); + t.end(); +});