leaflet
Version:
JavaScript library for mobile-friendly interactive maps
478 lines (425 loc) • 13 kB
JavaScript
import {Class} from './Class.js';
import * as Util from './Util.js';
/** @import {LeafletEvent} from '../types.js' */
/**
* A listener for the event type `K` of an object with the event map `E` that fired it as `T`. Events that aren't
* in the map (space-separated types, events of plugins) get a plain event object. An indexed access rather than a
* conditional type, so that `Evented<O, E>` stays covariant in `E` (a `Marker` is assignable to `Layer`); into
* `Pick<E, …>` rather than `E`, so that `this` of a generic subclass is too (`map.addLayer(this)` in `Popup`).
* @template {string} K
* @template {object} E
* @template [T=Evented]
* @typedef {(e: (Pick<E, K & keyof E> & {[type: string]: LeafletEvent & {[key: string]: any}})[K] & {target: T}) => void} Listener
*/
/**
* An object with event types as keys and listeners as values, as taken by [`on`](#evented-on). `M` is the object
* itself, inferred at the call, so that each listener is checked against the event type of its key.
* @template M
* @template {object} E
* @template [T=Evented]
* @typedef {{[K in keyof M]: Listener<K & string, E, T>}} ListenerMap
*/
/**
* A listener for an event without a known event object type.
* @typedef {(event: LeafletEvent & {[key: string]: any}) => void} LeafletEventHandlerFn
*/
const warnedEvents = new Set();
/**
* @typedef {Object} LeafletEventProps
* @property {string} type The event type (e.g. `'click'`).
* @property {Evented} target The object that fired the event. For propagated events, the last
* object in the propagation chain that fired the event.
* @property {Evented} sourceTarget The object that originally fired the event. For
* non-propagated events, this will be the same as the `target`.
* @property {Evented} [propagatedFrom] For propagated events, the last object that propagated
* the event to its event parent.
*/
/**
* A set of methods shared between event-powered classes (like `Map` and `Marker`). Generally, events allow
* you to execute some function when something happens with an object (e.g. the user clicks on the map,
* causing the map to fire `'click'` event).
*
* @example
* ```js
* map.on('click', function(e) {
* alert(e.latlng);
* } );
* ```
*
* Leaflet deals with event listeners by reference, so if you want to add a listener and then remove it, define
* it as a function:
*
* ```js
* function onClick(e) { ... }
*
* map.on('click', onClick);
* map.off('click', onClick);
* ```
*
* @template {object} [O=object]
* @template {object} [E={}]
* @extends {Class<O>}
*/
export class Evented extends Class {
static initHooks = true;
constructor() {
super();
/** @type {{[type: string]: Array<{fn: Function, ctx: any, once?: boolean}>} | undefined} */
this._events = undefined;
/** @type {{[id: number]: Evented} | undefined} */
this._eventParents = undefined;
this._firingCount = 0;
this.callInitHooks();
}
/**
* Adds a listener function (`fn`) to a particular event type of the object. 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 {K} type
* @param {Listener<K, E, this>} fn
* @param {unknown} [context]
* @returns {this}
*/
/**
* Adds a set of type/listener pairs, e.g. `{click: onClick, pointermove: onPointerMove}`
* @template M
* @overload
* @param {ListenerMap<M, E, this>} eventMap
* @param {unknown} [context]
* @returns {this}
*/
/**
* @param {string | object} types
* @param {any} [fn]
* @param {unknown} [context]
*/
on(types, fn, context) {
// types can be a map of types/handlers
if (typeof types === 'object') {
for (const [type, f] of Object.entries(types)) {
// we don't process space-separated events here for performance;
// it's a hot path since Layer uses the on(obj) syntax
this._on(type, f, fn);
}
} else {
// types can be a string of space-separated words
for (const type of Util.splitWords(types)) {
this._on(type, fn, context);
}
}
return this;
}
/**
* Removes a previously added listener function. If no function is specified, it will remove all the
* listeners of that particular event from the object. 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 {K} type
* @param {Listener<K, E, this>} [fn]
* @param {unknown} [context]
* @returns {this}
*/
/**
* Removes a set of type/listener pairs.
* @template M
* @overload
* @param {ListenerMap<M, E, this>} eventMap
* @param {unknown} [context]
* @returns {this}
*/
/**
* Removes all listeners to all events on the object. This includes implicitly attached events.
* @overload
* @returns {this}
*/
/**
* @param {string | object} [types]
* @param {any} [fn]
* @param {unknown} [context]
*/
off(types, fn, context) {
if (!arguments.length) {
// clear all listeners if called without arguments
this._events = undefined;
} else if (typeof types === 'object') {
for (const [type, f] of Object.entries(types)) {
this._off(type, f, fn);
}
} else {
const removeAll = arguments.length === 1;
for (const type of Util.splitWords(/** @type {string} */ (types))) {
if (removeAll) {
this._off(type);
} else {
this._off(type, fn, context);
}
}
}
return this;
}
/** @type {Record<string, string>} */
static __REMOVED_EVENTS = {
mousedown: 'pointerdown',
mouseup: 'pointerup',
mouseover: 'pointerover',
mouseout: 'pointerout',
mousemove: 'pointermove',
keypress: 'keydown'
};
// attach listener (without syntactic sugar now)
/**
* @param {string} type
* @param {unknown} fn
* @param {unknown} [context]
* @param {boolean} [_once]
*/
_on(type, fn, context, _once) {
// To be removed in leaflet 3
if (Object.hasOwn(Evented.__REMOVED_EVENTS, type) && !warnedEvents.has(type)) {
warnedEvents.add(type);
console.error(`The event ${type} has been removed in Leaflet 2. Use ${Evented.__REMOVED_EVENTS[type]} instead.`);
}
if (typeof fn !== 'function') {
console.warn(`wrong listener type: ${typeof fn}`);
return;
}
// check if fn already there
if (this._listens(type, fn, context) !== false) {
return;
}
if (context === this) {
// Less memory footprint.
context = undefined;
}
/** @type {{fn: Function, ctx: any, once?: boolean}} */
const newListener = {fn, ctx: context};
if (_once) {
newListener.once = true;
}
this._events ??= {};
this._events[type] ??= [];
this._events[type].push(newListener);
}
/**
* @param {string} type
* @param {unknown} [fn]
* @param {unknown} [context]
*/
_off(type, fn, context) {
if (!this._events) {
return;
}
let listeners = this._events[type];
if (!listeners) {
return;
}
if (arguments.length === 1) { // remove all
if (this._firingCount) {
// Set all removed listeners to noop
// so they are not called if remove happens in fire
for (const listener of listeners) {
listener.fn = Util.falseFn;
}
}
// clear all listeners for a type if function isn't specified
delete this._events[type];
return;
}
if (typeof fn !== 'function') {
console.warn(`wrong listener type: ${typeof fn}`);
return;
}
// find fn and remove it
const index = /** @type {number | false} */ (this._listens(type, fn, context));
if (index !== false) {
const listener = listeners[index];
if (this._firingCount) {
// set the removed listener to noop so that's not called if remove happens in fire
listener.fn = Util.falseFn;
/* copy array in case events are being fired */
this._events[type] = listeners = listeners.slice();
}
listeners.splice(index, 1);
}
}
/**
* Fires an event of the specified type. You can optionally provide a data object — the first argument of the
* listener function will contain its properties. The event can optionally be propagated to event parents.
* @param {string} type
* @param {object} [data]
* @param {boolean} [propagate]
*/
fire(type, data, propagate) {
if (!this.listens(type, propagate)) { return this; }
/** @type {LeafletEvent} */
const event = {
...data,
type,
target: this,
sourceTarget: /** @type {{sourceTarget?: Evented}} */ (data)?.sourceTarget || this
};
if (this._events) {
const listeners = this._events[type];
if (listeners) {
// stays NaN-safe for classes that include Evented.prototype without running its constructor
this._firingCount = (this._firingCount + 1) || 1;
for (const l of listeners) {
// off overwrites l.fn, so we need to copy fn to a variable
const fn = l.fn;
if (l.once) {
this.off(type, /** @type {any} */ (fn), l.ctx);
}
fn.call(l.ctx || this, event);
}
this._firingCount--;
}
}
if (propagate) {
// propagate the event to parents (set with addEventParent)
this._propagateEvent(event);
}
return this;
}
/**
* Returns `true` if a particular event type has any listeners attached to it. The verification can
* optionally be propagated, it will return `true` if parents have the listener attached to it.
* @overload
* @param {string} type
* @param {boolean} [propagate]
* @returns {boolean}
*/
/**
* Returns `true` if the given listener function (with the given context) is attached to a particular event
* type, optionally checking event parents too.
* @template {string} K
* @overload
* @param {K} type
* @param {Listener<K, E, this>} fn
* @param {unknown} [context]
* @param {boolean} [propagate]
* @returns {boolean}
*/
/**
* @param {string} type
* @param {unknown} [fn]
* @param {unknown} [context]
* @param {boolean} [propagate]
*/
listens(type, fn, context, propagate) {
if (typeof type !== 'string') {
console.warn('"string" type argument expected');
}
// we don't overwrite the input `fn` value, because we need to use it for propagation
let _fn = fn;
if (typeof fn !== 'function') {
propagate = !!fn;
_fn = undefined;
context = undefined;
}
if (this._events?.[type]?.length) {
if (this._listens(type, _fn, context) !== false) {
return true;
}
}
if (propagate) {
// also check parents for listeners if event propagates
for (const p of Object.values(this._eventParents ?? {})) {
if (p.listens(type, /** @type {any} */ (fn), context, propagate)) {
return true;
}
}
}
return false;
}
// returns the index (number) or false
/**
* @param {string} type
* @param {unknown} [fn]
* @param {unknown} [context]
*/
_listens(type, fn, context) {
if (!this._events) {
return false;
}
const listeners = this._events[type] ?? [];
if (!fn) {
return !!listeners.length;
}
if (context === this) {
// Less memory footprint.
context = undefined;
}
const index = listeners.findIndex(l => l.fn === fn && l.ctx === context);
return index === -1 ? false : index;
}
/**
* Behaves as [`on(…)`](#evented-on), except the listener will only get fired once and then removed.
* @template {string} K
* @overload
* @param {K} type
* @param {Listener<K, E, this>} fn
* @param {unknown} [context]
* @returns {this}
*/
/**
* @template M
* @overload
* @param {ListenerMap<M, E, this>} eventMap
* @param {unknown} [context]
* @returns {this}
*/
/**
* @param {string | object} types
* @param {any} [fn]
* @param {unknown} [context]
*/
once(types, fn, context) {
// types can be a map of types/handlers
if (typeof types === 'object') {
for (const [type, f] of Object.entries(types)) {
// we don't process space-separated events here for performance;
// it's a hot path since Layer uses the on(obj) syntax
this._on(type, f, fn, true);
}
} else {
// types can be a string of space-separated words
for (const type of Util.splitWords(types)) {
this._on(type, fn, context, true);
}
}
return this;
}
/**
* Adds an event parent - an `Evented` that will receive propagated events
* @param {Evented} obj
*/
addEventParent(obj) {
this._eventParents ??= {};
this._eventParents[Util.stamp(obj)] = obj;
return this;
}
/**
* Removes an event parent, so it will stop receiving propagated events
* @param {Evented} obj
*/
removeEventParent(obj) {
if (this._eventParents) {
delete this._eventParents[Util.stamp(obj)];
}
return this;
}
/** @param {LeafletEvent} e */
_propagateEvent(e) {
for (const p of Object.values(this._eventParents ?? {})) {
// `layer` is the 1.x name of `propagatedFrom`, left out of the types to steer new code to the new one
p.fire(e.type, {
layer: e.target,
propagatedFrom: e.target,
...e
}, true);
}
}
};