diff --git a/CHANGELOG.md b/CHANGELOG.md index eda0a836c59..5fceced6694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### ✨ Features and improvements -- *...Add new stuff here...* +- Added `collaborativeGestures` option when instantiating map to prevent inadvertent scrolling/panning when navigating a page where map is embedded inline (#234) ### 🐞 Bug fixes diff --git a/src/css/maplibre-gl.css b/src/css/maplibre-gl.css index 5e8fe3f0c90..83b20b3c041 100644 --- a/src/css/maplibre-gl.css +++ b/src/css/maplibre-gl.css @@ -917,3 +917,41 @@ a.mapboxgl-ctrl-logo.mapboxgl-compact { border: 2px dotted #202020; opacity: 0.5; } + +.maplibregl-cooperative-gesture-screen { + background: rgba(0 0 0 / 40%); + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + color: white; + padding: 1rem; + font-size: 1.4em; + line-height: 1.2; + opacity: 0; + pointer-events: none; + transition: opacity 1s ease 1s; +} + +.maplibregl-cooperative-gesture-screen.maplibregl-show { + opacity: 1; + transition: opacity 0.05s; +} + +.maplibregl-cooperative-gesture-screen .maplibregl-mobile-message { + display: none; +} + +@media (hover: none), (max-width: 480px) { + .maplibregl-cooperative-gesture-screen .maplibregl-desktop-message { + display: none; + } + + .maplibregl-cooperative-gesture-screen .maplibregl-mobile-message { + display: block; + } +} diff --git a/src/ui/handler/cooperative_gestures.test.ts b/src/ui/handler/cooperative_gestures.test.ts new file mode 100644 index 00000000000..dccd1334f8f --- /dev/null +++ b/src/ui/handler/cooperative_gestures.test.ts @@ -0,0 +1,167 @@ +import browser from '../../util/browser'; +import Map from '../map'; +import DOM from '../../util/dom'; +import simulate from '../../../test/unit/lib/simulate_interaction'; +import {setMatchMedia, setPerformance, setWebGlContext} from '../../util/test/util'; + +function createMap() { + return new Map({ + container: DOM.create('div', '', window.document.body), + style: { + 'version': 8, + 'sources': {}, + 'layers': [] + }, + cooperativeGestures: true + }); +} + +beforeEach(() => { + setPerformance(); + setWebGlContext(); + setMatchMedia(); +}); + +describe('CoopGesturesHandler', () => { + + test('Does not zoom on wheel if no key is down', () => { + const browserNow = jest.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + + const map = createMap(); + map._renderTaskQueue.run(); + + const startZoom = map.getZoom(); + // simulate a single 'wheel' event + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta}); + map._renderTaskQueue.run(); + + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + + const endZoom = map.getZoom(); + expect(endZoom).toBeCloseTo(startZoom); + + map.remove(); + }); + + test('Zooms on wheel if control key is down', () => { + // NOTE: This should pass regardless of whether cooperativeGestures is enabled or not + const browserNow = jest.spyOn(browser, 'now'); + let now = 1555555555555; + browserNow.mockReturnValue(now); + + const map = createMap(); + map._renderTaskQueue.run(); + + simulate.keydown(document, {type: 'keydown', key: 'Control'}); + map._renderTaskQueue.run(); + + const startZoom = map.getZoom(); + // simulate a single 'wheel' event + simulate.wheel(map.getCanvas(), {type: 'wheel', deltaY: -simulate.magicWheelZoomDelta}); + map._renderTaskQueue.run(); + + now += 400; + browserNow.mockReturnValue(now); + map._renderTaskQueue.run(); + + const endZoom = map.getZoom(); + expect(endZoom - startZoom).toBeCloseTo(0.0285, 3); + + map.remove(); + }); + + test('Does not pan on touchmove with a single touch', () => { + const map = createMap(); + const target = map.getCanvas(); + const startCenter = map.getCenter(); + map._renderTaskQueue.run(); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.touchstart(target, {touches: [{target, clientX: 0, clientY: 0}]}); + map._renderTaskQueue.run(); + + simulate.touchmove(target, {touches: [{target, clientX: 10, clientY: 10}]}); + map._renderTaskQueue.run(); + + simulate.touchend(target); + map._renderTaskQueue.run(); + + const endCenter = map.getCenter(); + expect(endCenter.lng).toEqual(startCenter.lng); + expect(endCenter.lat).toEqual(startCenter.lat); + + map.remove(); + }); + + test('Does pan on touchmove with a double touch', () => { + // NOTE: This should pass regardless of whether cooperativeGestures is enabled or not + const map = createMap(); + const target = map.getCanvas(); + const startCenter = map.getCenter(); + map._renderTaskQueue.run(); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.touchstart(target, {touches: [{target, clientX: 0, clientY: 0}, {target, clientX: 1, clientY: 1}]}); + map._renderTaskQueue.run(); + + simulate.touchmove(target, {touches: [{target, clientX: 10, clientY: 10}, {target, clientX: 11, clientY: 11}]}); + map._renderTaskQueue.run(); + + simulate.touchend(target); + map._renderTaskQueue.run(); + + const endCenter = map.getCenter(); + expect(endCenter.lng).toBeGreaterThan(startCenter.lng); + expect(endCenter.lat).toBeGreaterThan(startCenter.lat); + + map.remove(); + }); + + test('Drag pitch works with 3 fingers', () => { + // NOTE: This should pass regardless of whether cooperativeGestures is enabled or not + const map = createMap(); + const target = map.getCanvas(); + const startPitch = map.getPitch(); + map._renderTaskQueue.run(); + + const dragstart = jest.fn(); + const drag = jest.fn(); + const dragend = jest.fn(); + + map.on('dragstart', dragstart); + map.on('drag', drag); + map.on('dragend', dragend); + + simulate.touchstart(target, {touches: [{target, clientX: 0, clientY: 0}, {target, clientX: 1, clientY: 1}, {target, clientX: 2, clientY: 2}]}); + map._renderTaskQueue.run(); + + simulate.touchmove(target, {touches: [{target, clientX: 0, clientY: -10}, {target, clientX: 1, clientY: -11}, {target, clientX: 2, clientY: -12}]}); + map._renderTaskQueue.run(); + + simulate.touchend(target); + map._renderTaskQueue.run(); + + const endPitch = map.getPitch(); + expect(endPitch).toBeGreaterThan(startPitch); + + map.remove(); + }); +}); diff --git a/src/ui/handler/scroll_zoom.ts b/src/ui/handler/scroll_zoom.ts index c93b77b1b69..c02f196d6d0 100644 --- a/src/ui/handler/scroll_zoom.ts +++ b/src/ui/handler/scroll_zoom.ts @@ -148,6 +148,13 @@ class ScrollZoomHandler { wheel(e: WheelEvent) { if (!this.isEnabled()) return; + if (this._map._cooperativeGestures) { + if (this._map._metaPress) { + e.preventDefault(); + } else { + return; + } + } let value = e.deltaMode === WheelEvent.DOM_DELTA_LINE ? e.deltaY * 40 : e.deltaY; const now = browser.now(), timeDelta = now - (this._lastWheelEventTime || 0); diff --git a/src/ui/handler/touch_pan.ts b/src/ui/handler/touch_pan.ts index ed94b27d53e..d92724d1596 100644 --- a/src/ui/handler/touch_pan.ts +++ b/src/ui/handler/touch_pan.ts @@ -1,5 +1,7 @@ import Point from '@mapbox/point-geometry'; import {indexTouches} from './handler_util'; +import type Map from '../map'; +import {GestureOptions} from '../map'; export default class TouchPanHandler { @@ -11,12 +13,16 @@ export default class TouchPanHandler { _minTouches: number; _clickTolerance: number; _sum: Point; + _map: Map; + _cancelCooperativeMessage: boolean; constructor(options: { clickTolerance: number; - }) { - this._minTouches = 1; + cooperativeGestures: boolean | GestureOptions; + }, map: Map) { + this._minTouches = options.cooperativeGestures ? 2 : 1; this._clickTolerance = options.clickTolerance || 1; + this._map = map; this.reset(); } @@ -24,6 +30,11 @@ export default class TouchPanHandler { this._active = false; this._touches = {}; this._sum = new Point(0, 0); + + // Put a delay on the cooperative gesture message so it's less twitchy + setTimeout(() => { + this._cancelCooperativeMessage = false; + }, 200); } touchstart(e: TouchEvent, points: Array, mapTouches: Array) { @@ -31,6 +42,15 @@ export default class TouchPanHandler { } touchmove(e: TouchEvent, points: Array, mapTouches: Array) { + if (this._map._cooperativeGestures) { + if (this._minTouches === 2 && mapTouches.length < 2 && !this._cancelCooperativeMessage) { + // If coop gesture enabled, show panning info to user + this._map._onCooperativeGesture(e, false, mapTouches.length); + } else if (!this._cancelCooperativeMessage) { + // If user is successfully navigating, we don't need this warning until the touch resets + this._cancelCooperativeMessage = true; + } + } if (!this._active || mapTouches.length < this._minTouches) return; e.preventDefault(); return this._calculateTransform(e, points, mapTouches); diff --git a/src/ui/handler/touch_zoom_rotate.ts b/src/ui/handler/touch_zoom_rotate.ts index b1b34f13d4b..9aba2ec4f6d 100644 --- a/src/ui/handler/touch_zoom_rotate.ts +++ b/src/ui/handler/touch_zoom_rotate.ts @@ -1,5 +1,6 @@ import Point from '@mapbox/point-geometry'; import DOM from '../../util/dom'; +import type Map from '../map'; class TwoTouchHandler { @@ -23,7 +24,6 @@ class TwoTouchHandler { _move(points: [Point, Point], pinchAround: Point, e: TouchEvent) { return {}; } //eslint-disable-line touchstart(e: TouchEvent, points: Array, mapTouches: Array) { - //console.log(e.target, e.targetTouches.length ? e.targetTouches[0].target : null); //log('touchstart', points, e.target.innerHTML, e.targetTouches.length ? e.targetTouches[0].target.innerHTML: undefined); if (this._firstTwoTouches || mapTouches.length < 2) return; @@ -203,6 +203,13 @@ export class TouchPitchHandler extends TwoTouchHandler { _valid: boolean | void; _firstMove: number; _lastPoints: [Point, Point]; + _map: Map; + _currentTouchCount: number; + + constructor(map: Map) { + super(); + this._map = map; + } reset() { super.reset(); @@ -211,6 +218,11 @@ export class TouchPitchHandler extends TwoTouchHandler { delete this._lastPoints; } + touchstart(e: TouchEvent, points: Array, mapTouches: Array) { + super.touchstart(e, points, mapTouches); + this._currentTouchCount = mapTouches.length; + } + _start(points: [Point, Point]) { this._lastPoints = points; if (isVertical(points[0].sub(points[1]))) { @@ -221,6 +233,11 @@ export class TouchPitchHandler extends TwoTouchHandler { } _move(points: [Point, Point], center: Point, e: TouchEvent) { + // If cooperative gestures is enabled, we need a 3-finger minimum for this gesture to register + if (this._map._cooperativeGestures && this._currentTouchCount < 3) { + return; + } + const vectorA = points[0].sub(this._lastPoints[0]); const vectorB = points[1].sub(this._lastPoints[1]); diff --git a/src/ui/handler_manager.ts b/src/ui/handler_manager.ts index 027f85f977a..522efc8b224 100644 --- a/src/ui/handler_manager.ts +++ b/src/ui/handler_manager.ts @@ -193,7 +193,7 @@ class HandlerManager { const tapDragZoom = new TapDragZoomHandler(); this._add('tapDragZoom', tapDragZoom); - const touchPitch = map.touchPitch = new TouchPitchHandler(); + const touchPitch = map.touchPitch = new TouchPitchHandler(map); this._add('touchPitch', touchPitch); const mouseRotate = new MouseRotateHandler(options); @@ -203,7 +203,7 @@ class HandlerManager { this._add('mousePitch', mousePitch, ['mouseRotate']); const mousePan = new MousePanHandler(options); - const touchPan = new TouchPanHandler(options); + const touchPan = new TouchPanHandler(options, map); map.dragPan = new DragPanHandler(el, mousePan, touchPan); this._add('mousePan', mousePan); this._add('touchPan', touchPan, ['touchZoom', 'touchRotate']); diff --git a/src/ui/map.ts b/src/ui/map.ts index ada8f3b4b84..cce87aa205e 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -58,7 +58,6 @@ import type {ControlPosition, IControl} from './control/control'; import type {MapGeoJSONFeature} from '../util/vectortile_to_geojson'; /* eslint-enable no-use-before-define */ - export type MapOptions = { hash?: boolean | string; interactive?: boolean; @@ -85,6 +84,7 @@ export type MapOptions = { doubleClickZoom?: boolean; touchZoomRotate?: boolean; touchPitch?: boolean; + cooperativeGestures?: boolean | GestureOptions; trackResize?: boolean; center?: LngLatLike; zoom?: number; @@ -106,6 +106,12 @@ export type MapOptions = { pixelRatio?: number; }; +export type GestureOptions = { + windowsHelpText?: string; + macHelpText?: string; + mobileHelpText?: string; +}; + // See article here: https://medium.com/terria/typescript-transforming-optional-properties-to-required-properties-that-may-be-undefined-7482cb4e1585 type Complete = { [P in keyof Required]: Pick extends Required> ? T[P] : (T[P] | undefined); @@ -145,6 +151,7 @@ const defaultOptions = { doubleClickZoom: true, touchZoomRotate: true, touchPitch: true, + cooperativeGestures: undefined, bearingSnap: 7, clickTolerance: 3, @@ -216,6 +223,13 @@ 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|GestureOptions} [options.cooperativeGestures=undefined] If `true` or set to an options object, map is only accessible on desktop while holding Command/Ctrl and only accessible on mobile with two fingers. Interacting with the map using normal gestures will trigger an informational screen. With this option enabled, "drag to pitch" requires a three-finger gesture. + * A valid options object includes the following properties to customize the text on the informational screen. The values below are the defaults. + * { + * windowsHelpText: "Use Ctrl + scroll to zoom the map", + * macHelpText: "Use ⌘ + scroll to zoom the map", + * mobileHelpText: "Use two fingers to move the 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 initial geographical centerpoint of the map. If `center` is not specified in the constructor options, MapLibre 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: MapLibre 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, MapLibre 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`. @@ -270,6 +284,9 @@ class Map extends Camera { _controlContainer: HTMLElement; _controlPositions: {[_: string]: HTMLElement}; _interactive: boolean; + _cooperativeGestures: boolean | GestureOptions; + _cooperativeGesturesScreen: HTMLElement; + _metaPress: boolean; _showTileBoundaries: boolean; _showCollisionBoxes: boolean; _showPadding: boolean; @@ -380,6 +397,7 @@ class Map extends Camera { super(transform, {bearingSnap: options.bearingSnap}); this._interactive = options.interactive; + this._cooperativeGestures = options.cooperativeGestures; this._maxTileCacheSize = options.maxTileCacheSize; this._failIfMajorPerformanceCaveat = options.failIfMajorPerformanceCaveat; this._preserveDrawingBuffer = options.preserveDrawingBuffer; @@ -441,6 +459,10 @@ class Map extends Camera { this.handlers = new HandlerManager(this, options as CompleteMapOptions); + if (this._cooperativeGestures) { + this._setupCooperativeGestures(); + } + const hashName = (typeof options.hash === 'string' && options.hash) || undefined; this._hash = options.hash && (new Hash(hashName)).addTo(this); // don't set position from options if set through hash @@ -2384,6 +2406,35 @@ class Map extends Camera { this._container.addEventListener('scroll', this._onMapScroll, false); } + _setupCooperativeGestures() { + const container = this._container; + this._metaPress = false; + this._cooperativeGesturesScreen = DOM.create('div', 'maplibregl-cooperative-gesture-screen', container); + let modifierKeyName = 'Control'; + let desktopMessage = typeof this._cooperativeGestures !== 'boolean' && this._cooperativeGestures.windowsHelpText ? this._cooperativeGestures.windowsHelpText : 'Use Ctrl + scroll to zoom the map'; + if (navigator.platform.indexOf('Mac') === 0) { + desktopMessage = typeof this._cooperativeGestures !== 'boolean' && this._cooperativeGestures.macHelpText ? this._cooperativeGestures.macHelpText : 'Use ⌘ + scroll to zoom the map'; + modifierKeyName = 'Meta'; + } + const mobileMessage = typeof this._cooperativeGestures !== 'boolean' && this._cooperativeGestures.mobileHelpText ? this._cooperativeGestures.mobileHelpText : 'Use two fingers to move the map'; + this._cooperativeGesturesScreen.innerHTML = ` +
${desktopMessage}
+
${mobileMessage}
+ `; + document.addEventListener('keydown', (event) => { + if (event.key === modifierKeyName) this._metaPress = true; + }); + document.addEventListener('keyup', (event) => { + if (event.key === modifierKeyName) this._metaPress = false; + }); + // Add event to canvas container since gesture container is pointer-events: none + this._canvasContainer.addEventListener('wheel', (e) => { + this._onCooperativeGesture(e, this._metaPress, 1); + }, false); + // Remove the traditional pan classes + this._canvasContainer.classList.remove('mapboxgl-touch-drag-pan', 'maplibregl-touch-drag-pan'); + } + _resizeCanvas(width: number, height: number, pixelRatio: number) { // Request the required canvas size taking the pixelratio into account. this._canvas.width = pixelRatio * width; @@ -2439,6 +2490,17 @@ class Map extends Camera { return false; } + _onCooperativeGesture(event: any, metaPress, touches) { + if (!metaPress && touches < 2) { + // Alert user how to scroll/pan + this._cooperativeGesturesScreen.classList.add('maplibregl-show'); + setTimeout(() => { + this._cooperativeGesturesScreen.classList.remove('maplibregl-show'); + }, 100); + } + return false; + } + /** * Returns a Boolean indicating whether the map is fully loaded. * @@ -2684,6 +2746,9 @@ class Map extends Camera { this._canvas.removeEventListener('webglcontextlost', this._contextLost, false); DOM.remove(this._canvasContainer); DOM.remove(this._controlContainer); + if (this._cooperativeGestures) { + DOM.remove(this._cooperativeGesturesScreen); + } this._container.classList.remove('maplibregl-map', 'mapboxgl-map'); PerformanceUtils.clearMetrics();