UNPKG

12.2 kBJavaScriptView Raw
1const clone = require('./utils/clone');
2const is = require('./utils/is');
3const events = require('./utils/events');
4const extend = require('./utils/extend');
5const actions = require('./actions/base');
6const scope = require('./scope');
7const Eventable = require('./Eventable');
8const defaults = require('./defaultOptions');
9const signals = require('./utils/Signals').new();
10
11const {
12 getElementRect,
13 nodeContains,
14 trySelector,
15 matchesSelector,
16} = require('./utils/domUtils');
17const { getWindow } = require('./utils/window');
18const { contains } = require('./utils/arr');
19const { wheelEvent } = require('./utils/browser');
20
21// all set interactables
22scope.interactables = [];
23
24class Interactable {
25 /** */
26 constructor (target, options) {
27 options = options || {};
28
29 this.target = target;
30 this.events = new Eventable();
31 this._context = options.context || scope.document;
32 this._win = getWindow(trySelector(target)? this._context : target);
33 this._doc = this._win.document;
34
35 signals.fire('new', {
36 target,
37 options,
38 interactable: this,
39 win: this._win,
40 });
41
42 scope.addDocument( this._doc, this._win );
43
44 scope.interactables.push(this);
45
46 this.set(options);
47 }
48
49 setOnEvents (action, phases) {
50 const onAction = 'on' + action;
51
52 if (is.function(phases.onstart) ) { this.events[onAction + 'start' ] = phases.onstart ; }
53 if (is.function(phases.onmove) ) { this.events[onAction + 'move' ] = phases.onmove ; }
54 if (is.function(phases.onend) ) { this.events[onAction + 'end' ] = phases.onend ; }
55 if (is.function(phases.oninertiastart)) { this.events[onAction + 'inertiastart' ] = phases.oninertiastart ; }
56
57 return this;
58 }
59
60 setPerAction (action, options) {
61 // for all the default per-action options
62 for (const option in options) {
63 // if this option exists for this action
64 if (option in defaults[action]) {
65 // if the option in the options arg is an object value
66 if (is.object(options[option])) {
67 // duplicate the object and merge
68 this.options[action][option] = clone(this.options[action][option] || {});
69 extend(this.options[action][option], options[option]);
70
71 if (is.object(defaults.perAction[option]) && 'enabled' in defaults.perAction[option]) {
72 this.options[action][option].enabled = options[option].enabled === false? false : true;
73 }
74 }
75 else if (is.bool(options[option]) && is.object(defaults.perAction[option])) {
76 this.options[action][option].enabled = options[option];
77 }
78 else if (options[option] !== undefined) {
79 // or if it's not undefined, do a plain assignment
80 this.options[action][option] = options[option];
81 }
82 }
83 }
84 }
85
86 /**
87 * The default function to get an Interactables bounding rect. Can be
88 * overridden using {@link Interactable.rectChecker}.
89 *
90 * @param {Element} [element] The element to measure.
91 * @return {object} The object's bounding rectangle.
92 */
93 getRect (element) {
94 element = element || this.target;
95
96 if (is.string(this.target) && !(is.element(element))) {
97 element = this._context.querySelector(this.target);
98 }
99
100 return getElementRect(element);
101 }
102
103 /**
104 * Returns or sets the function used to calculate the interactable's
105 * element's rectangle
106 *
107 * @param {function} [checker] A function which returns this Interactable's
108 * bounding rectangle. See {@link Interactable.getRect}
109 * @return {function | object} The checker function or this Interactable
110 */
111 rectChecker (checker) {
112 if (is.function(checker)) {
113 this.getRect = checker;
114
115 return this;
116 }
117
118 if (checker === null) {
119 delete this.options.getRect;
120
121 return this;
122 }
123
124 return this.getRect;
125 }
126
127 _backCompatOption (optionName, newValue) {
128 if (trySelector(newValue) || is.object(newValue)) {
129 this.options[optionName] = newValue;
130
131 for (const action of actions.names) {
132 this.options[action][optionName] = newValue;
133 }
134
135 return this;
136 }
137
138 return this.options[optionName];
139 }
140
141 /**
142 * Gets or sets the origin of the Interactable's element. The x and y
143 * of the origin will be subtracted from action event coordinates.
144 *
145 * @param {Element | object | string} [origin] An HTML or SVG Element whose
146 * rect will be used, an object eg. { x: 0, y: 0 } or string 'parent', 'self'
147 * or any CSS selector
148 *
149 * @return {object} The current origin or this Interactable
150 */
151 origin (newValue) {
152 return this._backCompatOption('origin', newValue);
153 }
154
155 /**
156 * Returns or sets the mouse coordinate types used to calculate the
157 * movement of the pointer.
158 *
159 * @param {string} [newValue] Use 'client' if you will be scrolling while
160 * interacting; Use 'page' if you want autoScroll to work
161 * @return {string | object} The current deltaSource or this Interactable
162 */
163 deltaSource (newValue) {
164 if (newValue === 'page' || newValue === 'client') {
165 this.options.deltaSource = newValue;
166
167 return this;
168 }
169
170 return this.options.deltaSource;
171 }
172
173 /**
174 * Gets the selector context Node of the Interactable. The default is
175 * `window.document`.
176 *
177 * @return {Node} The context Node of this Interactable
178 */
179 context () {
180 return this._context;
181 }
182
183 inContext (element) {
184 return (this._context === element.ownerDocument
185 || nodeContains(this._context, element));
186 }
187
188 /**
189 * Calls listeners for the given InteractEvent type bound globally
190 * and directly to this Interactable
191 *
192 * @param {InteractEvent} iEvent The InteractEvent object to be fired on this
193 * Interactable
194 * @return {Interactable} this Interactable
195 */
196 fire (iEvent) {
197 this.events.fire(iEvent);
198
199 return this;
200 }
201
202 _onOffMultiple (method, eventType, listener, options) {
203 if (is.string(eventType) && eventType.search(' ') !== -1) {
204 eventType = eventType.trim().split(/ +/);
205 }
206
207 if (is.array(eventType)) {
208 for (const type of eventType) {
209 this[method](type, listener, options);
210 }
211
212 return true;
213 }
214
215 if (is.object(eventType)) {
216 for (const prop in eventType) {
217 this[method](prop, eventType[prop], listener);
218 }
219
220 return true;
221 }
222 }
223
224 /**
225 * Binds a listener for an InteractEvent, pointerEvent or DOM event.
226 *
227 * @param {string | array | object} eventType The types of events to listen
228 * for
229 * @param {function} listener The function event (s)
230 * @param {object | boolean} [options] options object or useCapture flag
231 * for addEventListener
232 * @return {object} This Interactable
233 */
234 on (eventType, listener, options) {
235 if (this._onOffMultiple('on', eventType, listener, options)) {
236 return this;
237 }
238
239 if (eventType === 'wheel') { eventType = wheelEvent; }
240
241 if (contains(Interactable.eventTypes, eventType)) {
242 this.events.on(eventType, listener);
243 }
244 // delegated event for selector
245 else if (is.string(this.target)) {
246 events.addDelegate(this.target, this._context, eventType, listener, options);
247 }
248 else {
249 events.add(this.target, eventType, listener, options);
250 }
251
252 return this;
253 }
254
255 /**
256 * Removes an InteractEvent, pointerEvent or DOM event listener
257 *
258 * @param {string | array | object} eventType The types of events that were
259 * listened for
260 * @param {function} listener The listener function to be removed
261 * @param {object | boolean} [options] options object or useCapture flag for
262 * removeEventListener
263 * @return {object} This Interactable
264 */
265 off (eventType, listener, options) {
266 if (this._onOffMultiple('off', eventType, listener, options)) {
267 return this;
268 }
269
270 if (eventType === 'wheel') { eventType = wheelEvent; }
271
272 // if it is an action event type
273 if (contains(Interactable.eventTypes, eventType)) {
274 this.events.off(eventType, listener);
275 }
276 // delegated event
277 else if (is.string(this.target)) {
278 events.removeDelegate(this.target, this._context, eventType, listener, options);
279 }
280 // remove listener from this Interatable's element
281 else {
282 events.remove(this.target, eventType, listener, options);
283 }
284
285 return this;
286 }
287
288 /**
289 * Reset the options of this Interactable
290 *
291 * @param {object} options The new settings to apply
292 * @return {object} This Interactable
293 */
294 set (options) {
295 if (!is.object(options)) {
296 options = {};
297 }
298
299 this.options = clone(defaults.base);
300
301 const perActions = clone(defaults.perAction);
302
303 for (const actionName in actions.methodDict) {
304 const methodName = actions.methodDict[actionName];
305
306 this.options[actionName] = clone(defaults[actionName]);
307
308 this.setPerAction(actionName, perActions);
309
310 this[methodName](options[actionName]);
311 }
312
313 for (const setting of Interactable.settingsMethods) {
314 this.options[setting] = defaults.base[setting];
315
316 if (setting in options) {
317 this[setting](options[setting]);
318 }
319 }
320
321 signals.fire('set', {
322 options,
323 interactable: this,
324 });
325
326 return this;
327 }
328
329 /**
330 * Remove this interactable from the list of interactables and remove it's
331 * action capabilities and event listeners
332 *
333 * @return {interact}
334 */
335 unset () {
336 events.remove(this.target, 'all');
337
338 if (is.string(this.target)) {
339 // remove delegated events
340 for (const type in events.delegatedEvents) {
341 const delegated = events.delegatedEvents[type];
342
343 if (delegated.selectors[0] === this.target
344 && delegated.contexts[0] === this._context) {
345
346 delegated.selectors.splice(0, 1);
347 delegated.contexts .splice(0, 1);
348 delegated.listeners.splice(0, 1);
349
350 // remove the arrays if they are empty
351 if (!delegated.selectors.length) {
352 delegated[type] = null;
353 }
354 }
355
356 events.remove(this._context, type, events.delegateListener);
357 events.remove(this._context, type, events.delegateUseCapture, true);
358 }
359 }
360 else {
361 events.remove(this, 'all');
362 }
363
364 signals.fire('unset', { interactable: this });
365
366 scope.interactables.splice(scope.interactables.indexOf(this), 1);
367
368 // Stop related interactions when an Interactable is unset
369 for (const interaction of scope.interactions || []) {
370 if (interaction.target === this && interaction.interacting() && !interaction._ending) {
371 interaction.stop();
372 }
373 }
374
375 return scope.interact;
376 }
377}
378
379scope.interactables.indexOfElement = function indexOfElement (target, context) {
380 context = context || scope.document;
381
382 for (let i = 0; i < this.length; i++) {
383 const interactable = this[i];
384
385 if (interactable.target === target && interactable._context === context) {
386 return i;
387 }
388 }
389 return -1;
390};
391
392scope.interactables.get = function interactableGet (element, options, dontCheckInContext) {
393 const ret = this[this.indexOfElement(element, options && options.context)];
394
395 return ret && (is.string(element) || dontCheckInContext || ret.inContext(element))? ret : null;
396};
397
398scope.interactables.forEachMatch = function (element, callback) {
399 for (const interactable of this) {
400 let ret;
401
402 if ((is.string(interactable.target)
403 // target is a selector and the element matches
404 ? (is.element(element) && matchesSelector(element, interactable.target))
405 // target is the element
406 : element === interactable.target)
407 // the element is in context
408 && (interactable.inContext(element))) {
409 ret = callback(interactable);
410 }
411
412 if (ret !== undefined) {
413 return ret;
414 }
415 }
416};
417
418// all interact.js eventTypes
419Interactable.eventTypes = scope.eventTypes = [];
420
421Interactable.signals = signals;
422
423Interactable.settingsMethods = [ 'deltaSource', 'origin', 'preventDefault', 'rectChecker' ];
424
425module.exports = Interactable;