leaflet
Version:
JavaScript library for mobile-friendly interactive maps
436 lines (385 loc) • 12.7 kB
JavaScript
import {Point} from '../geometry/Point.js';
import * as Util from '../core/Util.js';
import {getScale} from './DomUtil.js';
import {stoppedEvents, clickDisabledElements} from './DomEventUtil.js';
/**
* Utility functions to work with the
* [DOM events](https://developer.mozilla.org/docs/Web/API/Event), used by Leaflet internally.
* @module DomEvent
*/
/**
* The event object type for the DOM event type `K`, or for any of them if `K` holds space-separated types. Types
* that aren't in `HTMLElementEventMap` (custom events) get a plain `Event`.
* @template {string} K
* @typedef {K extends `${infer A} ${infer B}` ? DomEventOf<A> | DomEventOf<B> :
* K extends keyof HTMLElementEventMap ? HTMLElementEventMap[K] : Event} DomEventOf
*/
/**
* A listener for the DOM event type `K` (or several space-separated types).
* @template {string} K
* @typedef {(e: DomEventOf<K>) => void} DomEventListener
*/
/**
* An object with DOM event types as keys and listeners as values, as taken by [`DomEvent.on`](#domevent-on).
* @typedef {{[K in keyof HTMLElementEventMap]?: (e: HTMLElementEventMap[K]) => void} &
* {[type: string]: (e: any) => void}} DomEventListenerMap
*/
// Inspired by John Resig, Dean Edwards and YUI addEvent implementations.
/**
* Adds a listener function (`fn`) to a particular DOM event type of the element `el`. You
* can optionally specify the context of the listener (object the `this` keyword will point
* to). You can also pass several space-separated types (e.g. `'click dblclick'`).
* @template {string} K
* @overload
* @param {EventTarget} el
* @param {K} types
* @param {DomEventListener<K>} fn
* @param {unknown} [context]
* @returns {void}
*/
/**
* Adds a set of type/listener pairs, e.g. `{click: onClick, pointermove: onPointerMove}`
* @overload
* @param {EventTarget} el
* @param {DomEventListenerMap} eventMap
* @param {unknown} [context]
* @returns {void}
*/
/**
* @param {any} obj
* @param {any} types
* @param {any} [fn]
* @param {unknown} [context]
*/
export function on(obj, types, fn, context) {
if (types && typeof types === 'object') {
for (const [type, listener] of Object.entries(types)) {
addOne(obj, type, listener, fn);
}
} else {
for (const type of Util.splitWords(types)) {
addOne(obj, type, fn, context);
}
}
}
// the wrapped listeners added to each target, by type + listener id + context id
/** @type {WeakMap<EventTarget, Map<string, any>>} */
const handlers = new WeakMap();
/**
* Removes a previously added listener function. Note that if you passed a custom context
* to on, you must pass the same context to `off` in order to remove the listener.
* @template {string} K
* @overload
* @param {EventTarget} el
* @param {K} types
* @param {DomEventListener<K>} fn
* @param {unknown} [context]
* @returns {void}
*/
/**
* Removes a set of type/listener pairs, e.g. `{click: onClick, pointermove: onPointerMove}`
* @overload
* @param {EventTarget} el
* @param {DomEventListenerMap} eventMap
* @param {unknown} [context]
* @returns {void}
*/
/**
* Removes all previously added listeners of given types.
* @overload
* @param {EventTarget} el
* @param {string} types
* @returns {void}
*/
/**
* Removes all previously added listeners from given element.
* @overload
* @param {EventTarget} el
* @returns {void}
*/
/**
* @param {any} obj
* @param {any} [types]
* @param {any} [fn]
* @param {unknown} [context]
*/
export function off(obj, types, fn, context) {
if (arguments.length === 1) {
batchRemove(obj);
handlers.delete(obj);
} else if (types && typeof types === 'object') {
for (const [type, listener] of Object.entries(types)) {
removeOne(obj, type, listener, fn);
}
} else {
types = Util.splitWords(types);
if (arguments.length === 2) {
batchRemove(obj, type => types.includes(type));
} else {
for (const type of types) {
removeOne(obj, type, fn, context);
}
}
}
}
const DIGIT_RE = /\d/;
/**
* @param {EventTarget} obj
* @param {(type: string) => boolean} [filterFn]
*/
function batchRemove(obj, filterFn) {
for (const id of handlers.get(obj)?.keys() ?? []) {
const type = id.split(DIGIT_RE)[0];
if (!filterFn || filterFn(type)) {
removeOne(obj, type, null, null, id);
}
}
}
/**
* @param {EventTarget} obj
* @param {string} type
* @param {any} fn
* @param {unknown} [context]
*/
function addOne(obj, type, fn, context) {
const id = type + Util.stamp(fn) + (context ? `_${Util.stamp(context)}` : '');
let objHandlers = handlers.get(obj);
if (objHandlers?.has(id)) { return; }
/** @type {EventListener | ReturnType<typeof addDoubleTapListener>} */
let handler = function (e) {
return fn.call(context || obj, e);
};
if (type === 'dblclick') {
handler = addDoubleTapListener(/** @type {HTMLElement} */ (obj), handler);
} else {
obj.addEventListener(type, handler, type === 'wheel' ? {passive: false} : false);
}
if (!objHandlers) {
objHandlers = new Map();
handlers.set(obj, objHandlers);
}
objHandlers.set(id, handler);
}
/**
* @param {EventTarget} obj
* @param {string} type
* @param {any} fn
* @param {unknown} context
* @param {string} [id]
*/
function removeOne(obj, type, fn, context, id) {
id ??= type + Util.stamp(fn) + (context ? `_${Util.stamp(context)}` : '');
const objHandlers = handlers.get(obj);
const handler = objHandlers?.get(id);
if (!objHandlers || !handler) { return; }
if (type === 'dblclick') {
removeDoubleTapListener(/** @type {HTMLElement} */ (obj), handler);
} else {
obj.removeEventListener(type, handler, false);
}
objHandlers.delete(id);
}
/**
* Stop the given event from propagation to parent elements. Used inside the listener functions:
* ```js
* DomEvent.on(div, 'click', DomEvent.stopPropagation);
* ```
* Given a Leaflet event, it stops the map from handling the original DOM event.
* @param {Event | {originalEvent: Event}} e
*/
export function stopPropagation(e) {
if ('stopPropagation' in e) {
e.stopPropagation();
} else if (e.originalEvent) { // In case of Leaflet event.
stoppedEvents.add(e.originalEvent);
}
}
/**
* Adds `stopPropagation` to the element's `'wheel'` events (plus browser variants).
* @param {HTMLElement} el
*/
export function disableScrollPropagation(el) {
addOne(el, 'wheel', stopPropagation);
}
/**
* Adds `stopPropagation` to the element's `'dblclick'`, `'contextmenu'` and `'pointerdown'`
* events (plus browser variants). For `'click'` events, prevents the map from handling the
* click while still allowing the native DOM click event to propagate normally.
* @param {HTMLElement} el
*/
export function disableClickPropagation(el) {
on(el, 'pointerdown dblclick contextmenu', stopPropagation);
clickDisabledElements.add(el);
}
/**
* Prevents the default action of the DOM Event `e` from happening (such as following
* a link in the href of the a element, or doing a POST request with page reload
* when a `<form>` is submitted). Use it inside listener functions.
* @param {Event} e
*/
export function preventDefault(e) {
e.preventDefault?.();
}
/**
* Does `stopPropagation` and `preventDefault` at the same time. Given a Leaflet event, it only stops propagation:
* the default action of the original DOM event still happens.
* @param {Event | {originalEvent: Event}} e
*/
export function stop(e) {
if ('preventDefault' in e) { preventDefault(e); }
stopPropagation(e);
}
/**
* Gets normalized pointer position from a DOM event relative to the `container`
* (border excluded) or to the whole page if not specified.
* @param {MouseEvent} e
* @param {HTMLElement} [container]
*/
export function getPointerPosition(e, container) {
if (!container) {
return new Point(e.clientX, e.clientY);
}
const scale = getScale(container),
offset = scale.boundingClientRect; // left and top values are in page scale (like the event clientX/Y)
return new Point(
// offset.left/top values are in page scale (like clientX/Y),
// whereas clientLeft/Top (border width) values are the original values (before CSS scale applies).
(e.clientX - offset.left) / scale.x - container.clientLeft,
(e.clientY - offset.top) / scale.y - container.clientTop
);
}
/**
* Gets normalized wheel delta from a wheel DOM event, in vertical pixels scrolled (negative if scrolling down).
* Events from pointing devices without precise scrolling are mapped to a best guess of 60 pixels.
* @param {WheelEvent} e
*/
export function getWheelDelta(e) {
// a wheel notch gives 100-120px in most browsers, so halving it fits the default 60px per zoom level (see #7403)
return (e.deltaY && e.deltaMode === 0) ? -e.deltaY / (window.devicePixelRatio * 2) : // Pixels
(e.deltaY && e.deltaMode === 1) ? -e.deltaY * 20 : // Lines
(e.deltaY && e.deltaMode === 2) ? -e.deltaY * 60 : // Pages
(e.deltaX || e.deltaZ) ? 0 : // Skip horizontal/depth wheel events
0;
}
// Double tap support for mobile browsers. Most browsers fire native dblclick, with only a few exceptions
// (see https://github.com/Leaflet/Leaflet/issues/7012#issuecomment-595087386).
/** @param {MouseEvent & {region?: string | null}} ev */
function makeDblclick(ev) {
const init = {
// EventInit
bubbles: ev.bubbles,
cancelable: ev.cancelable,
composed: ev.composed,
// UIEventInit
detail: 2,
view: ev.view,
// mouseEventInit
screenX: ev.screenX,
screenY: ev.screenY,
clientX: ev.clientX,
clientY: ev.clientY,
ctrlKey: ev.ctrlKey,
shiftKey: ev.shiftKey,
altKey: ev.altKey,
metaKey: ev.metaKey,
button: ev.button,
buttons: ev.buttons,
relatedTarget: ev.relatedTarget,
region: ev.region,
};
let newEvent;
// The `click` event received should be a PointerEvent - but some
// Firefox versions still use MouseEvent.
if (ev instanceof PointerEvent) {
newEvent = new PointerEvent('dblclick', {
...init,
pointerId: ev.pointerId,
width: ev.width,
height: ev.height,
pressure: ev.pressure,
tangentialPressure: ev.tangentialPressure,
tiltX: ev.tiltX,
tiltY: ev.tiltY,
twist: ev.twist,
pointerType: ev.pointerType,
isPrimary: ev.isPrimary,
});
} else {
newEvent = new MouseEvent('dblclick', init);
}
return newEvent;
}
const delay = 200;
/**
* @param {HTMLElement} obj
* @param {EventListener} handler
*/
function addDoubleTapListener(obj, handler) {
// Most browsers handle double tap natively
obj.addEventListener('dblclick', handler);
// On some platforms the browser doesn't fire native dblclicks for touch events.
// It seems that in all such cases `detail` property of `click` event is always `1`.
// So here we rely on that fact to avoid excessive 'dblclick' simulation when not needed.
let last = 0,
/** @type {number} */
detail,
/** @type {string} */
pointerType;
// iOS Safari reports `pointerType: 'mouse'` on clicks from touch (WebKit bug 282988), so we take it from the
// pointerdown instead; capturing so that it's seen even if a child (e.g. a control) stops its propagation.
/** @param {PointerEvent} ev */
function onPointerDown(ev) {
pointerType = ev.pointerType;
}
/** @param {MouseEvent & {sourceCapabilities?: {firesTouchEvents: boolean} | null}} ev */
function simDblclick(ev) {
if (ev.detail !== 1) {
detail = ev.detail; // keep in sync to avoid false dblclick in some cases
return;
}
if (pointerType === 'mouse' ||
(ev.sourceCapabilities && !ev.sourceCapabilities.firesTouchEvents)) {
return;
}
// When clicking on an <input>, the browser generates a click on its
// <label> (and vice versa) triggering two clicks in quick succession.
// This ignores clicks on elements which are a label with a 'for'
// attribute (or children of such a label), but not children of
// a <input>.
const path = ev.composedPath();
if (path.some(el => el instanceof HTMLLabelElement && el.htmlFor) &&
!path.some(el => (
el instanceof HTMLInputElement ||
el instanceof HTMLSelectElement
))
) {
return;
}
const now = Date.now();
if (now - last <= delay) {
detail++;
if (detail === 2) {
/** @type {EventTarget} */ (ev.target).dispatchEvent(makeDblclick(ev));
}
} else {
detail = 1;
}
last = now;
}
obj.addEventListener('pointerdown', onPointerDown, true);
obj.addEventListener('click', simDblclick);
return {
dblclick: handler,
onPointerDown,
simDblclick
};
}
/**
* @param {HTMLElement} obj
* @param {ReturnType<typeof addDoubleTapListener>} handlers
*/
function removeDoubleTapListener(obj, handlers) {
obj.removeEventListener('dblclick', handlers.dblclick);
obj.removeEventListener('pointerdown', handlers.onPointerDown, true);
obj.removeEventListener('click', handlers.simDblclick);
}