const clone = require('./utils/clone');
const is = require('./utils/is');
const events = require('./utils/events');
const extend = require('./utils/extend');
const actions = require('./actions/base');
const scope = require('./scope');
const Eventable = require('./Eventable');
const defaults = require('./defaultOptions');
const signals = require('./utils/Signals').new();
const {
getElementRect,
nodeContains,
trySelector,
matchesSelector,
} = require('./utils/domUtils');
const { getWindow } = require('./utils/window');
const { contains } = require('./utils/arr');
const { wheelEvent } = require('./utils/browser');
// all set interactables
scope.interactables = [];
class Interactable {
/** */
constructor (target, options) {
options = options || {};
this.target = target;
this.events = new Eventable();
this._context = options.context || scope.document;
this._win = getWindow(trySelector(target)? this._context : target);
this._doc = this._win.document;
signals.fire('new', {
target,
options,
interactable: this,
win: this._win,
});
scope.addDocument( this._doc, this._win );
scope.interactables.push(this);
this.set(options);
}
setOnEvents (action, phases) {
const onAction = 'on' + action;
if (is.function(phases.onstart) ) { this.events[onAction + 'start' ] = phases.onstart ; }
if (is.function(phases.onmove) ) { this.events[onAction + 'move' ] = phases.onmove ; }
if (is.function(phases.onend) ) { this.events[onAction + 'end' ] = phases.onend ; }
if (is.function(phases.oninertiastart)) { this.events[onAction + 'inertiastart' ] = phases.oninertiastart ; }
return this;
}
setPerAction (action, options) {
// for all the default per-action options
for (const option in options) {
// if this option exists for this action
if (option in defaults[action]) {
// if the option in the options arg is an object value
if (is.object(options[option])) {
// duplicate the object and merge
this.options[action][option] = clone(this.options[action][option] || {});
extend(this.options[action][option], options[option]);
if (is.object(defaults.perAction[option]) && 'enabled' in defaults.perAction[option]) {
this.options[action][option].enabled = options[option].enabled === false? false : true;
}
}
else if (is.bool(options[option]) && is.object(defaults.perAction[option])) {
this.options[action][option].enabled = options[option];
}
else if (options[option] !== undefined) {
// or if it's not undefined, do a plain assignment
this.options[action][option] = options[option];
}
}
}
}
/**
* The default function to get an Interactables bounding rect. Can be
* overridden using {@link Interactable.rectChecker}.
*
* @param {Element} [element] The element to measure.
* @return {object} The object's bounding rectangle.
*/
getRect (element) {
element = element || this.target;
if (is.string(this.target) && !(is.element(element))) {
element = this._context.querySelector(this.target);
}
return getElementRect(element);
}
/**
* Returns or sets the function used to calculate the interactable's
* element's rectangle
*
* @param {function} [checker] A function which returns this Interactable's
* bounding rectangle. See {@link Interactable.getRect}
* @return {function | object} The checker function or this Interactable
*/
rectChecker (checker) {
if (is.function(checker)) {
this.getRect = checker;
return this;
}
if (checker === null) {
delete this.options.getRect;
return this;
}
return this.getRect;
}
_backCompatOption (optionName, newValue) {
if (trySelector(newValue) || is.object(newValue)) {
this.options[optionName] = newValue;
for (const action of actions.names) {
this.options[action][optionName] = newValue;
}
return this;
}
return this.options[optionName];
}
/**
* Gets or sets the origin of the Interactable's element. The x and y
* of the origin will be subtracted from action event coordinates.
*
* @param {Element | object | string} [origin] An HTML or SVG Element whose
* rect will be used, an object eg. { x: 0, y: 0 } or string 'parent', 'self'
* or any CSS selector
*
* @return {object} The current origin or this Interactable
*/
origin (newValue) {
return this._backCompatOption('origin', newValue);
}
/**
* Returns or sets the mouse coordinate types used to calculate the
* movement of the pointer.
*
* @param {string} [newValue] Use 'client' if you will be scrolling while
* interacting; Use 'page' if you want autoScroll to work
* @return {string | object} The current deltaSource or this Interactable
*/
deltaSource (newValue) {
if (newValue === 'page' || newValue === 'client') {
this.options.deltaSource = newValue;
return this;
}
return this.options.deltaSource;
}
/**
* Gets the selector context Node of the Interactable. The default is
* `window.document`.
*
* @return {Node} The context Node of this Interactable
*/
context () {
return this._context;
}
inContext (element) {
return (this._context === element.ownerDocument
|| nodeContains(this._context, element));
}
/**
* Calls listeners for the given InteractEvent type bound globally
* and directly to this Interactable
*
* @param {InteractEvent} iEvent The InteractEvent object to be fired on this
* Interactable
* @return {Interactable} this Interactable
*/
fire (iEvent) {
this.events.fire(iEvent);
return this;
}
_onOffMultiple (method, eventType, listener, options) {
if (is.string(eventType) && eventType.search(' ') !== -1) {
eventType = eventType.trim().split(/ +/);
}
if (is.array(eventType)) {
for (const type of eventType) {
this[method](type, listener, options);
}
return true;
}
if (is.object(eventType)) {
for (const prop in eventType) {
this[method](prop, eventType[prop], listener);
}
return true;
}
}
/**
* Binds a listener for an InteractEvent, pointerEvent or DOM event.
*
* @param {string | array | object} eventType The types of events to listen
* for
* @param {function} listener The function event (s)
* @param {object | boolean} [options] options object or useCapture flag
* for addEventListener
* @return {object} This Interactable
*/
on (eventType, listener, options) {
if (this._onOffMultiple('on', eventType, listener, options)) {
return this;
}
if (eventType === 'wheel') { eventType = wheelEvent; }
if (contains(Interactable.eventTypes, eventType)) {
this.events.on(eventType, listener);
}
// delegated event for selector
else if (is.string(this.target)) {
events.addDelegate(this.target, this._context, eventType, listener, options);
}
else {
events.add(this.target, eventType, listener, options);
}
return this;
}
/**
* Removes an InteractEvent, pointerEvent or DOM event listener
*
* @param {string | array | object} eventType The types of events that were
* listened for
* @param {function} listener The listener function to be removed
* @param {object | boolean} [options] options object or useCapture flag for
* removeEventListener
* @return {object} This Interactable
*/
off (eventType, listener, options) {
if (this._onOffMultiple('off', eventType, listener, options)) {
return this;
}
if (eventType === 'wheel') { eventType = wheelEvent; }
// if it is an action event type
if (contains(Interactable.eventTypes, eventType)) {
this.events.off(eventType, listener);
}
// delegated event
else if (is.string(this.target)) {
events.removeDelegate(this.target, this._context, eventType, listener, options);
}
// remove listener from this Interatable's element
else {
events.remove(this.target, eventType, listener, options);
}
return this;
}
/**
* Reset the options of this Interactable
*
* @param {object} options The new settings to apply
* @return {object} This Interactable
*/
set (options) {
if (!is.object(options)) {
options = {};
}
this.options = clone(defaults.base);
const perActions = clone(defaults.perAction);
for (const actionName in actions.methodDict) {
const methodName = actions.methodDict[actionName];
this.options[actionName] = clone(defaults[actionName]);
this.setPerAction(actionName, perActions);
this[methodName](options[actionName]);
}
for (const setting of Interactable.settingsMethods) {
this.options[setting] = defaults.base[setting];
if (setting in options) {
this[setting](options[setting]);
}
}
signals.fire('set', {
options,
interactable: this,
});
return this;
}
/**
* Remove this interactable from the list of interactables and remove it's
* action capabilities and event listeners
*
* @return {interact}
*/
unset () {
events.remove(this.target, 'all');
if (is.string(this.target)) {
// remove delegated events
for (const type in events.delegatedEvents) {
const delegated = events.delegatedEvents[type];
if (delegated.selectors[0] === this.target
&& delegated.contexts[0] === this._context) {
delegated.selectors.splice(0, 1);
delegated.contexts .splice(0, 1);
delegated.listeners.splice(0, 1);
// remove the arrays if they are empty
if (!delegated.selectors.length) {
delegated[type] = null;
}
}
events.remove(this._context, type, events.delegateListener);
events.remove(this._context, type, events.delegateUseCapture, true);
}
}
else {
events.remove(this, 'all');
}
signals.fire('unset', { interactable: this });
scope.interactables.splice(scope.interactables.indexOf(this), 1);
// Stop related interactions when an Interactable is unset
for (const interaction of scope.interactions || []) {
if (interaction.target === this && interaction.interacting() && !interaction._ending) {
interaction.stop();
}
}
return scope.interact;
}
}
scope.interactables.indexOfElement = function indexOfElement (target, context) {
context = context || scope.document;
for (let i = 0; i < this.length; i++) {
const interactable = this[i];
if (interactable.target === target && interactable._context === context) {
return i;
}
}
return -1;
};
scope.interactables.get = function interactableGet (element, options, dontCheckInContext) {
const ret = this[this.indexOfElement(element, options && options.context)];
return ret && (is.string(element) || dontCheckInContext || ret.inContext(element))? ret : null;
};
scope.interactables.forEachMatch = function (element, callback) {
for (const interactable of this) {
let ret;
if ((is.string(interactable.target)
// target is a selector and the element matches
? (is.element(element) && matchesSelector(element, interactable.target))
// target is the element
: element === interactable.target)
// the element is in context
&& (interactable.inContext(element))) {
ret = callback(interactable);
}
if (ret !== undefined) {
return ret;
}
}
};
// all interact.js eventTypes
Interactable.eventTypes = scope.eventTypes = [];
Interactable.signals = signals;
Interactable.settingsMethods = [ 'deltaSource', 'origin', 'preventDefault', 'rectChecker' ];
module.exports = Interactable;