diff --git a/h5p.classes.php b/h5p.classes.php index b6d3b37..c54a92c 100644 --- a/h5p.classes.php +++ b/h5p.classes.php @@ -14,6 +14,7 @@ interface H5PFrameworkInterface { * - h5pVersion: The version of the H5P plugin/module */ public function getPlatformInfo(); + /** * Fetches a file from a remote server using HTTP GET @@ -1589,7 +1590,8 @@ class H5PCore { public static $scripts = array( 'js/jquery.js', 'js/h5p.js', - 'js/h5p-event-dispatcher.js', + 'js/event-dispatcher.js', + 'js/x-api.js', ); public static $adminScripts = array( 'js/jquery.js', @@ -2645,6 +2647,9 @@ class H5PContentValidator { $found = FALSE; foreach ($semantics->fields as $field) { if ($field->name == $key) { + if (isset($semantics->optional) && $semantics->optional) { + $field->optional = TRUE; + } $function = $this->typeMap[$field->type]; $found = TRUE; break; @@ -2669,11 +2674,13 @@ class H5PContentValidator { } } } - foreach ($semantics->fields as $field) { - if (!(isset($field->optional) && $field->optional)) { - // Check if field is in group. - if (! property_exists($group, $field->name)) { - $this->h5pF->setErrorMessage($this->h5pF->t('No value given for mandatory field ' . $field->name)); + if (!(isset($semantics->optional) && $semantics->optional)) { + foreach ($semantics->fields as $field) { + if (!(isset($field->optional) && $field->optional)) { + // Check if field is in group. + if (! property_exists($group, $field->name)) { + $this->h5pF->setErrorMessage($this->h5pF->t('No value given for mandatory field ' . $field->name)); + } } } } diff --git a/js/h5p-event-dispatcher.js b/js/event-dispatcher.js similarity index 53% rename from js/h5p-event-dispatcher.js rename to js/event-dispatcher.js index fb7f5ef..7a86962 100644 --- a/js/h5p-event-dispatcher.js +++ b/js/event-dispatcher.js @@ -1,8 +1,13 @@ /** @namespace H5P */ var H5P = H5P || {}; -H5P.EventDispatcher = (function () { +H5P.Event = function(type, data) { + this.type = type; + this.data = data; +}; +H5P.EventDispatcher = (function () { + /** * The base of the event system. * Inherit this class if you want your H5P to dispatch events. @@ -12,11 +17,11 @@ H5P.EventDispatcher = (function () { var self = this; /** - * Keep track of events and listeners for each event. + * Keep track of listeners for each event. * @private * @type {Object} */ - var events = {}; + var triggers = {}; /** * Add new event listener. @@ -26,21 +31,24 @@ H5P.EventDispatcher = (function () { * @param {String} type Event type * @param {Function} listener Event listener */ - self.on = function (type, listener) { + self.on = function (type, listener, scope) { + if (scope === undefined) { + scope = self; + } if (!(listener instanceof Function)) { throw TypeError('listener must be a function'); } // Trigger event before adding to avoid recursion - self.trigger('newListener', type, listener); + self.trigger('newListener', {'type': type, 'listener': listener}); - if (!events[type]) { + if (!triggers[type]) { // First - events[type] = [listener]; + triggers[type] = [{'listener': listener, 'scope': scope}]; } else { // Append - events[type].push(listener); + triggers[type].push({'listener': listener, 'scope': scope}); } }; @@ -52,17 +60,20 @@ H5P.EventDispatcher = (function () { * @param {String} type Event type * @param {Function} listener Event listener */ - self.once = function (type, listener) { + self.once = function (type, listener, scope) { + if (scope === undefined) { + scope = self; + } if (!(listener instanceof Function)) { throw TypeError('listener must be a function'); } - var once = function () { - self.off(type, once); - listener.apply(self, arguments); + var once = function (event) { + self.off(event, once); + listener.apply(scope, event); }; - self.on(type, once); + self.on(type, once, scope); }; /** @@ -79,74 +90,58 @@ H5P.EventDispatcher = (function () { throw TypeError('listener must be a function'); } - if (events[type] === undefined) { + if (triggers[type] === undefined) { return; } if (listener === undefined) { // Remove all listeners - delete events[type]; + delete triggers[type]; self.trigger('removeListener', type); return; } // Find specific listener - for (var i = 0; i < events[type].length; i++) { - if (events[type][i] === listener) { - events[type].unshift(i, 1); - self.trigger('removeListener', type, listener); + for (var i = 0; i < triggers[type].length; i++) { + if (triggers[type][i].listener === listener) { + triggers[type].unshift(i, 1); + self.trigger('removeListener', type, {'listener': listener}); break; } } // Clean up empty arrays - if (!events[type].length) { - delete events[type]; + if (!triggers[type].length) { + delete triggers[type]; } }; - /** - * Creates a copy of the arguments list. Skips the given number of arguments. - * - * @private - * @param {Array} args List of arguments - * @param {Number} skip Number of arguments to skip - * @param {Array} Copy og arguments list - */ - var getArgs = function (args, skip) { - var left = []; - for (var i = skip; i < args.length; i++) { - left.push(args[i]); - } - return left; - }; - /** * Dispatch event. * * @public - * @param {String} type Event type - * @param {...*} args + * @param {String|Function} + * */ - self.trigger = function (type) { - if (self.debug !== undefined) { - // Class has debug enabled. Log events. - console.log(self.debug + ' - Firing event "' + type + '", ' + (events[type] === undefined ? 0 : events[type].length) + ' listeners.', getArgs(arguments, 1)); - } - - if (events[type] === undefined) { + self.trigger = function (event, eventData) { + if (event === undefined) { + return; + } + if (typeof event === 'string') { + event = new H5P.Event(event, eventData); + } + else if (eventData !== undefined) { + event.data = eventData; + } + if (triggers[event.type] === undefined) { return; } - - // Copy all arguments except the first - var args = getArgs(arguments, 1); - // Call all listeners - for (var i = 0; i < events[type].length; i++) { - events[type][i].apply(self, args); + for (var i = 0; i < triggers[event.type].length; i++) { + triggers[event.type][i].listener.call(triggers[event.type][i].scope, event); } }; } return EventDispatcher; -})(); +})(); \ No newline at end of file diff --git a/js/h5p-embed.js b/js/h5p-embed.js index b1b58c3..deeab96 100644 --- a/js/h5p-embed.js +++ b/js/h5p-embed.js @@ -90,7 +90,7 @@ var H5P = H5P || (function () { loadContent(contentId, h5ps[i]); contentId++; } - }; + } /** * Return integration object diff --git a/js/h5p.js b/js/h5p.js index d53f8e2..5df3f2b 100644 --- a/js/h5p.js +++ b/js/h5p.js @@ -8,6 +8,8 @@ H5P.isFramed = (window.self !== window.top); // Useful jQuery object. H5P.$window = H5P.jQuery(window); +H5P.instances = []; + // Detect if we support fullscreen, and what prefix to use. if (document.documentElement.requestFullScreen) { H5P.fullScreenBrowserPrefix = ''; @@ -93,13 +95,6 @@ H5P.init = function () { // Keep track of when we started H5P.opened[contentId] = new Date(); - // Handle events when the user finishes the content. Useful for logging exercise results. - instance.$.on('finish', function (event) { - if (event.data !== undefined) { - H5P.setFinished(contentId, event.data.score, event.data.maxScore, event.data.time); - } - }); - if (H5P.isFramed) { // Make it possible to resize the iframe when the content changes size. This way we get no scrollbars. var iframe = window.parent.document.getElementById('h5p-iframe-' + contentId); @@ -123,28 +118,32 @@ H5P.init = function () { }; var resizeDelay; - instance.$.on('resize', function () { + H5P.on(instance, 'resize', function () { // Use a delay to make sure iframe is resized to the correct size. clearTimeout(resizeDelay); resizeDelay = setTimeout(function () { resizeIframe(); }, 1); }); + H5P.instances.push(instance); } + H5P.on(instance, 'xAPI', H5P.xAPIListener); + H5P.on(instance, 'xAPI', H5P.xAPIEmitter); + // Resize everything when window is resized. $window.resize(function () { if (window.parent.H5P.isFullscreen) { // Use timeout to avoid bug in certain browsers when exiting fullscreen. Some browser will trigger resize before the fullscreenchange event. - instance.$.trigger('resize'); + H5P.trigger(instance, 'resize'); } else { - instance.$.trigger('resize'); + H5P.trigger(instance, 'resize'); } }); // Resize content. - instance.$.trigger('resize'); + H5P.trigger(instance, 'resize'); }); // Insert H5Ps that should be in iframes. @@ -156,6 +155,28 @@ H5P.init = function () { }); }; +/* + * TODO xAPI: + * 1. Create a xAPI.js file and move xAPI code there (public) + * 2. Be able to listen for events from both div and iframe embedded content + * via the same API (this is about adding communication between the iframe and + * it's parent and make sure that the parent distributes the events from the + * iframe) (public) + * 3. Create a separate Drupal module that is able to listen for events from + * both div and iframe embedded content and send them to analytics (custom for Zavango) + * 4. Move the event system code to a separate file (public) + * 5. Make sure the helper functions provides all the relevant data, example values + * and time spent (public) + * 6. Add documentation to the functions (public) + * 7. Add xAPI events to all the basic questiontype: + * 7.1 Multichoice + * 7.2 Fill in the blanks + * 7.3 Drag and drop + * 7.4 Drag the words + * 7.5 Mark the words + * 8. Add xAPI events to interactive video + */ + /** * Enable full screen for the given h5p. * @@ -208,8 +229,8 @@ H5P.fullScreen = function ($element, instance, exitCallback, body) { */ var entered = function () { // Do not rely on window resize events. - instance.$.trigger('resize'); - instance.$.trigger('focus'); + H5P.trigger(instance, 'resize'); + H5P.trigger(instance, 'focus'); }; /** @@ -223,8 +244,8 @@ H5P.fullScreen = function ($element, instance, exitCallback, body) { $classes.removeClass(classes); // Do not rely on window resize events. - instance.$.trigger('resize'); - instance.$.trigger('focus'); + H5P.trigger(instance, 'resize'); + H5P.trigger(instance, 'focus'); if (exitCallback !== undefined) { exitCallback(); @@ -353,6 +374,7 @@ H5P.classFromName = function (name) { * @param {Number} contentId * @param {jQuery} $attachTo An optional element to attach the instance to. * @param {Boolean} skipResize Optionally skip triggering of the resize event after attaching. + * @param {Object} The parent of this H5P * @return {Object} Instance. */ H5P.newRunnable = function (library, contentId, $attachTo, skipResize) { @@ -387,17 +409,27 @@ H5P.newRunnable = function (library, contentId, $attachTo, skipResize) { } var instance = new constructor(library.params, contentId); - + if (instance.$ === undefined) { instance.$ = H5P.jQuery(instance); } + // Make xAPI events bubble +// if (parent !== null && parent.trigger !== undefined) { +// instance.on('xAPI', parent.trigger); +// } + + // Automatically call resize on resize event if defined + if (typeof instance.resize === 'function') { + H5P.on(instance, 'resize', instance.resize); + } + if ($attachTo !== undefined) { instance.attach($attachTo); if (skipResize === undefined || !skipResize) { // Resize content. - instance.$.trigger('resize'); + H5P.trigger(instance, 'resize'); } } return instance; @@ -1055,7 +1087,7 @@ H5P.setFinished = function (contentId, score, maxScore, time) { var toUnix = function (date) { return Math.round(date.getTime() / 1000); }; - + // Post the results // TODO: Should we use a variable with the complete path? H5P.jQuery.post(H5P.ajaxPath + 'setFinished', { @@ -1099,3 +1131,48 @@ if (H5P.jQuery) { } }); } + +/** + * Trigger an event on an instance + * + * Helper function that triggers an event if the instance supports event handling + * + * @param {function} instance + * An H5P instance + * @param {string} eventType + * The event type + */ +H5P.trigger = function(instance, eventType) { + // Try new event system first + if (instance.trigger !== undefined) { + instance.trigger(eventType); + } + // Try deprecated event system + else if (instance.$ !== undefined && instance.$.trigger !== undefined) { + instance.$.trigger(eventType) + } +}; + +/** + * Register an event handler + * + * Helper function that registers an event handler for an event type if + * the instance supports event handling + * + * @param {function} instance + * An h5p instance + * @param {string} eventType + * The event type + * @param {function} handler + * Callback that gets triggered for events of the specified type + */ +H5P.on = function(instance, eventType, handler) { + // Try new event system first + if (instance.on !== undefined) { + instance.on(eventType, handler); + } + // Try deprecated event system + else if (instance.$ !== undefined && instance.$.on !== undefined) { + instance.$.on(eventType, handler) + } +}; \ No newline at end of file diff --git a/js/x-api.js b/js/x-api.js new file mode 100644 index 0000000..e94adf4 --- /dev/null +++ b/js/x-api.js @@ -0,0 +1,173 @@ +var H5P = H5P || {}; + +H5P.xAPIListener = function(event) { + var statement = event.data.statement; + if ('verb' in statement) { + if (statement.verb.id === 'http://adlnet.gov/expapi/verbs/completed') { + var score = statement.result.score.raw; + var maxScore = statement.result.score.max; + var contentId = statement.object.extensions['http://h5p.org/x-api/h5p-local-content-id']; + H5P.setFinished(contentId, score, maxScore); + } + } +}; + +H5P.xAPIEmitter = function (event) { + if (event.data.statement !== undefined) { + for (var i = 0; i < H5P.xAPIListeners.length; i++) { + H5P.xAPIListeners[i](event.data.statement); + } + } +}; + +H5P.xAPIListeners = []; + +H5P.onXAPI = function(listener) { + H5P.xAPIListeners.push(listener); +}; + +H5P.onXAPI(function(statement) { + console.log(statement); +}); + +H5P.XAPIEvent = function() { + H5P.Event.call(this, 'xAPI', {'statement': {}}); +}; + +H5P.XAPIEvent.prototype = Object.create(H5P.Event.prototype); +H5P.XAPIEvent.prototype.constructor = H5P.XAPIEvent; + +H5P.XAPIEvent.prototype.setScoredResult = function(score, maxScore) { + this.data.statement.result = { + 'score': { + 'min': 0, + 'max': maxScore, + 'raw': score + } + }; +}; + +H5P.XAPIEvent.prototype.setVerb = function(verb) { + if (H5P.jQuery.inArray(verb, H5P.XAPIEvent.allowedXAPIVerbs) !== -1) { + this.data.statement.verb = { + 'id': 'http://adlnet.gov/expapi/verbs/' + verb, + 'display': { + 'en-US': verb + } + }; + } + else { + console.log('illegal verb'); + } + // Else: Fail silently... +}; + +H5P.XAPIEvent.prototype.getVerb = function(full) { + var statement = this.data.statement; + if ('verb' in statement) { + if (full === true) { + return statement.verb; + } + return statement.verb.id.slice(31); + } + else { + return null; + } +} + +H5P.XAPIEvent.prototype.setObject = function(instance) { + this.data.statement.object = { + // TODO: Correct this. contentId might be vid + 'id': window.location.origin + Drupal.settings.basePath + 'node/' + instance.contentId, + 'objectType': 'Activity', + 'extensions': { + 'http://h5p.org/x-api/h5p-local-content-id': instance.contentId + } + }; +}; + +H5P.XAPIEvent.prototype.setActor = function() { + this.data.statement.actor = H5P.getActor(); +}; + +H5P.XAPIEvent.prototype.getMaxScore = function() { + return this.getVerifiedStatementValue(['result', 'score', 'max']); +}; + +H5P.XAPIEvent.prototype.getScore = function() { + return this.getVerifiedStatementValue(['result', 'score', 'raw']); +}; + +H5P.XAPIEvent.prototype.getVerifiedStatementValue = function(keys) { + var val = this.data.statement; + for (var i in keys) { + if (val[keys[i]] === undefined) { + return null; + } + val = val[keys[i]]; + } + return val; +} + +H5P.XAPIEvent.allowedXAPIVerbs = [ + 'answered', + 'asked', + 'attempted', + 'attended', + 'commented', + 'completed', + 'exited', + 'experienced', + 'failed', + 'imported', + 'initialized', + 'interacted', + 'launched', + 'mastered', + 'passed', + 'preferred', + 'progressed', + 'registered', + 'responded', + 'resumed', + 'scored', + 'shared', + 'suspended', + 'terminated', + 'voided' +]; + +H5P.EventDispatcher.prototype.triggerXAPI = function(verb, extra) { + this.trigger(this.createXAPIEventTemplate(verb, extra)); +}; + +H5P.EventDispatcher.prototype.createXAPIEventTemplate = function(verb, extra) { + var event = new H5P.XAPIEvent(); + + event.setActor(); + event.setVerb(verb); + if (extra !== undefined) { + for (var i in extra) { + event.data.statement[i] = extra[i]; + } + } + if (!('object' in event)) { + event.setObject(this); + } + return event; +}; + +H5P.EventDispatcher.prototype.triggerXAPICompleted = function(score, maxScore) { + var event = this.createXAPIEventTemplate('completed'); + event.setScoredResult(score, maxScore); + this.trigger(event); +} + +H5P.getActor = function() { + var user = H5PIntegration.getUser(); + return { + 'name': user.name, + 'mbox': 'mailto:' + user.mail, + 'objectType': 'Agent' + }; +}; \ No newline at end of file