leaflet
Version:
JavaScript library for mobile-friendly interactive maps
247 lines (190 loc) • 7.83 kB
JavaScript
import {Evented} from '../core/Events.js';
import * as DomEvent from './DomEvent.js';
import {getPointers} from './DomEventUtil.js';
import * as DomUtil from './DomUtil.js';
import * as Util from '../core/Util.js';
import {Point} from '../geometry/Point.js';
/** @import {Defaults, DragEndEvent, DraggableDefaults, DraggableEventMap, DraggableOptions, LeafletEvent} from '../types.js' */
const defaultOptions = {
/**
* The max number of pixels a user can shift the pointer during a click for
* it to be considered a valid click (as opposed to a pointer drag).
*/
clickTolerance: 3
};
/** @typedef {typeof defaultOptions} DraggableDefaultsValues */
/**
* @typedef {Object} DraggableEvents
* @property {LeafletEvent} down Fired when a drag is about to start.
* @property {LeafletEvent} dragstart Fired when a drag starts
* @property {LeafletEvent} predrag Fired continuously during dragging *before* each corresponding update of the
* element's position.
* @property {LeafletEvent} drag Fired continuously during dragging.
* @property {DragEndEvent} dragend Fired when the drag ends.
*/
/**
* @typedef {Object} DragEndEventProps
* @property {number} distance The distance in pixels the draggable element was moved by.
* @property {boolean} [noInertia] Whether the drag should end without inertia (set when the
* draggable is disabled mid-drag).
*/
/**
* A class for making DOM elements draggable. Used internally
* for map and marker dragging. Works on any DOM element
* @example
* ```js
* const draggable = new Draggable(elementToDrag);
* draggable.enable();
* ```
*
* @extends {Evented<DraggableOptions & DraggableDefaults, DraggableEventMap>}
*/
export class Draggable extends Evented {
static initHooks = true;
/** @type {Defaults<DraggableOptions>} */
static defaultOptions = defaultOptions;
// the draggable being dragged, so that only one is dragged at a time
/** @type {Draggable | false} */
static _dragging = false;
/**
* Creates a `Draggable` object for moving `element` when you start dragging
* the `dragStartTarget` element (equals `element` itself by default).
* @param {HTMLElement} element
* @param {HTMLElement} [dragStartTarget]
* @param {Partial<DraggableOptions>} [options]
*/
constructor(element, dragStartTarget, options) {
if (typeof options === 'boolean') {
throw new Error('Draggable: the preventOutline argument is removed in Leaflet 2. Pass options as the third argument.');
}
super();
Util.setOptions(this, options);
this._element = element;
this._dragStartTarget = dragStartTarget ?? element;
this.callInitHooks();
}
/** Enables the dragging ability */
enable() {
if (this._enabled) { return; }
DomEvent.on(this._dragStartTarget, 'pointerdown', this._onDown, this);
this._enabled = true;
}
/** Disables the dragging ability */
disable() {
if (!this._enabled) { return; }
// If we're currently dragging this draggable,
// disabling it counts as first ending the drag.
if (Draggable._dragging === this) {
this.finishDrag(true);
}
DomEvent.off(this._dragStartTarget, 'pointerdown', this._onDown, this);
this._enabled = false;
this._moved = false;
}
/** @param {PointerEvent} e */
_onDown(e) {
this._moved = false;
if (this._element.classList.contains('leaflet-zoom-anim')) { return; }
const pointers = getPointers().length;
if (pointers > 1) {
// Finish dragging to avoid conflict with touchZoom
if (Draggable._dragging === this) {
this.finishDrag();
}
return;
}
if (Draggable._dragging || e.shiftKey || (e.button !== 0 && e.pointerType !== 'touch')) { return; }
Draggable._dragging = this; // Prevent dragging multiple objects at once.
DomUtil.disableImageDrag();
if (this._moving) { return; }
// outside a map, nothing tracks pointers or captures them, so capture this one to keep its moves coming
if (!pointers && e.isTrusted) { /** @type {Element} */ (e.target).setPointerCapture(e.pointerId); }
this.fire('down');
const sizedParent = DomUtil.getSizedParentNode(this._element);
/** @type {Point} */
this._startPoint = new Point(e.clientX, e.clientY);
/** @type {Point} */
this._startPos = DomUtil.getPosition(this._element);
// Cache the scale, so that we can continuously compensate for it during drag (_onMove).
/** @type {{x: number, y: number}} */
this._parentScale = DomUtil.getScale(sizedParent);
DomEvent.on(this._dragStartTarget, 'pointermove', this._onMove, this);
DomEvent.on(this._dragStartTarget, 'pointerup pointercancel lostpointercapture', this._onUp, this);
}
/** @param {PointerEvent} e */
_onMove(e) {
if (getPointers().length > 1) {
this._moved = true;
return;
}
const offset = new Point(e.clientX, e.clientY)._subtract(this._startPoint);
// once the drag has started, the pointer must be followed even when it comes back near the start point
const dist = Math.abs(offset.x) + Math.abs(offset.y);
if (!this._moved && (!dist || dist < this.options.clickTolerance)) { return; }
// We assume that the parent container's position, border and scale do not change for the duration of the drag.
// Therefore there is no need to account for the position and border (they are eliminated by the subtraction)
// and we can use the cached value for the scale.
offset.x /= this._parentScale.x;
offset.y /= this._parentScale.y;
if (e.cancelable) {
e.preventDefault();
}
if (!this._moved) {
this.fire('dragstart');
this._moved = true;
// on the map container rather than the body, so that toggling it doesn't restyle the whole page
this._draggingContainer = this._element.closest('.leaflet-container') ?? this._element;
this._draggingContainer.classList.add('leaflet-dragging');
/** @type {Element | null} */
this._lastTarget = /** @type {Element} */ (e.target);
this._lastTarget.classList.add('leaflet-drag-target');
}
/** @type {Point} */
this._newPos = this._startPos.add(offset);
this._moving = true;
this._lastEvent = e;
this._updatePosition();
}
_updatePosition() {
const e = {originalEvent: this._lastEvent};
this.fire('predrag', e);
// pointer events can have fractional coordinates; snap them to device pixels, since an edge in the middle of
// one is drawn partly transparent on both sides and shows a seam between tiles
const dpr = window.devicePixelRatio;
this._newPos = this._newPos.multiplyBy(dpr)._round()._divideBy(dpr);
DomUtil.setPosition(this._element, this._newPos);
this.fire('drag', e);
}
/** @param {PointerEvent} e */
_onUp(e) {
// releasing the button outside the window may fire no pointerup, only a lostpointercapture with no buttons;
// capture lost while the button is still held (e.g. the pressed element was removed) shouldn't end the drag
if (e.type === 'lostpointercapture' && e.buttons) { return; }
this.finishDrag();
}
/**
* Ends the current drag, firing `dragend` if the element was moved. `noInertia` is passed on in the event, so
* that the map doesn't keep panning after a drag ended this way.
* @param {boolean} [noInertia]
*/
finishDrag(noInertia) {
this._draggingContainer?.classList.remove('leaflet-dragging');
this._draggingContainer = null;
if (this._lastTarget) {
this._lastTarget.classList.remove('leaflet-drag-target');
this._lastTarget = null;
}
DomEvent.off(this._dragStartTarget, 'pointermove', this._onMove, this);
DomEvent.off(this._dragStartTarget, 'pointerup pointercancel lostpointercapture', this._onUp, this);
DomUtil.enableImageDrag();
const fireDragend = this._moved && this._moving;
this._moving = false;
Draggable._dragging = false;
if (fireDragend) {
this.fire('dragend', {
noInertia,
distance: this._newPos.distanceTo(this._startPos)
});
}
}
}