/** * @file js/classes/Handler.js * * Copyright (c) 2014-2018 Simon Fraser University * Copyright (c) 2000-2018 John Willinsky * Distributed under the GNU GPL v2. For full terms see the file docs/COPYING. * * @class Handler * @ingroup js_classes * * @brief Base class for handlers bound to a DOM HTML element. */ /*global _, pkp */ (function($) { /** * @constructor * * @extends $.pkp.classes.ObjectProxy * * @param {jQueryObject} $element A DOM element to which * this handler is bound. * @param {Object} options Handler options. */ $.pkp.classes.Handler = function($element, options) { var $parents, self, i; // Check whether a single element was passed in. if ($element.length > 1) { throw new Error('jQuery selector contained more than one handler!'); } // Save a pointer to the bound element in the handler. this.$htmlElement_ = $element; // Check whether a handler has already been bound // to the element. if (this.data('handler') !== undefined) { throw new Error(['The handler "', this.getObjectName(), '" has already been bound to the selected element!'].join('')); } // Initialize object properties. this.eventBindings_ = { }; this.dataItems_ = { }; this.publishedEvents_ = { }; this.handlerChildren_ = []; this.globalEventListeners_ = { }; // Register this handler with a parent handler if one is found. This // allows global events to be de-registered when a parent handler is // refreshed. $parents = this.$htmlElement_.parents(); self = this; $parents.each(function(i) { if ($.pkp.classes.Handler.hasHandler($($parents[i]))) { $.pkp.classes.Handler.getHandler($($parents[i])) .handlerChildren_.push(self); return; // only attach to the closest parent handler } }); if (options.eventBridge) { // Configure the event bridge. this.eventBridge_ = options.eventBridge; } // The "publishChangeEvents" option can be used to specify // a list of event names that will also be published upon // content change. if (options.publishChangeEvents) { this.publishChangeEvents_ = options.publishChangeEvents; for (i = 0; i < this.publishChangeEvents_.length; i++) { this.publishEvent(this.publishChangeEvents_[i]); } } else { this.publishChangeEvents_ = []; } // Bind the handler to the DOM element. this.data('handler', this); }; // // Private properties // /** * Optional list of publication events. * @private * @type {Array} */ $.pkp.classes.Handler.prototype.publishChangeEvents_ = null; /** * The HTML element this handler is bound to. * @private * @type {jQueryObject} */ $.pkp.classes.Handler.prototype.$htmlElement_ = null; /** * A list of event bindings for this handler. * @private * @type {Object.} */ $.pkp.classes.Handler.prototype.eventBindings_ = null; /** * A list of data items bound to the DOM element * managed by this handler. * @private * @type {Object.} */ $.pkp.classes.Handler.prototype.dataItems_ = null; /** * A list of published events. * @private * @type {Object.} */ $.pkp.classes.Handler.prototype.publishedEvents_ = null; /** * An HTML element id to which we'll forward all handler events. * @private * @type {?string} */ $.pkp.classes.Handler.prototype.eventBridge_ = null; /** * Global event bindings. These are tracked so they can be deregistered when * the handler is destroyed. * @private * @type {Object} */ $.pkp.classes.Handler.prototype.globalEventListeners_ = null; // // Public static methods // /** * Retrieve the bound handler from the jQuery element. * @param {jQueryObject} $element The element to which the * handler was attached. * @return {Object} The retrieved handler. */ $.pkp.classes.Handler.getHandler = function($element) { // Retrieve the handler. We cannot do this with our own // data() method because this method cannot be called // in the context of the handler if it's purpose is to // retrieve the handler. This should be the only place // at all where we have to do access element data // directly. var handler = $element.data('pkp.handler'); // Check whether the handler exists. if (!(handler instanceof $.pkp.classes.Handler)) { throw new Error('There is no handler bound to this element!'); } return handler; }; /** * Check if a jQuery element has a handler bound to it * * @param {jQueryObject} $element The element to check for a handler * @return {boolean} */ $.pkp.classes.Handler.hasHandler = function($element) { return $element.data('pkp.handler') instanceof $.pkp.classes.Handler; }; // // Public methods // /** * Returns the HTML element this handler is bound to. * * @return {jQueryObject} The element this handler is bound to. */ $.pkp.classes.Handler.prototype.getHtmlElement = function() { $.pkp.classes.Handler.checkContext_(this); // Return the HTML element. return this.$htmlElement_; }; /** * Publish change events. (See options.publishChangeEvents.) */ $.pkp.classes.Handler.prototype.publishChangeEvents = function() { var i; for (i = 0; i < this.publishChangeEvents_.length; i++) { this.trigger(this.publishChangeEvents_[i]); } }; /** * A generic event dispatcher that will be bound to * all handler events. See bind() above. * * @this {HTMLElement} * @param {jQuery.Event} event The jQuery event object. * @return {boolean} Return value to be passed back * to jQuery. */ $.pkp.classes.Handler.prototype.handleEvent = function(event) { var $callingElement, handler, boundEvents, args, returnValue, i, l; // This handler is always called out of the // handler context. $callingElement = $(this); // Identify the targeted handler. handler = $.pkp.classes.Handler.getHandler($callingElement); // Make sure that we really got the right element. if ($callingElement[0] !== handler.getHtmlElement.call(handler)[0]) { throw new Error(['An invalid handler is bound to the calling ', 'element of an event!'].join('')); } // Retrieve the event handlers for the given event type. boundEvents = handler.eventBindings_[event.type]; if (boundEvents === undefined) { // We have no handler for this event but we also // don't allow bubbling of events outside of the // GUI widget! return false; } // Call all event handlers. args = $.makeArray(arguments); returnValue = true; args.unshift(this); for (i = 0, l = boundEvents.length; i < l; i++) { // Invoke the event handler in the context // of the handler object. if (boundEvents[i].apply(handler, args) === false) { // False overrides true. returnValue = false; } // Stop immediately if one of the handlers requests this. if (event.isImmediatePropagationStopped()) { break; } } // We do not allow bubbling of events outside of the GUI widget! event.stopPropagation(); // Return the event handler status. return returnValue; }; /** * Create a closure that calls the callback in the * context of the handler object. * * NB: Always make sure that the callback is properly * unbound and freed for garbage collection. Otherwise * you might create a memory leak. If you want to bind * an event to the HTMLElement handled by this handler * then always use the above bind() method instead which * is safer. * * @param {Function} callback The callback to be wrapped. * @param {Object=} opt_context Specifies the object which * |this| should point to when the function is run. * If the value is not given, the context will default * to the handler object. * @return {Function} The wrapped callback. */ $.pkp.classes.Handler.prototype.callbackWrapper = function(callback, opt_context) { $.pkp.classes.Handler.checkContext_(this); // Create a closure that calls the event handler // in the right context. if (!opt_context) { opt_context = this; } return function() { var args; args = $.makeArray(arguments); args.unshift(this); return callback.apply(opt_context, args); }; }; /** * This callback can be used to handle simple remote server requests. * * @param {Object} ajaxOptions AJAX options. * @param {Object} jsonData A JSON object. * @return {Object|boolean} The parsed JSON data if no error occurred, * otherwise false. */ $.pkp.classes.Handler.prototype.remoteResponse = function(ajaxOptions, jsonData) { return this.handleJson(jsonData); }; /** * Completely remove all traces of the handler from the * HTML element to which it is bound and leave the element in * its previous state. * * Subclasses should override this method if necessary but * should always call this implementation. */ $.pkp.classes.Handler.prototype.remove = function() { $.pkp.classes.Handler.checkContext_(this); var $element, key; // Remove all event handlers in our namespace. $element = this.getHtmlElement(); $element.unbind('.pkpHandler'); // Remove all our data items except for the // handler itself. for (key in this.dataItems_) { if (key !== 'pkp.handler') { $element.removeData(key); } } // Trigger the remove event, then delete it. $element.trigger('pkpRemoveHandler'); $element.unbind('.pkpHandlerRemove'); // Delete the handler. $element.removeData('pkp.handler'); }; /** * This function should be used to pre-process a JSON response * from the server. * * @param {Object} jsonData The returned server response data. * @return {Object|boolean} The returned server response data or * false if an error occurred. */ $.pkp.classes.Handler.prototype.handleJson = function(jsonData) { if (!jsonData) { throw new Error('Server error: Server returned no or invalid data!'); } if (jsonData.status === true) { // Trigger events passed from the server _.each((/** @type {{ events: Object }} */ jsonData).events, function(event) { /** @type {{isGlobalEvent: boolean}} */ var eventData = _.has(event, 'data') ? event.data : null; if (!_.isNull(eventData) && eventData.isGlobalEvent) { eventData.handler = this; pkp.eventBus.$emit(event.name, eventData); } else { this.trigger(event.name, eventData); } }, this); return jsonData; } else { // If we got an error message then display it. if (jsonData.content) { alert(jsonData.content); } return false; } }; // // Protected methods // /** * Sets the HTML element this handler is bound to. * * @protected * @param {jQueryObject} $htmlElement The element this handler should be bound * to. * @return {jQueryObject} Passes through the supplied parameter. */ $.pkp.classes.Handler.prototype.setHtmlElement = function($htmlElement) { $.pkp.classes.Handler.checkContext_(this); // Return the HTML element. this.$htmlElement_ = $htmlElement; return $htmlElement; }; /** * Bind an event to a handler operation. * * This will be done with a generic event handler * to make sure that we get a chance to re-set * 'this' to the handler before we call the actual * handler method. * * @protected * @param {string} eventName The name of the event * to be bound. See jQuery.bind() for event names. * @param {Function} handler The event handler to * be called when the even is triggered. */ $.pkp.classes.Handler.prototype.bind = function(eventName, handler) { $.pkp.classes.Handler.checkContext_(this); if (!this.eventBindings_[eventName]) { // Initialize the event store for this event. this.eventBindings_[eventName] = []; // Determine the event namespace. var eventNamespace; eventNamespace = '.pkpHandler'; if (eventName === 'pkpRemoveHandler') { // We have a special namespace for the remove event // because it needs to be triggered when all other // events have already been removed. eventNamespace = '.pkpHandlerRemove'; } // Bind the generic event handler to the event within our namespace. this.getHtmlElement().bind(eventName + eventNamespace, this.handleEvent); } // Store the event binding internally this.eventBindings_[eventName].push(handler); }; /** * Unbind an event from a handler operation. * * @protected * @param {string} eventName The name of the event * to be bound. See jQuery.bind() for event names. * @param {Function} handler The event handler to * be called when the even is triggered. * @return {boolean} True, if a handler was found and * removed, otherwise false. */ $.pkp.classes.Handler.prototype.unbind = function(eventName, handler) { $.pkp.classes.Handler.checkContext_(this); // Remove the event from the internal event cache. if (!this.eventBindings_[eventName]) { return false; } var i, length; for (i = 0, length = this.eventBindings_[eventName].length; i < length; i++) { if (this.eventBindings_[eventName][i] === handler) { this.eventBindings_[eventName].splice([i], 1); break; } } if (this.eventBindings_[eventName].length === 0) { // If this was the last event then unbind the generic event handler. delete this.eventBindings_[eventName]; this.getHtmlElement().unbind(eventName, this.handleEvent); } return true; }; /** * Bind a global event to a handler operation. * * Binds a callback function to fire when a global event is triggered on * the global event router. * * @param {string} eventName The name of the event to bind to. * @param {Function} callback The function to firewhen the event is triggered */ $.pkp.classes.Handler.prototype.bindGlobal = function(eventName, callback) { if (typeof this.globalEventListeners_[eventName] === 'undefined') { this.globalEventListeners_[eventName] = []; } var wrapper = this.callbackWrapper(callback); this.globalEventListeners_[eventName].push(wrapper); pkp.eventBus.$on(eventName, wrapper); }; /** * Unbind a global event from a handler operation. * * If passing a `null` callback, all callbacks bound to eventName by this * handler will be unbound. See: http://backbonejs.org/#Events-off * * @see $.pkp.classes.Handler.prototype.bindGlobal() * @param {string} eventName The name of the event to bind to * @param {Function} callback The function to fire when event is triggered */ $.pkp.classes.Handler.prototype.unbindGlobal = function(eventName, callback) { var wrapper = this.callbackWrapper(callback); if (typeof this.globalEventListeners_[eventName] !== 'undefined') { this.globalEventListeners = _.reject(this.globalEventListeners, function(cb) { return cb === wrapper; }); } pkp.eventBus.$off(eventName, wrapper); }; /** * Unbind all global event listeners on this handler and any child handlers */ $.pkp.classes.Handler.prototype.unbindGlobalAll = function() { if (typeof this.globalEventListeners_ !== 'undefined') { _.each(this.globalEventListeners_, function(callbacks, eventName) { _.each(callbacks, function(callback) { pkp.eventBus.$off(eventName, callback); }); }); } this.globalEventListeners = null; this.unbindGlobalChildren(); }; /** * Unbind all global event listeners on child handlers */ $.pkp.classes.Handler.prototype.unbindGlobalChildren = function() { _.each(this.handlerChildren_, function(childHandler) { // Handler in legacy JS framework if (typeof childHandler.unbindGlobalAll !== 'undefined') { childHandler.unbindGlobalAll(); // Handler in new Vue.js framework } else if (typeof childHandler.$destroy !== 'undefined') { delete pkp.registry._instances[childHandler.id]; childHandler.$destroy(); } }); }; /** * Add or retrieve a data item to/from the DOM element * this handler is managing. * * Always use this method if you want to store data * items. It makes sure that your items will be properly * namespaced and it also guarantees correct garbage * collection of your items once the handler is removed. * * @protected * @param {string} key The name of the item to be stored * or retrieved. * @param {Object=} opt_value The data item to be stored. If no item * is given then the existing value for the given key * will be returned. * @return {Object} The cached data item. */ $.pkp.classes.Handler.prototype.data = function(key, opt_value) { $.pkp.classes.Handler.checkContext_(this); // Namespace the key. key = 'pkp.' + key; if (opt_value !== undefined) { // Add the key to the list of data items // that need to be garbage collected. this.dataItems_[key] = true; } // Add/retrieve the data to/from the // element's data cache. if (arguments.length > 1) { return this.getHtmlElement().data(key, opt_value); } else { return this.getHtmlElement().data(key); } }; /** * This function should be used to let the element emit events * that bubble outside the widget and are published over the * event bridge. * * @protected * @param {string} eventName The event to be triggered. * @param {Array=} opt_data Additional event data. */ $.pkp.classes.Handler.prototype.trigger = function(eventName, opt_data) { if (opt_data === undefined) { opt_data = null; } // Trigger the event on the handled element. var $handledElement = this.getHtmlElement(); $handledElement.triggerHandler(eventName, opt_data); // Trigger the event publicly if it's not // published anyway. if (!this.publishedEvents_[eventName]) { this.triggerPublicEvent_(eventName, opt_data); } }; /** * Publish an event triggered by a nested widget. This event * will bubble outside the widget and will also be published * over the event bridge. * * @param {string} eventName The event name. */ $.pkp.classes.Handler.prototype.publishEvent = function(eventName) { // If the event has been published before then do nothing. if (this.publishedEvents_[eventName]) { return; } // Add the event to the published event list. this.publishedEvents_[eventName] = true; this.bind(eventName, function(context, privateEvent, var_args) { // Retrieve additional event data. var eventData = null; if (arguments.length > 2) { eventData = Array.prototype.slice.call(arguments, 2); } // Re-trigger the private event publicly. this.triggerPublicEvent_(eventName, eventData); }); }; /** * Handle the "show more" and "show less" clicks triggered by the * links in longer text items. * * @param {Event} event The event. */ $.pkp.classes.Handler.prototype.switchViz = function(event) { var eventElement = event.currentTarget; $(eventElement).parent().parent().find('span').toggle(); }; /** * Initialize TinyMCE instances. * * There are instances where TinyMCE is not initialized with the call to * init(). These occur when content is loaded after the fact (via AJAX). * * In these cases, search for richContent fields and initialize them. */ $.pkp.classes.Handler.prototype.initializeTinyMCE = function() { if (typeof tinyMCE !== 'undefined') { var $element = this.getHtmlElement(), elementId = $element.attr('id'), settings = tinyMCE.EditorManager.settings; settings.defaultToolbar = settings.toolbar; $('#' + elementId).find('.richContent').each(function() { var id = /** @type {string} */ ($(this).attr('id')), icon = $('
'), iconParent = $('
'), classes, i, editor, settings = tinyMCE.EditorManager.settings; // Set the extended toolbar, if requested if ($(this).hasClass('extendedRichContent')) { settings.toolbar = settings.richToolbar; } else { settings.toolbar = settings.defaultToolbar; } editor = tinyMCE.EditorManager.createEditor(id, settings).render(); // For localizable text fields add globe and flag icons if ($(this).hasClass('localizable') || $(this).hasClass('flag')) { icon.addClass('mceLocalizationIcon localizable'); icon.attr('id', 'mceLocalizationIcon-' + id); $(this).wrap(iconParent); $(this).parent().append(icon); if ($(this).hasClass('localizable')) { // Add a globe icon to localizable TinyMCE textareas icon.addClass('mceGlobe'); } else if ($(this).hasClass('flag')) { // Add country flag icon to localizable TinyMCE textareas classes = $(this).attr('class').split(' '); if (classes.length) { for (i = 0; i < classes.length; i++) { if (classes[i].match(/^flag_[a-z]{2}_[A-Z]{2}$/)) { icon.addClass(classes[i]); break; } } } } } }); } }; // // Private methods // /** * Trigger a public event. * * Public events will bubble outside the widget and will * also be forwarded through the event bridge if one has * been configured. * * @private * @param {string} eventName The event to be triggered. * @param {Array=} opt_data Additional event data. */ $.pkp.classes.Handler.prototype.triggerPublicEvent_ = function(eventName, opt_data) { // Publish the event. var $handledElement = this.getHtmlElement(); $handledElement.parent().trigger(eventName, opt_data); // If we have an event bridge configured then re-trigger // the event on the target object. if (this.eventBridge_) { $('[id^="' + this.eventBridge_ + '"]').trigger(eventName, opt_data); } }; /** * Wrapper for the jQuery .replaceWith() function. * * This unbinds all global events before replacing the HTML content, to * ensure there are no orphaned event listeners lingering from handlers * which may have been destroyed when the HTML was replaced. * * This function can only be used when the entire handler is replaced. For * replacing parts of a handler, see replacePartialWith(). * * @param {string|jQueryObject} html The HTML content to replace the * current element with */ $.pkp.classes.Handler.prototype.replaceWith = function(html) { this.unbindGlobalAll(); this.getHtmlElement().replaceWith(html); }; /** * Wrapper for the jQuery .replaceWith() function. * * This function works like the .replaceWith() wrapper above, except it * allows you to pass a specific dom element to replace within the Handler. * * This function loops over any handlers found within the $partial dom * element, unbinding global events to ensure there are no orphaned event * listeners when the HTML element is replaced. * * The .replaceWith() function is preferred in most cases. This should only * been used when you _need_ to replace part of a Handler's HTML content. * Full handler refreshes are preferred to keep things simple. Also, this * function isn't very performant, because it requires looping over every * child DOM element. * * @param {string|jQueryObject} html The HTML content to inject into * the $partial * @param {jQueryObject} $partial The HTML element to unbind */ $.pkp.classes.Handler.prototype.replacePartialWith = function(html, $partial) { // Check if the $partial already has a handler bound to it on which // we can call .unbindGlobalAll() instead if ($.pkp.classes.Handler.hasHandler($partial)) { $.pkp.classes.Handler.getHandler($partial).replaceWith(html); return; } this.unbindPartial($partial); $partial.replaceWith(html); }; /** * Wrapper for the jQuery .html() function. * * This unbinds all global events before replacing the inner HTML content. * It differs from the .replaceWith() wrapper function in that the handler's * element is not removed. This means the handler isn't re-initialized, and * so only child handler events need to be unbound. * * @param {string} html The HTML content to inject into the $partial */ $.pkp.classes.Handler.prototype.html = function(html) { this.unbindGlobalChildren(); this.getHtmlElement().html(html); }; /** * This function loops over any handlers found within the $partial dom * element, unbinding global events to ensure there are no orphaned event * listeners when the HTML element is replaced. * * This function isn't very performant. It requires looping over every * element in scope, which could potentially be hundreds or thousands. * This should only be used as a last resort for some handlers which need * to empty out partial content, such as tabs and grids. * * @param {jQueryObject} $partial The HTML element to unbind */ $.pkp.classes.Handler.prototype.unbindPartial = function($partial) { $('*', $partial).each(function() { if ($.pkp.classes.Handler.hasHandler($(this))) { var handler = $.pkp.classes.Handler.getHandler($(this)); handler.callbackWrapper(handler.unbindGlobalAll()); } }); }; // // Private static methods // /** * Check the context of a method invocation. * * @private * @param {Object} context The function context * to be tested. */ $.pkp.classes.Handler.checkContext_ = function(context) { if (!(context instanceof $.pkp.classes.Handler)) { throw new Error('Trying to call handler method in non-handler context!'); } }; /** @param {jQuery} $ jQuery closure. */ }(jQuery));