animejs
Version:
JavaScript animation engine
1,237 lines (1,146 loc) • 44.5 kB
JavaScript
/**
* Anime.js - draggable - ESM
* @version v4.3.6
* @license MIT
* @copyright 2026 - Julian Garnier
*/
import { globals, scope } from '../core/globals.js';
import { doc, win, noop, maxValue, compositionTypes } from '../core/consts.js';
import { parseTargets } from '../core/targets.js';
import { isUnd, isObj, isArr, now, atan2, round, max, snap, clamp, isNum, abs, sqrt, cos, sin, isFnc } from '../core/helpers.js';
import { setValue } from '../core/values.js';
import { mapRange } from '../utils/number.js';
import { Timer } from '../timer/timer.js';
import { JSAnimation } from '../animation/animation.js';
import { removeTargetsFromRenderable } from '../animation/composition.js';
import { Animatable } from '../animatable/animatable.js';
import { parseEase, eases } from '../easings/eases/parser.js';
import { spring } from '../easings/spring/index.js';
import { get, set } from '../utils/target.js';
/**
* @import {
* DOMTarget,
* DOMTargetSelector,
* DraggableCursorParams,
* DraggableDragThresholdParams,
* TargetsParam,
* DraggableParams,
* EasingFunction,
* Callback,
* AnimatableParams,
* DraggableAxisParam,
* AnimatableObject,
* EasingParam,
* } from '../types/index.js'
*/
/**
* @import {
* Spring,
* } from '../easings/spring/index.js'
*/
/**
* @param {Event} e
*/
const preventDefault = e => {
if (e.cancelable) e.preventDefault();
};
class DOMProxy {
/** @param {Object} el */
constructor(el) {
this.el = el;
this.zIndex = 0;
this.parentElement = null;
this.classList = {
add: noop,
remove: noop,
};
}
get x() { return this.el.x || 0 };
set x(v) { this.el.x = v; };
get y() { return this.el.y || 0 };
set y(v) { this.el.y = v; };
get width() { return this.el.width || 0 };
set width(v) { this.el.width = v; };
get height() { return this.el.height || 0 };
set height(v) { this.el.height = v; };
getBoundingClientRect() {
return {
top: this.y,
right: this.x,
bottom: this.y + this.height,
left: this.x + this.width,
}
}
}
class Transforms {
/**
* @param {DOMTarget|DOMProxy} $el
*/
constructor($el) {
this.$el = $el;
this.inlineTransforms = [];
this.point = new DOMPoint();
this.inversedMatrix = this.getMatrix().inverse();
}
/**
* @param {Number} x
* @param {Number} y
* @return {DOMPoint}
*/
normalizePoint(x, y) {
this.point.x = x;
this.point.y = y;
return this.point.matrixTransform(this.inversedMatrix);
}
/**
* @callback TraverseParentsCallback
* @param {DOMTarget} $el
* @param {Number} i
*/
/**
* @param {TraverseParentsCallback} cb
*/
traverseUp(cb) {
let $el = /** @type {DOMTarget|Document} */(this.$el.parentElement), i = 0;
while ($el && $el !== doc) {
cb(/** @type {DOMTarget} */($el), i);
$el = /** @type {DOMTarget} */($el.parentElement);
i++;
}
}
getMatrix() {
const matrix = new DOMMatrix();
this.traverseUp($el => {
const transformValue = getComputedStyle($el).transform;
if (transformValue) {
const elMatrix = new DOMMatrix(transformValue);
matrix.preMultiplySelf(elMatrix);
}
});
return matrix;
}
remove() {
this.traverseUp(($el, i) => {
this.inlineTransforms[i] = $el.style.transform;
$el.style.transform = 'none';
});
}
revert() {
this.traverseUp(($el, i) => {
const ct = this.inlineTransforms[i];
if (ct === '') {
$el.style.removeProperty('transform');
} else {
$el.style.transform = ct;
}
});
}
}
/**
* @template {Array<Number>|DOMTargetSelector|String|Number|Boolean|Function|DraggableCursorParams|DraggableDragThresholdParams} T
* @param {T | ((draggable: Draggable) => T)} value
* @param {Draggable} draggable
* @return {T}
*/
const parseDraggableFunctionParameter = (value, draggable) => value && isFnc(value) ? /** @type {Function} */(value)(draggable) : /** @type {T} */(value);
let zIndex = 0;
class Draggable {
/**
* @param {TargetsParam} target
* @param {DraggableParams} [parameters]
*/
constructor(target, parameters = {}) {
if (!target) return;
if (scope.current) scope.current.register(this);
const paramX = parameters.x;
const paramY = parameters.y;
const trigger = parameters.trigger;
const modifier = parameters.modifier;
const ease = parameters.releaseEase;
const customEase = ease && parseEase(ease);
const hasSpring = !isUnd(ease) && !isUnd(/** @type {Spring} */(ease).ease);
const xProp = /** @type {String} */(isObj(paramX) && !isUnd(/** @type {Object} */(paramX).mapTo) ? /** @type {Object} */(paramX).mapTo : 'translateX');
const yProp = /** @type {String} */(isObj(paramY) && !isUnd(/** @type {Object} */(paramY).mapTo) ? /** @type {Object} */(paramY).mapTo : 'translateY');
const container = parseDraggableFunctionParameter(parameters.container, this);
this.containerArray = isArr(container) ? container : null;
this.$container = /** @type {HTMLElement} */(container && !this.containerArray ? parseTargets(/** @type {DOMTarget} */(container))[0] : doc.body);
this.useWin = this.$container === doc.body;
/** @type {Window | HTMLElement} */
this.$scrollContainer = this.useWin ? win : this.$container;
this.$target = /** @type {HTMLElement} */(isObj(target) ? new DOMProxy(target) : parseTargets(target)[0]);
this.$trigger = /** @type {HTMLElement} */(parseTargets(trigger ? trigger : target)[0]);
this.fixed = get(this.$target, 'position') === 'fixed';
// Refreshable parameters
this.isFinePointer = true;
/** @type {[Number, Number, Number, Number]} */
this.containerPadding = [0, 0, 0, 0];
/** @type {Number} */
this.containerFriction = 0;
/** @type {Number} */
this.releaseContainerFriction = 0;
/** @type {Number|Array<Number>} */
this.snapX = 0;
/** @type {Number|Array<Number>} */
this.snapY = 0;
/** @type {Number} */
this.scrollSpeed = 0;
/** @type {Number} */
this.scrollThreshold = 0;
/** @type {Number} */
this.dragSpeed = 0;
/** @type {Number} */
this.dragThreshold = 3;
/** @type {Number} */
this.maxVelocity = 0;
/** @type {Number} */
this.minVelocity = 0;
/** @type {Number} */
this.velocityMultiplier = 0;
/** @type {Boolean|DraggableCursorParams} */
this.cursor = false;
/** @type {Spring} */
this.releaseXSpring = hasSpring ? /** @type {Spring} */(ease) : spring({
mass: setValue(parameters.releaseMass, 1),
stiffness: setValue(parameters.releaseStiffness, 80),
damping: setValue(parameters.releaseDamping, 20),
});
/** @type {Spring} */
this.releaseYSpring = hasSpring ? /** @type {Spring} */(ease) : spring({
mass: setValue(parameters.releaseMass, 1),
stiffness: setValue(parameters.releaseStiffness, 80),
damping: setValue(parameters.releaseDamping, 20),
});
/** @type {EasingFunction} */
this.releaseEase = customEase || eases.outQuint;
/** @type {Boolean} */
this.hasReleaseSpring = hasSpring;
/** @type {Callback<this>} */
this.onGrab = parameters.onGrab || noop;
/** @type {Callback<this>} */
this.onDrag = parameters.onDrag || noop;
/** @type {Callback<this>} */
this.onRelease = parameters.onRelease || noop;
/** @type {Callback<this>} */
this.onUpdate = parameters.onUpdate || noop;
/** @type {Callback<this>} */
this.onSettle = parameters.onSettle || noop;
/** @type {Callback<this>} */
this.onSnap = parameters.onSnap || noop;
/** @type {Callback<this>} */
this.onResize = parameters.onResize || noop;
/** @type {Callback<this>} */
this.onAfterResize = parameters.onAfterResize || noop;
/** @type {[Number, Number]} */
this.disabled = [0, 0];
/** @type {AnimatableParams} */
const animatableParams = {};
if (modifier) animatableParams.modifier = modifier;
if (isUnd(paramX) || paramX === true) {
animatableParams[xProp] = 0;
} else if (isObj(paramX)) {
const paramXObject = /** @type {DraggableAxisParam} */(paramX);
const animatableXParams = {};
if (paramXObject.modifier) animatableXParams.modifier = paramXObject.modifier;
if (paramXObject.composition) animatableXParams.composition = paramXObject.composition;
animatableParams[xProp] = animatableXParams;
} else if (paramX === false) {
animatableParams[xProp] = 0;
this.disabled[0] = 1;
}
if (isUnd(paramY) || paramY === true) {
animatableParams[yProp] = 0;
} else if (isObj(paramY)) {
const paramYObject = /** @type {DraggableAxisParam} */(paramY);
const animatableYParams = {};
if (paramYObject.modifier) animatableYParams.modifier = paramYObject.modifier;
if (paramYObject.composition) animatableYParams.composition = paramYObject.composition;
animatableParams[yProp] = animatableYParams;
} else if (paramY === false) {
animatableParams[yProp] = 0;
this.disabled[1] = 1;
}
/** @type {AnimatableObject} */
this.animate = /** @type {AnimatableObject} */(new Animatable(this.$target, animatableParams));
// Internal props
this.xProp = xProp;
this.yProp = yProp;
this.destX = 0;
this.destY = 0;
this.deltaX = 0;
this.deltaY = 0;
this.scroll = {x: 0, y: 0};
/** @type {[Number, Number, Number, Number]} */
this.coords = [this.x, this.y, 0, 0]; // x, y, temp x, temp y
/** @type {[Number, Number]} */
this.snapped = [0, 0]; // x, y
/** @type {[Number, Number, Number, Number, Number, Number, Number, Number]} */
this.pointer = [0, 0, 0, 0, 0, 0, 0, 0]; // x1, y1, x2, y2, temp x1, temp y1, temp x2, temp y2
/** @type {[Number, Number]} */
this.scrollView = [0, 0]; // w, h
/** @type {[Number, Number, Number, Number]} */
this.dragArea = [0, 0, 0, 0]; // x, y, w, h
/** @type {[Number, Number, Number, Number]} */
this.containerBounds = [-maxValue, maxValue, maxValue, -maxValue]; // t, r, b, l
/** @type {[Number, Number, Number, Number]} */
this.scrollBounds = [0, 0, 0, 0]; // t, r, b, l
/** @type {[Number, Number, Number, Number]} */
this.targetBounds = [0, 0, 0, 0]; // t, r, b, l
/** @type {[Number, Number]} */
this.window = [0, 0]; // w, h
/** @type {[Number, Number, Number]} */
this.velocityStack = [0, 0, 0];
/** @type {Number} */
this.velocityStackIndex = 0;
/** @type {Number} */
this.velocityTime = now();
/** @type {Number} */
this.velocity = 0;
/** @type {Number} */
this.angle = 0;
/** @type {JSAnimation} */
this.cursorStyles = null;
/** @type {JSAnimation} */
this.triggerStyles = null;
/** @type {JSAnimation} */
this.bodyStyles = null;
/** @type {JSAnimation} */
this.targetStyles = null;
/** @type {JSAnimation} */
this.touchActionStyles = null;
this.transforms = new Transforms(this.$target);
this.overshootCoords = { x: 0, y: 0 };
this.overshootTicker = new Timer({
autoplay: false,
onUpdate: () => {
this.updated = true;
this.manual = true;
// Use a duration of 1 to prevent the animatable from completing immediately to prevent issues with onSettle()
// https://github.com/juliangarnier/anime/issues/1045
if (!this.disabled[0]) this.animate[this.xProp](this.overshootCoords.x, 1);
if (!this.disabled[1]) this.animate[this.yProp](this.overshootCoords.y, 1);
},
onComplete: () => {
this.manual = false;
if (!this.disabled[0]) this.animate[this.xProp](this.overshootCoords.x, 0);
if (!this.disabled[1]) this.animate[this.yProp](this.overshootCoords.y, 0);
},
}, null, 0).init();
this.updateTicker = new Timer({ autoplay: false, onUpdate: () => this.update() }, null,0,).init();
this.contained = !isUnd(container);
this.manual = false;
this.grabbed = false;
this.dragged = false;
this.updated = false;
this.released = false;
this.canScroll = false;
this.enabled = false;
this.initialized = false;
this.activeProp = this.disabled[1] ? xProp : yProp;
this.animate.callbacks.onRender = () => {
const hasUpdated = this.updated;
const hasMoved = this.grabbed && hasUpdated;
const hasReleased = !hasMoved && this.released;
const x = this.x;
const y = this.y;
const dx = x - this.coords[2];
const dy = y - this.coords[3];
this.deltaX = dx;
this.deltaY = dy;
this.coords[2] = x;
this.coords[3] = y;
// Check if dx or dy are not 0 to check if the draggable has actually moved
// https://github.com/juliangarnier/anime/issues/1032
if (hasUpdated && (dx || dy)) {
this.onUpdate(this);
}
if (!hasReleased) {
this.updated = false;
} else {
this.computeVelocity(dx, dy);
this.angle = atan2(dy, dx);
}
};
this.animate.callbacks.onComplete = () => {
if ((!this.grabbed && this.released)) {
// Set released to false before calling onSettle to avoid recursion
this.released = false;
}
if (!this.manual) {
this.deltaX = 0;
this.deltaY = 0;
this.velocity = 0;
this.velocityStack[0] = 0;
this.velocityStack[1] = 0;
this.velocityStack[2] = 0;
this.velocityStackIndex = 0;
this.onSettle(this);
}
};
this.resizeTicker = new Timer({
autoplay: false,
duration: 150 * globals.timeScale,
onComplete: () => {
this.onResize(this);
this.refresh();
this.onAfterResize(this);
},
}).init();
this.parameters = parameters;
this.resizeObserver = new ResizeObserver(() => {
if (this.initialized) {
this.resizeTicker.restart();
} else {
this.initialized = true;
}
});
this.enable();
this.refresh();
this.resizeObserver.observe(this.$container);
if (!isObj(target)) this.resizeObserver.observe(this.$target);
}
/**
* @param {Number} dx
* @param {Number} dy
* @return {Number}
*/
computeVelocity(dx, dy) {
const prevTime = this.velocityTime;
const curTime = now();
const elapsed = curTime - prevTime;
if (elapsed < 17) return this.velocity;
this.velocityTime = curTime;
const velocityStack = this.velocityStack;
const vMul = this.velocityMultiplier;
const minV = this.minVelocity;
const maxV = this.maxVelocity;
const vi = this.velocityStackIndex;
velocityStack[vi] = round(clamp((sqrt(dx * dx + dy * dy) / elapsed) * vMul, minV, maxV), 5);
const velocity = max(velocityStack[0], velocityStack[1], velocityStack[2]);
this.velocity = velocity;
this.velocityStackIndex = (vi + 1) % 3;
return velocity;
}
/**
* @param {Number} x
* @param {Boolean} [muteUpdateCallback]
* @return {this}
*/
setX(x, muteUpdateCallback = false) {
if (this.disabled[0]) return;
const v = round(x, 5);
this.overshootTicker.pause();
this.manual = true;
this.updated = !muteUpdateCallback;
this.destX = v;
this.snapped[0] = snap(v, this.snapX);
this.animate[this.xProp](v, 0);
this.manual = false;
return this;
}
/**
* @param {Number} y
* @param {Boolean} [muteUpdateCallback]
* @return {this}
*/
setY(y, muteUpdateCallback = false) {
if (this.disabled[1]) return;
const v = round(y, 5);
this.overshootTicker.pause();
this.manual = true;
this.updated = !muteUpdateCallback;
this.destY = v;
this.snapped[1] = snap(v, this.snapY);
this.animate[this.yProp](v, 0);
this.manual = false;
return this;
}
get x() {
return round(/** @type {Number} */(this.animate[this.xProp]()), globals.precision);
}
set x(x) {
this.setX(x, false);
}
get y() {
return round(/** @type {Number} */(this.animate[this.yProp]()), globals.precision);
}
set y(y) {
this.setY(y, false);
}
get progressX() {
return mapRange(this.x, this.containerBounds[3], this.containerBounds[1], 0, 1);
}
set progressX(x) {
this.setX(mapRange(x, 0, 1, this.containerBounds[3], this.containerBounds[1]), false);
}
get progressY() {
return mapRange(this.y, this.containerBounds[0], this.containerBounds[2], 0, 1);
}
set progressY(y) {
this.setY(mapRange(y, 0, 1, this.containerBounds[0], this.containerBounds[2]), false);
}
updateScrollCoords() {
const sx = round(this.useWin ? win.scrollX : this.$container.scrollLeft, 0);
const sy = round(this.useWin ? win.scrollY : this.$container.scrollTop, 0);
const [ cpt, cpr, cpb, cpl ] = this.containerPadding;
const threshold = this.scrollThreshold;
this.scroll.x = sx;
this.scroll.y = sy;
this.scrollBounds[0] = sy - this.targetBounds[0] + cpt - threshold;
this.scrollBounds[1] = sx - this.targetBounds[1] - cpr + threshold;
this.scrollBounds[2] = sy - this.targetBounds[2] - cpb + threshold;
this.scrollBounds[3] = sx - this.targetBounds[3] + cpl - threshold;
}
updateBoundingValues() {
const $container = this.$container;
// Return early if no $container defined to prevents error when reading scrollWidth / scrollHeight
// https://github.com/juliangarnier/anime/issues/1064
if (!$container) return;
const cx = this.x;
const cy = this.y;
const cx2 = this.coords[2];
const cy2 = this.coords[3];
// Prevents interfering with the scroll area in cases the target is outside of the container
// Make sure the temp coords are also adjuset to prevents wrong delta calculation on updates
this.coords[2] = 0;
this.coords[3] = 0;
this.setX(0, true);
this.setY(0, true);
this.transforms.remove();
const iw = this.window[0] = win.innerWidth;
const ih = this.window[1] = win.innerHeight;
const uw = this.useWin;
const sw = $container.scrollWidth;
const sh = $container.scrollHeight;
const fx = this.fixed;
const transformContainerRect = $container.getBoundingClientRect();
const [ cpt, cpr, cpb, cpl ] = this.containerPadding;
this.dragArea[0] = uw ? 0 : transformContainerRect.left;
this.dragArea[1] = uw ? 0 : transformContainerRect.top;
this.scrollView[0] = uw ? clamp(sw, iw, sw) : sw;
this.scrollView[1] = uw ? clamp(sh, ih, sh) : sh;
this.updateScrollCoords();
const { width, height, left, top, right, bottom } = $container.getBoundingClientRect();
this.dragArea[2] = round(uw ? clamp(width, iw, iw) : width, 0);
this.dragArea[3] = round(uw ? clamp(height, ih, ih) : height, 0);
const containerOverflow = get($container, 'overflow');
const visibleOverflow = containerOverflow === 'visible';
const hiddenOverflow = containerOverflow === 'hidden';
this.canScroll = fx ? false :
this.contained &&
(($container === doc.body && visibleOverflow) || (!hiddenOverflow && !visibleOverflow)) &&
(sw > this.dragArea[2] + cpl - cpr || sh > this.dragArea[3] + cpt - cpb) &&
(!this.containerArray || (this.containerArray && !isArr(this.containerArray)));
if (this.contained) {
const sx = this.scroll.x;
const sy = this.scroll.y;
const canScroll = this.canScroll;
const targetRect = this.$target.getBoundingClientRect();
const hiddenLeft = canScroll ? uw ? 0 : $container.scrollLeft : 0;
const hiddenTop = canScroll ? uw ? 0 : $container.scrollTop : 0;
const hiddenRight = canScroll ? this.scrollView[0] - hiddenLeft - width : 0;
const hiddenBottom = canScroll ? this.scrollView[1] - hiddenTop - height : 0;
this.targetBounds[0] = round((targetRect.top + sy) - (uw ? 0 : top), 0);
this.targetBounds[1] = round((targetRect.right + sx) - (uw ? iw : right), 0);
this.targetBounds[2] = round((targetRect.bottom + sy) - (uw ? ih : bottom), 0);
this.targetBounds[3] = round((targetRect.left + sx) - (uw ? 0 : left), 0);
if (this.containerArray) {
this.containerBounds[0] = this.containerArray[0] + cpt;
this.containerBounds[1] = this.containerArray[1] - cpr;
this.containerBounds[2] = this.containerArray[2] - cpb;
this.containerBounds[3] = this.containerArray[3] + cpl;
} else {
this.containerBounds[0] = -round(targetRect.top - (fx ? clamp(top, 0, ih) : top) + hiddenTop - cpt, 0);
this.containerBounds[1] = -round(targetRect.right - (fx ? clamp(right, 0, iw) : right) - hiddenRight + cpr, 0);
this.containerBounds[2] = -round(targetRect.bottom - (fx ? clamp(bottom, 0, ih) : bottom) - hiddenBottom + cpb, 0);
this.containerBounds[3] = -round(targetRect.left - (fx ? clamp(left, 0, iw) : left) + hiddenLeft - cpl, 0);
}
}
this.transforms.revert();
// Restore coordinates
this.coords[2] = cx2;
this.coords[3] = cy2;
this.setX(cx, true);
this.setY(cy, true);
}
/**
* @param {Array} bounds
* @param {Number} x
* @param {Number} y
* @return {Number}
*/
isOutOfBounds(bounds, x, y) {
// Returns 0 if not OB, 1 if x is OB, 2 if y is OB, 3 if both x and y are OB
if (!this.contained) return 0;
const [ bt, br, bb, bl ] = bounds;
const [ dx, dy ] = this.disabled;
const obx = !dx && x < bl || !dx && x > br;
const oby = !dy && y < bt || !dy && y > bb;
return obx && !oby ? 1 : !obx && oby ? 2 : obx && oby ? 3 : 0;
}
refresh() {
const params = this.parameters;
const paramX = params.x;
const paramY = params.y;
const container = parseDraggableFunctionParameter(params.container, this);
const cp = parseDraggableFunctionParameter(params.containerPadding, this) || 0;
const containerPadding = /** @type {[Number, Number, Number, Number]} */(isArr(cp) ? cp : [cp, cp, cp, cp]);
const cx = this.x;
const cy = this.y;
const parsedCursorStyles = parseDraggableFunctionParameter(params.cursor, this);
const cursorStyles = { onHover: 'grab', onGrab: 'grabbing' };
if (parsedCursorStyles) {
const { onHover, onGrab } = /** @type {DraggableCursorParams} */(parsedCursorStyles);
if (onHover) cursorStyles.onHover = onHover;
if (onGrab) cursorStyles.onGrab = onGrab;
}
const parsedDragThreshold = parseDraggableFunctionParameter(params.dragThreshold, this);
const dragThreshold = { mouse: 3, touch: 7 };
if (isNum(parsedDragThreshold)) {
dragThreshold.mouse = parsedDragThreshold;
dragThreshold.touch = parsedDragThreshold;
} else if (parsedDragThreshold) {
const { mouse, touch } = parsedDragThreshold;
if (!isUnd(mouse)) dragThreshold.mouse = mouse;
if (!isUnd(touch)) dragThreshold.touch = touch;
}
this.containerArray = isArr(container) ? container : null;
this.$container = /** @type {HTMLElement} */(container && !this.containerArray ? parseTargets(/** @type {DOMTarget} */(container))[0] : doc.body);
this.useWin = this.$container === doc.body;
/** @type {Window | HTMLElement} */
this.$scrollContainer = this.useWin ? win : this.$container;
this.isFinePointer = matchMedia('(pointer:fine)').matches;
this.containerPadding = setValue(containerPadding, [0, 0, 0, 0]);
this.containerFriction = clamp(setValue(parseDraggableFunctionParameter(params.containerFriction, this), .8), 0, 1);
this.releaseContainerFriction = clamp(setValue(parseDraggableFunctionParameter(params.releaseContainerFriction, this), this.containerFriction), 0, 1);
this.snapX = parseDraggableFunctionParameter(isObj(paramX) && !isUnd(paramX.snap) ? paramX.snap : params.snap, this);
this.snapY = parseDraggableFunctionParameter(isObj(paramY) && !isUnd(paramY.snap) ? paramY.snap : params.snap, this);
this.scrollSpeed = setValue(parseDraggableFunctionParameter(params.scrollSpeed, this), 1.5);
this.scrollThreshold = setValue(parseDraggableFunctionParameter(params.scrollThreshold, this), 20);
this.dragSpeed = setValue(parseDraggableFunctionParameter(params.dragSpeed, this), 1);
this.dragThreshold = this.isFinePointer ? dragThreshold.mouse : dragThreshold.touch;
this.minVelocity = setValue(parseDraggableFunctionParameter(params.minVelocity, this), 0);
this.maxVelocity = setValue(parseDraggableFunctionParameter(params.maxVelocity, this), 50);
this.velocityMultiplier = setValue(parseDraggableFunctionParameter(params.velocityMultiplier, this), 1);
this.cursor = parsedCursorStyles === false ? false : cursorStyles;
this.updateBoundingValues();
// const ob = this.isOutOfBounds(this.containerBounds, this.x, this.y);
// if (ob === 1 || ob === 3) this.progressX = px;
// if (ob === 2 || ob === 3) this.progressY = py;
// if (this.initialized && this.contained) {
// if (this.progressX !== px) this.progressX = px;
// if (this.progressY !== py) this.progressY = py;
// }
const [ bt, br, bb, bl ] = this.containerBounds;
this.setX(clamp(cx, bl, br), true);
this.setY(clamp(cy, bt, bb), true);
}
update() {
this.updateScrollCoords();
if (this.canScroll) {
const [ cpt, cpr, cpb, cpl ] = this.containerPadding;
const [ sw, sh ] = this.scrollView;
const daw = this.dragArea[2];
const dah = this.dragArea[3];
const csx = this.scroll.x;
const csy = this.scroll.y;
const nsw = this.$container.scrollWidth;
const nsh = this.$container.scrollHeight;
const csw = this.useWin ? clamp(nsw, this.window[0], nsw) : nsw;
const csh = this.useWin ? clamp(nsh, this.window[1], nsh) : nsh;
const swd = sw - csw;
const shd = sh - csh;
// Handle cases where the scrollarea dimensions changes during drag
if (this.dragged && swd > 0) {
this.coords[0] -= swd;
this.scrollView[0] = csw;
}
if (this.dragged && shd > 0) {
this.coords[1] -= shd;
this.scrollView[1] = csh;
}
// Handle autoscroll when target is at the edges of the scroll bounds
const s = this.scrollSpeed * 10;
const threshold = this.scrollThreshold;
const [ x, y ] = this.coords;
const [ st, sr, sb, sl ] = this.scrollBounds;
const t = round(clamp((y - st + cpt) / threshold, -1, 0) * s, 0);
const r = round(clamp((x - sr - cpr) / threshold, 0, 1) * s, 0);
const b = round(clamp((y - sb - cpb) / threshold, 0, 1) * s, 0);
const l = round(clamp((x - sl + cpl) / threshold, -1, 0) * s, 0);
if (t || b || l || r) {
const [nx, ny] = this.disabled;
let scrollX = csx;
let scrollY = csy;
if (!nx) {
scrollX = round(clamp(csx + (l || r), 0, sw - daw), 0);
this.coords[0] -= csx - scrollX;
}
if (!ny) {
scrollY = round(clamp(csy + (t || b), 0, sh - dah), 0);
this.coords[1] -= csy - scrollY;
}
// Note: Safari mobile requires to use different scroll methods depending if using the window or not
if (this.useWin) {
this.$scrollContainer.scrollBy(-(csx - scrollX), -(csy - scrollY));
} else {
this.$scrollContainer.scrollTo(scrollX, scrollY);
}
}
}
const [ ct, cr, cb, cl ] = this.containerBounds;
const [ px1, py1, px2, py2, px3, py3 ] = this.pointer;
this.coords[0] += (px1 - px3) * this.dragSpeed;
this.coords[1] += (py1 - py3) * this.dragSpeed;
this.pointer[4] = px1;
this.pointer[5] = py1;
const [ cx, cy ] = this.coords;
const [ sx, sy ] = this.snapped;
const cf = (1 - this.containerFriction) * this.dragSpeed;
this.setX(cx > cr ? cr + (cx - cr) * cf : cx < cl ? cl + (cx - cl) * cf : cx, false);
this.setY(cy > cb ? cb + (cy - cb) * cf : cy < ct ? ct + (cy - ct) * cf : cy, false);
this.computeVelocity(px1 - px3, py1 - py3);
this.angle = atan2(py1 - py2, px1 - px2);
const [ nsx, nsy ] = this.snapped;
if (nsx !== sx && this.snapX || nsy !== sy && this.snapY) {
this.onSnap(this);
}
}
stop() {
this.updateTicker.pause();
this.overshootTicker.pause();
// Pauses the in bounds onRelease animations
for (let prop in this.animate.animations) this.animate.animations[prop].pause();
removeTargetsFromRenderable([this], null, 'x');
removeTargetsFromRenderable([this], null, 'y');
removeTargetsFromRenderable([this], null, 'progressX');
removeTargetsFromRenderable([this], null, 'progressY');
removeTargetsFromRenderable([this.scroll]); // Removes any active animations on the container scroll
removeTargetsFromRenderable([this.overshootCoords]); // Removes active overshoot animations
return this;
}
/**
* @param {Number} [duration]
* @param {Number} [gap]
* @param {EasingParam} [ease]
* @return {this}
*/
scrollInView(duration, gap = 0, ease = eases.inOutQuad) {
this.updateScrollCoords();
const x = this.destX;
const y = this.destY;
const scroll = this.scroll;
const scrollBounds = this.scrollBounds;
const canScroll = this.canScroll;
if (!this.containerArray && this.isOutOfBounds(scrollBounds, x, y)) {
const [ st, sr, sb, sl ] = scrollBounds;
const t = round(clamp(y - st, -maxValue, 0), 0);
const r = round(clamp(x - sr, 0, maxValue), 0);
const b = round(clamp(y - sb, 0, maxValue), 0);
const l = round(clamp(x - sl, -maxValue, 0), 0);
new JSAnimation(scroll, {
x: round(scroll.x + (l ? l - gap : r ? r + gap : 0), 0),
y: round(scroll.y + (t ? t - gap : b ? b + gap : 0), 0),
duration: isUnd(duration) ? 350 * globals.timeScale : duration,
ease,
onUpdate: () => {
this.canScroll = false;
this.$scrollContainer.scrollTo(scroll.x, scroll.y);
}
}).init().then(() => {
this.canScroll = canScroll;
});
}
return this;
}
handleHover() {
if (this.isFinePointer && this.cursor && !this.cursorStyles) {
this.cursorStyles = set(this.$trigger, {
cursor: /** @type {DraggableCursorParams} */(this.cursor).onHover
});
}
}
/**
* @param {Number} [duration]
* @param {Number} [gap]
* @param {EasingParam} [ease]
* @return {this}
*/
animateInView(duration, gap = 0, ease = eases.inOutQuad) {
this.stop();
this.updateBoundingValues();
const x = this.x;
const y = this.y;
const [ cpt, cpr, cpb, cpl ] = this.containerPadding;
const bt = this.scroll.y - this.targetBounds[0] + cpt + gap;
const br = this.scroll.x - this.targetBounds[1] - cpr - gap;
const bb = this.scroll.y - this.targetBounds[2] - cpb - gap;
const bl = this.scroll.x - this.targetBounds[3] + cpl + gap;
const ob = this.isOutOfBounds([bt, br, bb, bl], x, y);
if (ob) {
const [ disabledX, disabledY ] = this.disabled;
const destX = clamp(snap(x, this.snapX), bl, br);
const destY = clamp(snap(y, this.snapY), bt, bb);
const dur = isUnd(duration) ? 350 * globals.timeScale : duration;
if (!disabledX && (ob === 1 || ob === 3)) this.animate[this.xProp](destX, dur, ease);
if (!disabledY && (ob === 2 || ob === 3)) this.animate[this.yProp](destY, dur, ease);
}
return this;
}
/**
* @param {MouseEvent|TouchEvent} e
*/
handleDown(e) {
const $eTarget = /** @type {HTMLElement} */(e.target);
if (this.grabbed || /** @type {HTMLInputElement} */($eTarget).type === 'range') return;
e.stopPropagation();
this.grabbed = true;
this.released = false;
this.stop();
this.updateBoundingValues();
const touches = /** @type {TouchEvent} */(e).changedTouches;
const eventX = touches ? touches[0].clientX : /** @type {MouseEvent} */(e).clientX;
const eventY = touches ? touches[0].clientY : /** @type {MouseEvent} */(e).clientY;
const { x, y } = this.transforms.normalizePoint(eventX, eventY);
const [ ct, cr, cb, cl ] = this.containerBounds;
const cf = (1 - this.containerFriction) * this.dragSpeed;
const cx = this.x;
const cy = this.y;
this.coords[0] = this.coords[2] = !cf ? cx : cx > cr ? cr + (cx - cr) / cf : cx < cl ? cl + (cx - cl) / cf : cx;
this.coords[1] = this.coords[3] = !cf ? cy : cy > cb ? cb + (cy - cb) / cf : cy < ct ? ct + (cy - ct) / cf : cy;
this.pointer[0] = x;
this.pointer[1] = y;
this.pointer[2] = x;
this.pointer[3] = y;
this.pointer[4] = x;
this.pointer[5] = y;
this.pointer[6] = x;
this.pointer[7] = y;
this.deltaX = 0;
this.deltaY = 0;
this.velocity = 0;
this.velocityStack[0] = 0;
this.velocityStack[1] = 0;
this.velocityStack[2] = 0;
this.velocityStackIndex = 0;
this.angle = 0;
if (this.targetStyles) {
this.targetStyles.revert();
this.targetStyles = null;
}
const z = /** @type {Number} */(get(this.$target, 'zIndex', false));
zIndex = (z > zIndex ? z : zIndex) + 1;
this.targetStyles = set(this.$target, { zIndex });
if (this.triggerStyles) {
this.triggerStyles.revert();
this.triggerStyles = null;
}
if (this.cursorStyles) {
this.cursorStyles.revert();
this.cursorStyles = null;
}
if (this.isFinePointer && this.cursor) {
this.bodyStyles = set(doc.body, {
cursor: /** @type {DraggableCursorParams} */(this.cursor).onGrab
});
}
this.scrollInView(100, 0, eases.out(3));
this.onGrab(this);
doc.addEventListener('touchmove', this);
doc.addEventListener('touchend', this);
doc.addEventListener('touchcancel', this);
doc.addEventListener('mousemove', this);
doc.addEventListener('mouseup', this);
doc.addEventListener('selectstart', this);
}
/**
* @param {MouseEvent|TouchEvent} e
*/
handleMove(e) {
if (!this.grabbed) return;
const touches = /** @type {TouchEvent} */(e).changedTouches;
const eventX = touches ? touches[0].clientX : /** @type {MouseEvent} */(e).clientX;
const eventY = touches ? touches[0].clientY : /** @type {MouseEvent} */(e).clientY;
const { x, y } = this.transforms.normalizePoint(eventX, eventY);
const movedX = x - this.pointer[6];
const movedY = y - this.pointer[7];
let $parent = /** @type {HTMLElement} */(e.target);
let isAtTop = false;
let isAtBottom = false;
let canTouchScroll = false;
while (touches && $parent && $parent !== this.$trigger) {
const overflowY = get($parent, 'overflow-y');
if (overflowY !== 'hidden' && overflowY !== 'visible') {
const { scrollTop, scrollHeight, clientHeight } = $parent;
if (scrollHeight > clientHeight) {
canTouchScroll = true;
isAtTop = scrollTop <= 3;
isAtBottom = scrollTop >= (scrollHeight - clientHeight) - 3;
break;
}
}
$parent = $parent.parentElement;
}
if (canTouchScroll && ((!isAtTop && !isAtBottom) || (isAtTop && movedY < 0) || (isAtBottom && movedY > 0))) {
this.pointer[0] = x;
this.pointer[1] = y;
this.pointer[2] = x;
this.pointer[3] = y;
this.pointer[4] = x;
this.pointer[5] = y;
this.pointer[6] = x;
this.pointer[7] = y;
} else {
preventDefault(e);
// Needed to prevents click on handleUp
if (!this.triggerStyles) this.triggerStyles = set(this.$trigger, { pointerEvents: 'none' });
// Needed to prevent page scroll while dragging on touch devvice
this.$trigger.addEventListener('touchstart', preventDefault, { passive: false });
this.$trigger.addEventListener('touchmove', preventDefault, { passive: false });
this.$trigger.addEventListener('touchend', preventDefault);
// Don't check for a miminim distance move if already dragging
if (this.dragged || (!this.disabled[0] && abs(movedX) > this.dragThreshold) || (!this.disabled[1] && abs(movedY) > this.dragThreshold)) {
this.updateTicker.resume();
this.pointer[2] = this.pointer[0];
this.pointer[3] = this.pointer[1];
this.pointer[0] = x;
this.pointer[1] = y;
this.dragged = true;
this.released = false;
this.onDrag(this);
}
}
}
handleUp() {
if (!this.grabbed) return;
this.updateTicker.pause();
if (this.triggerStyles) {
this.triggerStyles.revert();
this.triggerStyles = null;
}
if (this.bodyStyles) {
this.bodyStyles.revert();
this.bodyStyles = null;
}
const [ disabledX, disabledY ] = this.disabled;
const [ px1, py1, px2, py2, px3, py3 ] = this.pointer;
const [ ct, cr, cb, cl ] = this.containerBounds;
const [ sx, sy ] = this.snapped;
const springX = this.releaseXSpring;
const springY = this.releaseYSpring;
const releaseEase = this.releaseEase;
const hasReleaseSpring = this.hasReleaseSpring;
const overshootCoords = this.overshootCoords;
const cx = this.x;
const cy = this.y;
const pv = this.computeVelocity(px1 - px3, py1 - py3);
const pa = this.angle = atan2(py1 - py2, px1 - px2);
const ds = pv * 150;
const cf = (1 - this.releaseContainerFriction) * this.dragSpeed;
const nx = cx + (cos(pa) * ds);
const ny = cy + (sin(pa) * ds);
const bx = nx > cr ? cr + (nx - cr) * cf : nx < cl ? cl + (nx - cl) * cf : nx;
const by = ny > cb ? cb + (ny - cb) * cf : ny < ct ? ct + (ny - ct) * cf : ny;
const dx = this.destX = clamp(round(snap(bx, this.snapX), 5), cl, cr);
const dy = this.destY = clamp(round(snap(by, this.snapY), 5), ct, cb);
const ob = this.isOutOfBounds(this.containerBounds, nx, ny);
let durationX = 0;
let durationY = 0;
let easeX = releaseEase;
let easeY = releaseEase;
let longestReleaseDuration = 0;
overshootCoords.x = cx;
overshootCoords.y = cy;
if (!disabledX) {
const directionX = dx === cr ? cx > cr ? -1 : 1 : cx < cl ? -1 : 1;
const distanceX = round(cx - dx, 0);
springX.velocity = disabledY && hasReleaseSpring ? distanceX ? (ds * directionX) / abs(distanceX) : 0 : pv;
const { ease, settlingDuration, restDuration } = springX;
durationX = cx === dx ? 0 : hasReleaseSpring ? settlingDuration : settlingDuration - (restDuration * globals.timeScale);
if (hasReleaseSpring) easeX = ease;
if (durationX > longestReleaseDuration) longestReleaseDuration = durationX;
}
if (!disabledY) {
const directionY = dy === cb ? cy > cb ? -1 : 1 : cy < ct ? -1 : 1;
const distanceY = round(cy - dy, 0);
springY.velocity = disabledX && hasReleaseSpring ? distanceY ? (ds * directionY) / abs(distanceY) : 0 : pv;
const { ease, settlingDuration, restDuration } = springY;
durationY = cy === dy ? 0 : hasReleaseSpring ? settlingDuration : settlingDuration - (restDuration * globals.timeScale);
if (hasReleaseSpring) easeY = ease;
if (durationY > longestReleaseDuration) longestReleaseDuration = durationY;
}
if (!hasReleaseSpring && ob && cf && (durationX || durationY)) {
const composition = compositionTypes.blend;
new JSAnimation(overshootCoords, {
x: { to: bx, duration: durationX * .65 },
y: { to: by, duration: durationY * .65 },
ease: releaseEase,
composition,
}).init();
new JSAnimation(overshootCoords, {
x: { to: dx, duration: durationX },
y: { to: dy, duration: durationY },
ease: releaseEase,
composition,
}).init();
this.overshootTicker.stretch(max(durationX, durationY)).restart();
} else {
if (!disabledX) this.animate[this.xProp](dx, durationX, easeX);
if (!disabledY) this.animate[this.yProp](dy, durationY, easeY);
}
this.scrollInView(longestReleaseDuration, this.scrollThreshold, releaseEase);
let hasSnapped = false;
if (dx !== sx) {
this.snapped[0] = dx;
if (this.snapX) hasSnapped = true;
}
if (dy !== sy && this.snapY) {
this.snapped[1] = dy;
if (this.snapY) hasSnapped = true;
}
if (hasSnapped) this.onSnap(this);
this.grabbed = false;
this.dragged = false;
this.updated = true;
this.released = true;
// It's important to trigger the callback after the release animations to be able to cancel them
this.onRelease(this);
this.$trigger.removeEventListener('touchstart', preventDefault);
this.$trigger.removeEventListener('touchmove', preventDefault);
this.$trigger.removeEventListener('touchend', preventDefault);
doc.removeEventListener('touchmove', this);
doc.removeEventListener('touchend', this);
doc.removeEventListener('touchcancel', this);
doc.removeEventListener('mousemove', this);
doc.removeEventListener('mouseup', this);
doc.removeEventListener('selectstart', this);
}
reset() {
this.stop();
this.resizeTicker.pause();
this.grabbed = false;
this.dragged = false;
this.updated = false;
this.released = false;
this.canScroll = false;
this.setX(0, true);
this.setY(0, true);
this.coords[0] = 0;
this.coords[1] = 0;
this.pointer[0] = 0;
this.pointer[1] = 0;
this.pointer[2] = 0;
this.pointer[3] = 0;
this.pointer[4] = 0;
this.pointer[5] = 0;
this.pointer[6] = 0;
this.pointer[7] = 0;
this.velocity = 0;
this.velocityStack[0] = 0;
this.velocityStack[1] = 0;
this.velocityStack[2] = 0;
this.velocityStackIndex = 0;
this.angle = 0;
return this;
}
enable() {
if (!this.enabled) {
this.enabled = true;
this.$target.classList.remove('is-disabled');
this.touchActionStyles = set(this.$trigger, {
touchAction: this.disabled[0] ? 'pan-x' : this.disabled[1] ? 'pan-y' : 'none'
});
this.$trigger.addEventListener('touchstart', this, { passive: true });
this.$trigger.addEventListener('mousedown', this, { passive: true });
this.$trigger.addEventListener('mouseenter', this);
}
return this;
}
disable() {
this.enabled = false;
this.grabbed = false;
this.dragged = false;
this.updated = false;
this.released = false;
this.canScroll = false;
this.touchActionStyles.revert();
if (this.cursorStyles) {
this.cursorStyles.revert();
this.cursorStyles = null;
}
if (this.triggerStyles) {
this.triggerStyles.revert();
this.triggerStyles = null;
}
if (this.bodyStyles) {
this.bodyStyles.revert();
this.bodyStyles = null;
}
if (this.targetStyles) {
this.targetStyles.revert();
this.targetStyles = null;
}
this.$target.classList.add('is-disabled');
this.$trigger.removeEventListener('touchstart', this);
this.$trigger.removeEventListener('mousedown', this);
this.$trigger.removeEventListener('mouseenter', this);
doc.removeEventListener('touchmove', this);
doc.removeEventListener('touchend', this);
doc.removeEventListener('touchcancel', this);
doc.removeEventListener('mousemove', this);
doc.removeEventListener('mouseup', this);
doc.removeEventListener('selectstart', this);
return this;
}
revert() {
this.reset();
this.disable();
this.$target.classList.remove('is-disabled');
this.updateTicker.revert();
this.overshootTicker.revert();
this.resizeTicker.revert();
this.animate.revert();
this.resizeObserver.disconnect();
return this;
}
/**
* @param {Event} e
*/
handleEvent(e) {
switch (e.type) {
case 'mousedown':
this.handleDown(/** @type {MouseEvent} */(e));
break;
case 'touchstart':
this.handleDown(/** @type {TouchEvent} */(e));
break;
case 'mousemove':
this.handleMove(/** @type {MouseEvent} */(e));
break;
case 'touchmove':
this.handleMove(/** @type {TouchEvent} */(e));
break;
case 'mouseup':
this.handleUp();
break;
case 'touchend':
this.handleUp();
break;
case 'touchcancel':
this.handleUp();
break;
case 'mouseenter':
this.handleHover();
break;
case 'selectstart':
preventDefault(e);
break;
}
}
}
/**
* @param {TargetsParam} target
* @param {DraggableParams} [parameters]
* @return {Draggable}
*/
const createDraggable = (target, parameters) => new Draggable(target, parameters);
export { Draggable, createDraggable };