H5P.AdvancedText = (function ($, EventDispatcher) { /** * A simple library for displaying text with advanced styling. * * @class H5P.AdvancedText * @param {Object} parameters * @param {Object} [parameters.text='New text'] * @param {number} id */ function AdvancedText(parameters, id) { var self = this; EventDispatcher.call(this); var html = (parameters.text === undefined ? 'New text' : parameters.text); /** * Wipe container and add text html. * * @alias H5P.AdvancedText#attach * @param {H5P.jQuery} $container */ self.attach = function ($container) { $container.addClass('h5p-advanced-text').html(html); }; } AdvancedText.prototype = Object.create(EventDispatcher.prototype); AdvancedText.prototype.constructor = AdvancedText; return AdvancedText; })(H5P.jQuery, H5P.EventDispatcher); ; H5P.Collage = (function ($, EventDispatcher) { /** * Create a new collage. * * @class H5P.Collage * @extends H5P.EventDispatcher * @param {Object} parameters * @param {number} contentId */ function Collage(parameters, contentId) { var self = this; // Initialize event inheritance EventDispatcher.call(self); // Content defaults setDefaults(parameters, { collage: { template: '2-1', options: { heightRatio: 0.75, spacing: 0.5, frame: true }, clips: [] } }); var content = parameters.collage; var $wrapper; // Create new template for adding clips to var template = new Collage.Template(content.options.spacing); // Add clips to columns template.on('columnAdded', function (event) { var $col = event.data; var clipIndex = self.clips.length; // Set default if (!content.clips[clipIndex]) { content.clips[clipIndex] = {}; } // Add new clip var clip = new Collage.Clip($col, content.clips[clipIndex], contentId); self.clips.push(clip); self.trigger('clipAdded', clip); clip.load(); }); /** * Creates the HTML the first time the collage is attaced. * * @private */ var createHtml = function () { // Create collage wrapper var wrapperOptions = { 'class': 'h5p-collage-wrapper', css: {} }; if (content.options.frame) { wrapperOptions.css.borderWidth = content.options.spacing + 'em'; } $wrapper = $('
', wrapperOptions); // Add template template.appendTo($wrapper); // Render template self.setLayout(content.template); }; /** * Attach the collage to the given container. * * @param {H5P.jQuery} $container */ self.attach = function ($container) { this.triggerConsumed(); if ($wrapper === undefined) { createHtml(); var $parent = $container.parent(); if (!$parent.hasClass('h5p-frame')) { $parent.css('backgroundColor', 'transparent'); } } // Add to DOM $container.addClass('h5p-collage').html('').append($wrapper); }; /** * Trigger the 'consumed' xAPI event when this commences * * (Will be more sophisticated in future version) */ self.triggerConsumed = function () { var xAPIEvent = this.createXAPIEventTemplate({ id: 'http://activitystrea.ms/schema/1.0/consume', display: { 'en-US': 'consumed' } }, { result: { completion: true } }); this.trigger(xAPIEvent); }; /** * Set a new collage layout. * * @param {string} newLayout */ self.setLayout = function (newLayout) { self.clips = []; template.setLayout(newLayout); }; /** * Set the spacing between the collage clips. * * @param {number} newSpacing */ self.setSpacing = function (newSpacing) { template.setSpacing(newSpacing); }; /** * Set the frame around the collage. * * @param {number} newFrameWidth */ self.setFrame = function (newFrameWidth) { $wrapper.css('borderWidth', newFrameWidth + 'em'); }; /** * Set the height / aspect ratio of the collage. * * @param {number} newHeight */ self.setHeight = function (newHeight) { // Update template var wrapperSize = $wrapper[0].getBoundingClientRect(); $wrapper.css('height', (wrapperSize.width * newHeight) + 'px'); }; /** * Handle resize events */ self.on('resize', function () { if ($wrapper === undefined) { return; } // Get outer width without rounding var width = $wrapper[0].getBoundingClientRect().width; $wrapper.css({ fontSize: ((width / 480) * 16) + 'px', height: (content.options.heightRatio * width) + 'px' }); // Position clips for (let i = 0; i < self.clips.length; i++) { if (!self.clips[i].isPositioned()) { self.clips[i].positionImage() } } }); } // Extends the event dispatcher Collage.prototype = Object.create(EventDispatcher.prototype); Collage.prototype.constructor = Collage; /** * Simple recusive function the helps set default values without * destroying object references. * * @param {object} params values * @param {object} values default values */ var setDefaults = function (params, values) { for (var prop in values) { if (values.hasOwnProperty(prop)) { if (params[prop] === undefined) { params[prop] = values[prop]; } else if (params[prop] instanceof Object && !(params[prop] instanceof Array)) { setDefaults(params[prop], values[prop]); } } } }; return Collage; })(H5P.jQuery, H5P.EventDispatcher); ; (function ($, EventDispatcher, Collage) { /** * Collage Template * * @class H5P.Collage.Template * @extends H5P.EventDispatcher * @param {number} spacing * @param {string} layout */ Collage.Template = function (spacing, layout) { var self = this; // Initialize event inheritance EventDispatcher.call(self); // Create template wrapper var $wrapper; // Half the spacing spacing /= 2; // Keep track of our rows var table = []; /** * Add columns to row. * * @private * @param {H5P.jQuery} $row * @param {number} num */ var addCols = function ($row, num) { var cols = []; for (var i = 0; i < num; i++) { // Add column to row var $col = $('
', { 'class': 'h5p-collage-col', css: { width: (100 / num) + '%', borderLeftWidth: (i === 0 ? 0 : spacing + 'em'), borderRightWidth: (i === num - 1 ? 0 : spacing + 'em') }, appendTo: $row }); self.trigger('columnAdded', $col); cols.push($col); } return cols; }; /** * Add rows to wrapper. * * @private * @param {Array} rows */ var addRows = function (rows) { for (var i = 0; i < rows.length; i++) { // Add row to wrapper var $row = $('
', { 'class': 'h5p-collage-row', css: { height: (100 / rows.length) + '%', borderTopWidth: (i === 0 ? 0 : spacing + 'em'), borderBottomWidth: (i === rows.length - 1 ? 0 : spacing + 'em') }, appendTo: $wrapper }); // Add row columns table.push({ $row: $row, cols: addCols($row, Number(rows[i])) }); } }; /** * Append template to given container. * * @param {H5P.jQuery} $container */ self.appendTo = function ($container) { // Create wrapper $wrapper = $('
', { 'class': 'h5p-collage-template' }); // Initialize right away if we have a layout if (layout) { self.setLayout(layout); } // Insert our wrapper into the given container $wrapper.appendTo($container); }; /** * Set a new layout for the template. * * @param {string} newLayout */ self.setLayout = function (newLayout) { $wrapper.html(''); addRows(newLayout.split('-')); }; /** * Set the spacing between the clips. * * @param {number} newSpacing */ self.setSpacing = function (newSpacing) { spacing = newSpacing / 2; // Update table styling for (var i = 0; i < table.length; i++) { var row = table[i]; row.$row.css({ borderTopWidth: (i === 0 ? 0 : spacing + 'em'), borderBottomWidth: (i === table.length - 1 ? 0 : spacing + 'em') }); for (var j = 0; j < row.cols.length; j++) { row.cols[j].css({ borderLeftWidth: (j === 0 ? 0 : spacing + 'em'), borderRightWidth: (j === row.cols.length - 1 ? 0 : spacing + 'em') }); } } }; }; // Extends the event dispatcher Collage.Template.prototype = Object.create(EventDispatcher.prototype); Collage.Template.prototype.constructor = Collage.Template; })(H5P.jQuery, H5P.EventDispatcher, H5P.Collage); ; (function ($, Collage, EventDispatcher) { /** * Collage Clip * * @class H5P.Collage.Clip * @extends H5P.EventDispatcher * @param {H5P.jQuery} $container * @param {Object} content * @param {number} contentId */ Collage.Clip = function ($container, content, contentId) { var self = this; // Initialize event inheritance EventDispatcher.call(self); // Photo wrapper self.$wrapper = $('
', { 'class': 'h5p-collage-photo', appendTo: $container }); // Clip resource var $img; // Always available self.content = content; // Keep track of image has been positioned let isPositioned = false; /** * Position the clip image according to params. */ self.positionImage = function () { if (self.$wrapper[0].offsetParent === null || isPositioned || !$img || !$img.length) { return; // Not visible, position will not be correct } // Determine image ratio const imageRatio = $img[0].width ? ($img[0].width / $img[0].height) : (content.image.width && content.image.height ? content.image.width / content.image.height : null); if (imageRatio === null) { return; // Skip } isPositioned = true; // Find container raioratios var containerSize = window.getComputedStyle(self.$wrapper[0]); var containerRatio = (parseFloat(containerSize.width) / parseFloat(containerSize.height)); // Make sure image covers the whole container if (isNaN(containerRatio) || imageRatio > containerRatio) { self.prop = 'height'; } else { self.prop = 'width'; } $img.css(self.prop, (content.scale * 100) + '%'); // Pan image $img.css('margin', content.offset.top + '% 0 0 ' + content.offset.left + '%'); }; /** * Triggers the loading of the image. */ self.load = function () { if (self.empty()) { self.$wrapper.addClass('h5p-collage-empty'); return; // No image set } else { self.$wrapper.removeClass('h5p-collage-empty'); } // Create image $img = $('', { 'class': 'h5p-collage-image', alt: content.alt, title: content.title, src: H5P.getPath(content.image.path, contentId), prependTo: self.$wrapper, on: { load: function () { // Make sure it's in the correct position self.positionImage(); } } }); setTimeout(function () { // Wait for next tick to make sure everything is visible self.positionImage(); }, 0); self.trigger('change', $img); }; /** * Check if the current clip is empty or set. * * @returns {boolean} */ self.empty = function () { return !content.image; }; /** * Check if the current clip is positioned yet. * * @returns {boolean} */ self.isPositioned = function () { return isPositioned; }; }; // Extends the event dispatcher Collage.Clip.prototype = Object.create(EventDispatcher.prototype); Collage.Clip.prototype.constructor = Collage.Clip; })(H5P.jQuery, H5P.Collage, H5P.EventDispatcher); ; H5P.Column = (function (EventDispatcher) { /** * Column Constructor * * @class * @param {Object} params Describes task behavior * @param {number} id Content identifier * @param {Object} data User specific data to adapt behavior */ function Column(params, id, data) { /** @alias H5P.Column# */ var self = this; // We support events by extending this class EventDispatcher.call(self); // Add defaults params = params || {}; if (params.useSeparators === undefined) { params.useSeparators = true; } this.contentData = data; // Column wrapper element var wrapper; // H5P content in the column var instances = []; var instanceContainers = []; // Number of tasks among instances var numTasks = 0; // Number of tasks that has been completed var numTasksCompleted = 0; // Keep track of result for each task var tasksResultEvent = []; // Keep track of last content's margin state var previousHasMargin; /** * Calculate score and trigger completed event. * * @private */ var completed = function () { // Sum all scores var raw = 0; var max = 0; for (var i = 0; i < tasksResultEvent.length; i++) { var event = tasksResultEvent[i]; raw += event.getScore(); max += event.getMaxScore(); } self.triggerXAPIScored(raw, max, 'completed'); }; /** * Generates an event handler for the given task index. * * @private * @param {number} taskIndex * @return {function} xAPI event handler */ var trackScoring = function (taskIndex) { return function (event) { if (event.getScore() === null) { return; // Skip, not relevant } if (tasksResultEvent[taskIndex] === undefined) { // Update number of completed tasks numTasksCompleted++; } // Keep track of latest event with result tasksResultEvent[taskIndex] = event; // Track progress var progressed = self.createXAPIEventTemplate('progressed'); progressed.data.statement.object.definition.extensions['http://id.tincanapi.com/extension/ending-point'] = taskIndex + 1; self.trigger(progressed); // Check to see if we're done if (numTasksCompleted === numTasks) { // Run this after the current event is sent setTimeout(function () { completed(); // Done }, 0); } }; }; /** * Creates a new ontent instance from the given content parameters and * then attaches it the wrapper. Sets up event listeners. * * @private * @param {Object} content Parameters * @param {Object} [contentData] Content Data */ var addRunnable = function (content, contentData) { // Create container for content var container = document.createElement('div'); container.classList.add('h5p-column-content'); // Content overrides var library = content.library.split(' ')[0]; if (library === 'H5P.Video') { // Prevent video from growing endlessly since height is unlimited. content.params.visuals.fit = false; } // Create content instance var instance = H5P.newRunnable(content, id, undefined, true, contentData); // Bubble resize events bubbleUp(instance, 'resize', self); // Check if instance is a task if (Column.isTask(instance)) { // Tasks requires completion instance.on('xAPI', trackScoring(numTasks)); numTasks++; } if (library === 'H5P.Image' || library === 'H5P.TwitterUserFeed') { // Resize when images are loaded instance.on('loaded', function () { self.trigger('resize'); }); } // Keep track of all instances instances.push(instance); instanceContainers.push({ hasAttached: false, container: container, instanceIndex: instances.length - 1, }); // Add to DOM wrapper wrapper.appendChild(container); }; /** * Help get data for content at given index * * @private * @param {number} index * @returns {Object} Data object with previous state */ var grabContentData = function (index) { var contentData = { parent: self }; if (data.previousState && data.previousState.instances && data.previousState.instances[index]) { contentData.previousState = data.previousState.instances[index]; } return contentData; }; /** * Adds separator before the next content. * * @private * @param {string} libraryName Name of the next content type * @param {string} useSeparator */ var addSeparator = function (libraryName, useSeparator) { // Determine separator spacing var thisHasMargin = (hasMargins.indexOf(libraryName) !== -1); // Only add if previous content exists if (previousHasMargin !== undefined) { // Create separator element var separator = document.createElement('div'); //separator.classList.add('h5p-column-ruler'); // If no margins, check for top margin only if (!thisHasMargin && (hasTopMargins.indexOf(libraryName) === -1)) { if (!previousHasMargin) { // None of them have margin // Only add separator if forced if (useSeparator === 'enabled') { // Add ruler separator.classList.add('h5p-column-ruler'); // Add space both before and after the ruler separator.classList.add('h5p-column-space-before-n-after'); } else { // Default is to separte using a single space, no ruler separator.classList.add('h5p-column-space-before'); } } else { // We don't have any margin but the previous content does // Only add separator if forced if (useSeparator === 'enabled') { // Add ruler separator.classList.add('h5p-column-ruler'); // Add space after the ruler separator.classList.add('h5p-column-space-after'); } } } else if (!previousHasMargin) { // We have margin but not the previous content doesn't // Only add separator if forced if (useSeparator === 'enabled') { // Add ruler separator.classList.add('h5p-column-ruler'); // Add space after the ruler separator.classList.add('h5p-column-space-before'); } } else { // Both already have margin if (useSeparator !== 'disabled') { // Default is to add ruler unless its disabled separator.classList.add('h5p-column-ruler'); } } // Insert into DOM wrapper.appendChild(separator); } // Keep track of spacing for next separator previousHasMargin = thisHasMargin || (hasBottomMargins.indexOf(libraryName) !== -1); }; /** * Creates a wrapper and the column content the first time the column * is attached to the DOM. * * @private */ var createHTML = function () { // Create wrapper wrapper = document.createElement('div'); // Go though all contents for (var i = 0; i < params.content.length; i++) { var content = params.content[i]; // In case the author has created an element without selecting any // library if (content.content === undefined) { continue; } if (params.useSeparators) { // (check for global override) // Add separator between contents addSeparator(content.content.library.split(' ')[0], content.useSeparator); } // Add content addRunnable(content.content, grabContentData(i)); } }; /** * Attach the column to the given container * * @param {H5P.jQuery} $container */ self.attach = function ($container) { if (wrapper === undefined) { // Create wrapper and content createHTML(); } // Attach instances that have not been attached instanceContainers.filter(function (container) { return !container.hasAttached }) .forEach(function (container) { instances[container.instanceIndex] .attach(H5P.jQuery(container.container)); // Remove any fullscreen buttons disableFullscreen(instances[container.instanceIndex]); }); // Add to DOM $container.addClass('h5p-column').html('').append(wrapper); }; /** * Create object containing information about the current state * of this content. * * @return {Object} */ self.getCurrentState = function () { // Get previous state object or create new state object var state = (data.previousState ? data.previousState : {}); if (!state.instances) { state.instances = []; } // Grab the current state for each instance for (var i = 0; i < instances.length; i++) { var instance = instances[i]; if (instance.getCurrentState instanceof Function || typeof instance.getCurrentState === 'function') { state.instances[i] = instance.getCurrentState(); } } // Done return state; }; /** * Get xAPI data. * Contract used by report rendering engine. * * @see contract at {@link https://h5p.org/documentation/developers/contracts#guides-header-6} */ self.getXAPIData = function () { var xAPIEvent = self.createXAPIEventTemplate('answered'); addQuestionToXAPI(xAPIEvent); xAPIEvent.setScoredResult(self.getScore(), self.getMaxScore(), self, true, self.getScore() === self.getMaxScore() ); return { statement: xAPIEvent.data.statement, children: getXAPIDataFromChildren(instances) }; }; /** * Get score for all children * Contract used for getting the complete score of task. * * @return {number} Score for questions */ self.getScore = function () { return instances.reduce(function (prev, instance) { return prev + (instance.getScore ? instance.getScore() : 0); }, 0); }; /** * Get maximum score possible for all children instances * Contract. * * @return {number} Maximum score for questions */ self.getMaxScore = function () { return instances.reduce(function (prev, instance) { return prev + (instance.getMaxScore ? instance.getMaxScore() : 0); }, 0); }; /** * Get answer given * Contract. * * @return {boolean} True, if all answers have been given. */ self.getAnswerGiven = function () { return instances.reduce(function (prev, instance) { return prev && (instance.getAnswerGiven ? instance.getAnswerGiven() : prev); }, true); }; /** * Show solutions. * Contract. */ self.showSolutions = function () { instances.forEach(function (instance) { if (instance.toggleReadSpeaker) { instance.toggleReadSpeaker(true); } if (instance.showSolutions) { instance.showSolutions(); } if (instance.toggleReadSpeaker) { instance.toggleReadSpeaker(false); } }); }; /** * Reset task. * Contract. */ self.resetTask = function () { instances.forEach(function (instance) { if (instance.resetTask) { instance.resetTask(); } }); }; /** * Get instances for all children * TODO: This is not a good interface, we should provide handling needed * handling of the tasks instead of repeating them for each parent... * * @return {Object[]} array of instances */ self.getInstances = function () { return instances; }; /** * Get title, e.g. for xAPI when Column is subcontent. * * @return {string} Title. */ self.getTitle = function () { return H5P.createTitle((self.contentData && self.contentData.metadata && self.contentData.metadata.title) ? self.contentData.metadata.title : 'Column'); }; /** * Add the question itself to the definition part of an xAPIEvent */ var addQuestionToXAPI = function (xAPIEvent) { var definition = xAPIEvent.getVerifiedStatementValue(['object', 'definition']); H5P.jQuery.extend(definition, getxAPIDefinition()); }; /** * Generate xAPI object definition used in xAPI statements. * @return {Object} */ var getxAPIDefinition = function () { var definition = {}; definition.interactionType = 'compound'; definition.type = 'http://adlnet.gov/expapi/activities/cmi.interaction'; definition.description = { 'en-US': '' }; return definition; }; /** * Get xAPI data from sub content types * * @param {Array} of H5P instances * @returns {Array} of xAPI data objects used to build a report */ var getXAPIDataFromChildren = function (children) { return children.map(function (child) { if (typeof child.getXAPIData == 'function') { return child.getXAPIData(); } }).filter(function (data) { return !!data; }); }; // Resize children to fit inside parent bubbleDown(self, 'resize', instances); if (wrapper === undefined) { // Create wrapper and content createHTML(); } self.setActivityStarted(); } Column.prototype = Object.create(EventDispatcher.prototype); Column.prototype.constructor = Column; /** * Makes it easy to bubble events from parent to children * * @private * @param {Object} origin Origin of the Event * @param {string} eventName Name of the Event * @param {Array} targets Targets to trigger event on */ function bubbleDown(origin, eventName, targets) { origin.on(eventName, function (event) { if (origin.bubblingUpwards) { return; // Prevent send event back down. } for (var i = 0; i < targets.length; i++) { targets[i].trigger(eventName, event); } }); } /** * Makes it easy to bubble events from child to parent * * @private * @param {Object} origin Origin of the Event * @param {string} eventName Name of the Event * @param {Object} target Target to trigger event on */ function bubbleUp(origin, eventName, target) { origin.on(eventName, function (event) { // Prevent target from sending event back down target.bubblingUpwards = true; // Trigger event target.trigger(eventName, event); // Reset target.bubblingUpwards = false; }); } /** * Definition of which content types are tasks */ var isTasks = [ 'H5P.ImageHotspotQuestion', 'H5P.Blanks', 'H5P.Essay', 'H5P.SingleChoiceSet', 'H5P.MultiChoice', 'H5P.TrueFalse', 'H5P.DragQuestion', 'H5P.Summary', 'H5P.DragText', 'H5P.MarkTheWords', 'H5P.MemoryGame', 'H5P.QuestionSet', 'H5P.InteractiveVideo', 'H5P.CoursePresentation', 'H5P.DocumentationTool' ]; /** * Check if the given content instance is a task (will give a score) * * @param {Object} instance * @return {boolean} */ Column.isTask = function (instance) { if (instance.isTask !== undefined) { return instance.isTask; // Content will determine self if it's a task } // Go through the valid task names for (var i = 0; i < isTasks.length; i++) { // Check against library info. (instanceof is broken in H5P.newRunnable) if (instance.libraryInfo.machineName === isTasks[i]) { return true; } } return false; } /** * Definition of which content type have margins */ var hasMargins = [ 'H5P.AdvancedText', 'H5P.AudioRecorder', 'H5P.Essay', 'H5P.Link', 'H5P.Accordion', 'H5P.Table', 'H5P.GuessTheAnswer', 'H5P.Blanks', 'H5P.MultiChoice', 'H5P.TrueFalse', 'H5P.DragQuestion', 'H5P.Summary', 'H5P.DragText', 'H5P.MarkTheWords', 'H5P.ImageHotspotQuestion', 'H5P.MemoryGame', 'H5P.Dialogcards', 'H5P.QuestionSet', 'H5P.DocumentationTool' ]; /** * Definition of which content type have top margins */ var hasTopMargins = [ 'H5P.SingleChoiceSet' ]; /** * Definition of which content type have bottom margins */ var hasBottomMargins = [ 'H5P.CoursePresentation', 'H5P.Dialogcards', 'H5P.GuessTheAnswer', 'H5P.ImageSlider' ]; /** * Remove custom fullscreen buttons from sub content. * (A bit of a hack, there should have been some sort of override…) * * @param {Object} instance */ function disableFullscreen(instance) { switch (instance.libraryInfo.machineName) { case 'H5P.CoursePresentation': if (instance.$fullScreenButton) { instance.$fullScreenButton.remove(); } break; case 'H5P.InteractiveVideo': instance.on('controls', function () { if (instance.controls.$fullscreen) { instance.controls.$fullscreen.remove(); } }); break; } } return Column; })(H5P.EventDispatcher); ;