MDL-61138 javascript: stop duplicate custom events firing
[moodle.git] / lib / amd / src / custom_interaction_events.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16 /**
17  * This module provides a wrapper to encapsulate a lot of the common combinations of
18  * user interaction we use in Moodle.
19  *
20  * @module     core/custom_interaction_events
21  * @class      custom_interaction_events
22  * @package    core
23  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
24  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25  * @since      3.2
26  */
27 define(['jquery', 'core/key_codes'], function($, keyCodes) {
28     // The list of events provided by this module. Namespaced to avoid clashes.
29     var events = {
30         activate: 'cie:activate',
31         keyboardActivate: 'cie:keyboardactivate',
32         escape: 'cie:escape',
33         down: 'cie:down',
34         up: 'cie:up',
35         home: 'cie:home',
36         end: 'cie:end',
37         next: 'cie:next',
38         previous: 'cie:previous',
39         asterix: 'cie:asterix',
40         scrollLock: 'cie:scrollLock',
41         scrollTop: 'cie:scrollTop',
42         scrollBottom: 'cie:scrollBottom',
43         ctrlPageUp: 'cie:ctrlPageUp',
44         ctrlPageDown: 'cie:ctrlPageDown',
45         enter: 'cie:enter',
46     };
47     // Static cache of jQuery events that have been handled. This should
48     // only be populated by JavaScript generated events (which will keep it
49     // fairly small).
50     var triggeredEvents = {};
52     /**
53      * Check if the caller has asked for the given event type to be
54      * registered.
55      *
56      * @method shouldAddEvent
57      * @private
58      * @param {string} eventType name of the event (see events above)
59      * @param {array} include the list of events to be added
60      * @return {bool} true if the event should be added, false otherwise.
61      */
62     var shouldAddEvent = function(eventType, include) {
63         include = include || [];
65         if (include.length && include.indexOf(eventType) !== -1) {
66             return true;
67         }
69         return false;
70     };
72     /**
73      * Check if any of the modifier keys have been pressed on the event.
74      *
75      * @method isModifierPressed
76      * @private
77      * @param {event} e jQuery event
78      * @return {bool} true if shift, meta (command on Mac), alt or ctrl are pressed
79      */
80     var isModifierPressed = function(e) {
81         return (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey);
82     };
84     /**
85      * Trigger the custom event for the given jQuery event.
86      *
87      * This function will only fire the custom event if one hasn't already been
88      * fired for the jQuery event.
89      *
90      * This is to prevent multiple custom event handlers triggering multiple
91      * custom events for a single jQuery event as it bubbles up the stack.
92      *
93      * @param  {string} eventName The name of the custom event
94      * @param  {event} e          The jQuery event
95      * @return {void}
96      */
97     var triggerEvent = function(eventName, e) {
98         var eventTypeKey = "";
100         if (!e.hasOwnProperty('originalEvent')) {
101             // This is a jQuery event generated from JavaScript not a browser event so
102             // we need to build the cache key for the event.
103             eventTypeKey = "" + eventName + e.type + e.timeStamp;
105             if (!triggeredEvents.hasOwnProperty(eventTypeKey)) {
106                 // If we haven't seen this jQuery event before then fire a custom
107                 // event for it and remember the event for later.
108                 triggeredEvents[eventTypeKey] = true;
109                 $(e.target).trigger(eventName, [{originalEvent: e}]);
110             }
111             return;
112         }
114         eventTypeKey = "triggeredCustom_" + eventName;
115         if (!e.originalEvent.hasOwnProperty(eventTypeKey)) {
116             // If this is a jQuery event generated by the browser then set a
117             // property on the original event to track that we've seen it before.
118             // The property is set on the original event because it's the only part
119             // of the jQuery event that is maintained through multiple event handlers.
120             e.originalEvent[eventTypeKey] = true;
121             $(e.target).trigger(eventName, [{originalEvent: e}]);
122             return;
123         }
124     };
126     /**
127      * Register a keyboard event that ignores modifier keys.
128      *
129      * @method addKeyboardEvent
130      * @private
131      * @param {object} element A jQuery object of the element to bind events to
132      * @param {string} event The custom interaction event name
133      * @param {int} keyCode The key code.
134      */
135     var addKeyboardEvent = function(element, event, keyCode) {
136         element.off('keydown.' + event).on('keydown.' + event, function(e) {
137             if (!isModifierPressed(e)) {
138                 if (e.keyCode == keyCode) {
139                     triggerEvent(event, e);
140                 }
141             }
142         });
143     };
145     /**
146      * Trigger the activate event on the given element if it is clicked or the enter
147      * or space key are pressed without a modifier key.
148      *
149      * @method addActivateListener
150      * @private
151      * @param {object} element jQuery object to add event listeners to
152      */
153     var addActivateListener = function(element) {
154         element.off('click.cie.activate').on('click.cie.activate', function(e) {
155             triggerEvent(events.activate, e);
156         });
157         element.off('keydown.cie.activate').on('keydown.cie.activate', function(e) {
158             if (!isModifierPressed(e)) {
159                 if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) {
160                     triggerEvent(events.activate, e);
161                 }
162             }
163         });
164     };
166     /**
167      * Trigger the keyboard activate event on the given element if the enter
168      * or space key are pressed without a modifier key.
169      *
170      * @method addKeyboardActivateListener
171      * @private
172      * @param {object} element jQuery object to add event listeners to
173      */
174     var addKeyboardActivateListener = function(element) {
175         element.off('keydown.cie.keyboardactivate').on('keydown.cie.keyboardactivate', function(e) {
176             if (!isModifierPressed(e)) {
177                 if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) {
178                     triggerEvent(events.keyboardActivate, e);
179                 }
180             }
181         });
182     };
184     /**
185      * Trigger the escape event on the given element if the escape key is pressed
186      * without a modifier key.
187      *
188      * @method addEscapeListener
189      * @private
190      * @param {object} element jQuery object to add event listeners to
191      */
192     var addEscapeListener = function(element) {
193         addKeyboardEvent(element, events.escape, keyCodes.escape);
194     };
196     /**
197      * Trigger the down event on the given element if the down arrow key is pressed
198      * without a modifier key.
199      *
200      * @method addDownListener
201      * @private
202      * @param {object} element jQuery object to add event listeners to
203      */
204     var addDownListener = function(element) {
205         addKeyboardEvent(element, events.down, keyCodes.arrowDown);
206     };
208     /**
209      * Trigger the up event on the given element if the up arrow key is pressed
210      * without a modifier key.
211      *
212      * @method addUpListener
213      * @private
214      * @param {object} element jQuery object to add event listeners to
215      */
216     var addUpListener = function(element) {
217         addKeyboardEvent(element, events.up, keyCodes.arrowUp);
218     };
220     /**
221      * Trigger the home event on the given element if the home key is pressed
222      * without a modifier key.
223      *
224      * @method addHomeListener
225      * @private
226      * @param {object} element jQuery object to add event listeners to
227      */
228     var addHomeListener = function(element) {
229         addKeyboardEvent(element, events.home, keyCodes.home);
230     };
232     /**
233      * Trigger the end event on the given element if the end key is pressed
234      * without a modifier key.
235      *
236      * @method addEndListener
237      * @private
238      * @param {object} element jQuery object to add event listeners to
239      */
240     var addEndListener = function(element) {
241         addKeyboardEvent(element, events.end, keyCodes.end);
242     };
244     /**
245      * Trigger the next event on the given element if the right arrow key is pressed
246      * without a modifier key in LTR mode or left arrow key in RTL mode.
247      *
248      * @method addNextListener
249      * @private
250      * @param {object} element jQuery object to add event listeners to
251      */
252     var addNextListener = function(element) {
253         // Left and right are flipped in RTL mode.
254         var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowLeft : keyCodes.arrowRight;
256         addKeyboardEvent(element, events.next, keyCode);
257     };
259     /**
260      * Trigger the previous event on the given element if the left arrow key is pressed
261      * without a modifier key in LTR mode or right arrow key in RTL mode.
262      *
263      * @method addPreviousListener
264      * @private
265      * @param {object} element jQuery object to add event listeners to
266      */
267     var addPreviousListener = function(element) {
268         // Left and right are flipped in RTL mode.
269         var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowRight : keyCodes.arrowLeft;
271         addKeyboardEvent(element, events.previous, keyCode);
272     };
274     /**
275      * Trigger the asterix event on the given element if the asterix key is pressed
276      * without a modifier key.
277      *
278      * @method addAsterixListener
279      * @private
280      * @param {object} element jQuery object to add event listeners to
281      */
282     var addAsterixListener = function(element) {
283         addKeyboardEvent(element, events.asterix, keyCodes.asterix);
284     };
287     /**
288      * Trigger the scrollTop event on the given element if the user scrolls to
289      * the top of the given element.
290      *
291      * @method addScrollTopListener
292      * @private
293      * @param {object} element jQuery object to add event listeners to
294      */
295     var addScrollTopListener = function(element) {
296         element.off('scroll.cie.scrollTop').on('scroll.cie.scrollTop', function(e) {
297             var scrollTop = element.scrollTop();
298             if (scrollTop === 0) {
299                 triggerEvent(events.scrollTop, e);
300             }
301         });
302     };
304     /**
305      * Trigger the scrollBottom event on the given element if the user scrolls to
306      * the bottom of the given element.
307      *
308      * @method addScrollBottomListener
309      * @private
310      * @param {object} element jQuery object to add event listeners to
311      */
312     var addScrollBottomListener = function(element) {
313         element.off('scroll.cie.scrollBottom').on('scroll.cie.scrollBottom', function(e) {
314             var scrollTop = element.scrollTop();
315             var innerHeight = element.innerHeight();
316             var scrollHeight = element[0].scrollHeight;
318             if (scrollTop + innerHeight >= scrollHeight) {
319                 triggerEvent(events.scrollBottom, e);
320             }
321         });
322     };
324     /**
325      * Trigger the scrollLock event on the given element if the user scrolls to
326      * the bottom or top of the given element.
327      *
328      * @method addScrollLockListener
329      * @private
330      * @param {jQuery} element jQuery object to add event listeners to
331      */
332     var addScrollLockListener = function(element) {
333         // Lock mousewheel scrolling within the element to stop the annoying window scroll.
334         element.off('DOMMouseScroll.cie.DOMMouseScrollLock mousewheel.cie.mousewheelLock')
335             .on('DOMMouseScroll.cie.DOMMouseScrollLock mousewheel.cie.mousewheelLock', function(e) {
336                 var scrollTop = element.scrollTop();
337                 var scrollHeight = element[0].scrollHeight;
338                 var height = element.height();
339                 var delta = (e.type == 'DOMMouseScroll' ?
340                     e.originalEvent.detail * -40 :
341                     e.originalEvent.wheelDelta);
342                 var up = delta > 0;
344                 if (!up && -delta > scrollHeight - height - scrollTop) {
345                     // Scrolling down past the bottom.
346                     element.scrollTop(scrollHeight);
347                     e.stopPropagation();
348                     e.preventDefault();
349                     e.returnValue = false;
350                     // Fire the scroll lock event.
351                     triggerEvent(events.scrollLock, e);
353                     return false;
354                 } else if (up && delta > scrollTop) {
355                     // Scrolling up past the top.
356                     element.scrollTop(0);
357                     e.stopPropagation();
358                     e.preventDefault();
359                     e.returnValue = false;
360                     // Fire the scroll lock event.
361                     triggerEvent(events.scrollLock, e);
363                     return false;
364                 }
366                 return true;
367             });
368     };
370     /**
371      * Trigger the ctrlPageUp event on the given element if the user presses the
372      * control and page up key.
373      *
374      * @method addCtrlPageUpListener
375      * @private
376      * @param {object} element jQuery object to add event listeners to
377      */
378     var addCtrlPageUpListener = function(element) {
379         element.off('keydown.cie.ctrlpageup').on('keydown.cie.ctrlpageup', function(e) {
380             if (e.ctrlKey) {
381                 if (e.keyCode == keyCodes.pageUp) {
382                     triggerEvent(events.ctrlPageUp, e);
383                 }
384             }
385         });
386     };
388     /**
389      * Trigger the ctrlPageDown event on the given element if the user presses the
390      * control and page down key.
391      *
392      * @method addCtrlPageDownListener
393      * @private
394      * @param {object} element jQuery object to add event listeners to
395      */
396     var addCtrlPageDownListener = function(element) {
397         element.off('keydown.cie.ctrlpagedown').on('keydown.cie.ctrlpagedown', function(e) {
398             if (e.ctrlKey) {
399                 if (e.keyCode == keyCodes.pageDown) {
400                     triggerEvent(events.ctrlPageDown, e);
401                 }
402             }
403         });
404     };
406     /**
407      * Trigger the enter event on the given element if the enter key is pressed
408      * without a modifier key.
409      *
410      * @method addEnterListener
411      * @private
412      * @param {object} element jQuery object to add event listeners to
413      */
414     var addEnterListener = function(element) {
415         addKeyboardEvent(element, events.enter, keyCodes.enter);
416     };
418     /**
419      * Get the list of events and their handlers.
420      *
421      * @method getHandlers
422      * @private
423      * @return {object} object key of event names and value of handler functions
424      */
425     var getHandlers = function() {
426         var handlers = {};
428         handlers[events.activate] = addActivateListener;
429         handlers[events.keyboardActivate] = addKeyboardActivateListener;
430         handlers[events.escape] = addEscapeListener;
431         handlers[events.down] = addDownListener;
432         handlers[events.up] = addUpListener;
433         handlers[events.home] = addHomeListener;
434         handlers[events.end] = addEndListener;
435         handlers[events.next] = addNextListener;
436         handlers[events.previous] = addPreviousListener;
437         handlers[events.asterix] = addAsterixListener;
438         handlers[events.scrollLock] = addScrollLockListener;
439         handlers[events.scrollTop] = addScrollTopListener;
440         handlers[events.scrollBottom] = addScrollBottomListener;
441         handlers[events.ctrlPageUp] = addCtrlPageUpListener;
442         handlers[events.ctrlPageDown] = addCtrlPageDownListener;
443         handlers[events.enter] = addEnterListener;
445         return handlers;
446     };
448     /**
449      * Add all of the listeners on the given element for the requested events.
450      *
451      * @method define
452      * @public
453      * @param {object} element the DOM element to register event listeners on
454      * @param {array} include the array of events to be triggered
455      */
456     var define = function(element, include) {
457         element = $(element);
458         include = include || [];
460         if (!element.length || !include.length) {
461             return;
462         }
464         $.each(getHandlers(), function(eventType, handler) {
465             if (shouldAddEvent(eventType, include)) {
466                 handler(element);
467             }
468         });
469     };
471     return /** @module core/custom_interaction_events */ {
472         define: define,
473         events: events,
474     };
475 });