diff --git a/src/ui/events.ts b/src/ui/events.ts index 19d41752d1b..6d92613a29b 100644 --- a/src/ui/events.ts +++ b/src/ui/events.ts @@ -12,10 +12,12 @@ import type {SourceSpecification} from '../style-spec/types'; export type MapMouseEventType = | 'mousedown' | 'mouseup' + | 'mouseupWindow' | 'preclick' | 'click' | 'dblclick' | 'mousemove' + | 'mousemoveWindow' | 'mouseover' | 'mouseenter' | 'mouseleave' @@ -530,6 +532,32 @@ export type MapEvents = { */ 'mouseup': MapMouseEvent; + /** + * Fired when a pointing device (usually a mouse) is released anywhere on the page during a + * gesture that started inside the map. Unlike `mouseup`, this fires when the release happens + * outside the map's canvas container as well, allowing consumers to terminate gestures cleanly + * when the cursor has left the map. + * + * @event mouseupWindow + * @memberof Map + * @instance + * @type {MapMouseEvent} + */ + 'mouseupWindow': MapMouseEvent; + + /** + * Fired when a pointing device (usually a mouse) is moved anywhere on the page during a + * gesture that started inside the map. Unlike `mousemove`, this fires while the cursor is + * outside the map's canvas container as well, allowing consumers to track movement during + * gestures that extend outside the map. + * + * @event mousemoveWindow + * @memberof Map + * @instance + * @type {MapMouseEvent} + */ + 'mousemoveWindow': MapMouseEvent; + /** * Fired when a pointing device (usually a mouse) is moved within the map. * As you move the cursor across a web page containing a map, diff --git a/src/ui/handler/map_event.ts b/src/ui/handler/map_event.ts index b19841c3aa1..6772a1bfda2 100644 --- a/src/ui/handler/map_event.ts +++ b/src/ui/handler/map_event.ts @@ -40,6 +40,10 @@ export class MapEventHandler implements Handler { this._map.fire(new MapMouseEvent(e.type as 'mouseup', this._map, e)); } + mouseupWindow(e: MouseEvent) { + this._map.fire(new MapMouseEvent('mouseupWindow', this._map, e)); + } + preclick(e: MouseEvent) { const synth = new MouseEvent('preclick', e); this._map.fire(new MapMouseEvent(synth.type as 'preclick', this._map, synth)); @@ -126,6 +130,10 @@ export class BlockableMapEventHandler { this._map.fire(new MapMouseEvent(e.type as 'mousemove', this._map, e)); } + mousemoveWindow(e: MouseEvent) { + this._map.fire(new MapMouseEvent('mousemoveWindow', this._map, e)); + } + mousedown() { this._delayContextMenu = true; } diff --git a/src/ui/marker.ts b/src/ui/marker.ts index c34e3683a6c..49857df64a8 100644 --- a/src/ui/marker.ts +++ b/src/ui/marker.ts @@ -286,8 +286,10 @@ export default class Marker extends Evented { map.off('touchstart', this._addDragHandler); map.off('mouseup', this._onUp); map.off('touchend', this._onUp); + map.off('mouseupWindow', this._onUp); map.off('mousemove', this._onMove); map.off('touchmove', this._onMove); + map.off('mousemoveWindow', this._onMove); map.off('remove', this._clearFadeTimer); map._removeMarker(this); this._map = undefined; @@ -842,8 +844,10 @@ export default class Marker extends Evented { this._state = 'pending'; map.on('mousemove', this._onMove); map.on('touchmove', this._onMove); + map.on('mousemoveWindow', this._onMove); map.once('mouseup', this._onUp); map.once('touchend', this._onUp); + map.once('mouseupWindow', this._onUp); } } diff --git a/test/unit/ui/marker.test.ts b/test/unit/ui/marker.test.ts index 5e9a33df6ca..ece69492257 100644 --- a/test/unit/ui/marker.test.ts +++ b/test/unit/ui/marker.test.ts @@ -633,6 +633,27 @@ test('Marker with draggable:true fires dragstart, drag, and dragend events at ap map.remove(); }); +test('Marker with draggable:true completes drag when the pointer is released outside the map canvas', () => { + const map = createMap(); + const marker = new Marker({draggable: true}) + .setLngLat([0, 0]) + .addTo(map); + const el = marker.getElement(); + + const dragend = vi.fn(); + marker.on('dragend', dragend); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + simulate.mousedown(el, {clientX: 0, clientY: 0}); + document.dispatchEvent(new MouseEvent('mousemove', {bubbles: true, clientX: 10, clientY: 0})); + document.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, clientX: 10, clientY: 0})); + + expect(dragend).toHaveBeenCalledTimes(1); + expect(el.style.pointerEvents).toEqual('auto'); + + map.remove(); +}); + test('Marker with draggable:true fires dragstart, drag, and dragend events at appropriate times in response to mouse-triggered drag with marker-specific clickTolerance', () => { const map = createMap(); const marker = new Marker({draggable: true, clickTolerance: 4})