H5P.BranchingQuestion = (function () { function BranchingQuestion(parameters) { var self = this; H5P.EventDispatcher.call(self); this.container = null; let answered; let timestamp; /** * Get closest ancestor of DOM element that matches selector. * * Mimics Element.closest(), workaround for IE11. * * @param {Element} element DOM element. * @param {string} selector CSS selector. * @return {Element|null} Element, if found. Else null. */ const getClosestParent = function (element, selector) { if (!document.documentElement.contains(element)) { return null; } if (!Element.prototype.matches) { Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector; } do { if (element.matches(selector)) { return element; } element = element.parentElement || element.parentNode; } while (element !== null && element.nodeType === 1); return null; }; var createWrapper = function () { var wrapper = document.createElement('div'); wrapper.classList.add('h5p-branching-question'); var icon = document.createElement('img'); icon.classList.add('h5p-branching-question-icon'); icon.src = self.getLibraryFilePath('branching-question-icon.svg'); wrapper.appendChild(icon); return wrapper; }; var appendMultiChoiceSection = function (parameters, wrapper) { var questionWrapper = document.createElement('div'); questionWrapper.classList.add('h5p-multichoice-wrapper'); var title = document.createElement('div'); title.classList.add('h5p-branching-question-title'); if (parameters.branchingQuestion.question) { title.innerHTML = parameters.branchingQuestion.question; } questionWrapper.appendChild(title); const alternatives = parameters.branchingQuestion.alternatives || [] ; alternatives.forEach(function (altParams, index, array) { const alternative = createAlternativeContainer(altParams.text, index); alternative.nextContentId = altParams.nextContentId; // Create feedback screen if it exists const hasFeedback = altParams.feedback && !!( altParams.feedback.title && altParams.feedback.title.trim() || altParams.feedback.subtitle && altParams.feedback.subtitle.trim() || altParams.feedback.image ); if (hasFeedback && altParams.nextContentId !== -1) { alternative.feedbackScreen = createFeedbackScreen( altParams.feedback, alternative.nextContentId, index ); alternative.proceedButton = alternative.feedbackScreen.querySelectorAll('button')[0]; } alternative.hasFeedback = altParams.feedback && !!(hasFeedback || altParams.feedback.endScreenScore !== undefined); alternative.feedback = altParams.feedback; alternative.addEventListener('keyup', function (event) { if (event.which === 13 || event.which === 32) { this.click(); } }); alternative.onclick = function (e) { if (this.feedbackScreen !== undefined) { if (self.container) { self.container.classList.add('h5p-branching-scenario-feedback-dialog'); } wrapper.innerHTML = ''; wrapper.appendChild(this.feedbackScreen); self.parent.trigger('resize'); answered = index; timestamp = new Date().toISOString(); const container = document.querySelector('.h5p-branching-question-container'); if (container.hasAttribute('role')) { container.removeAttribute('role'); container.removeAttribute('aria-labelledby'); } this.feedbackScreen.setAttribute('role', 'dialog'); this.feedbackScreen.setAttribute('aria-labelledby', 'h5p-feedback-content-title'); this.feedbackScreen.setAttribute('aria-describedby', 'h5p-feedback-content-content'); this.proceedButton.focus(); self.triggerXAPI('interacted'); } else { var currentAlt = e.target.classList.contains('.h5p-branching-question-alternative') ? e.target : getClosestParent(e.target, '.h5p-branching-question-alternative'); var alts = questionWrapper.querySelectorAll('.h5p-branching-question-alternative'); var index2; for (var i = 0; i < alts.length; i++) { if (alts[i] === currentAlt) { index2 = +alts[i].getAttribute('data-id'); break; } } answered = index2; timestamp = new Date().toISOString(); var nextScreen = { nextContentId: this.nextContentId, chosenAlternative: index2, }; const currentAltParams = parameters.branchingQuestion.alternatives[index2]; const currentAltHasFeedback = !!(currentAltParams.feedback.title || currentAltParams.feedback.subtitle || currentAltParams.feedback.image || currentAltParams.feedback.endScreenScore !== undefined ); if (index2 >= 0 && currentAltHasFeedback) { nextScreen.feedback = currentAltParams.feedback; } self.trigger('navigated', nextScreen); } }; questionWrapper.appendChild(alternative); }); if (parameters.branchingQuestion.randomize && !questionWrapper.dataset.shuffled) { const alternatives = questionWrapper.querySelectorAll('button.h5p-branching-question-alternative'); const shuffledAlternatives = H5P.shuffleArray(Array.from(alternatives)); // Reorder the alternatives according to shuffledAlternatives shuffledAlternatives.forEach(function (alternative) { questionWrapper.appendChild(alternative); }); // Prevent shuffling more than once questionWrapper.setAttribute('data-shuffled', true); } wrapper.appendChild(questionWrapper); return wrapper; }; var createAlternativeContainer = function (text, id) { var wrapper = document.createElement('button'); wrapper.classList.add('h5p-branching-question-alternative'); wrapper.tabIndex = 0; wrapper.setAttribute('data-id', id); var alternativeText = document.createElement('p'); alternativeText.innerHTML = text; wrapper.appendChild(alternativeText); return wrapper; }; var createFeedbackScreen = function (feedback, nextContentId, chosenAlternativeIndex) { var wrapper = document.createElement('div'); wrapper.classList.add('h5p-branching-question'); wrapper.classList.add(feedback.image !== undefined ? 'h5p-feedback-has-image' : 'h5p-feedback-default'); if (feedback.image !== undefined && feedback.image.path !== undefined) { var imageContainer = document.createElement('div'); imageContainer.classList.add('h5p-branching-question'); imageContainer.classList.add('h5p-feedback-image'); var image = document.createElement('img'); image.src = H5P.getPath(feedback.image.path, self.contentId); imageContainer.appendChild(image); wrapper.appendChild(imageContainer); } var feedbackContent = document.createElement('div'); feedbackContent.classList.add('h5p-branching-question'); feedbackContent.classList.add('h5p-feedback-content'); var feedbackText = document.createElement('div'); feedbackText.classList.add('h5p-feedback-content-content'); feedbackContent.appendChild(feedbackText); var title = document.createElement('h1'); title.innerHTML = feedback.title || ''; title.id = 'h5p-feedback-content-title'; feedbackText.appendChild(title); if (feedback.subtitle) { var subtitle = document.createElement('div'); subtitle.id = 'h5p-feedback-content-content'; subtitle.innerHTML = feedback.subtitle || ''; feedbackText.appendChild(subtitle); } var navButton = document.createElement('button'); navButton.onclick = function () { self.trigger('navigated', { nextContentId: nextContentId, chosenAlternative: chosenAlternativeIndex }); }; var text = document.createTextNode(parameters.proceedButtonText); navButton.appendChild(text); feedbackContent.appendChild(navButton); wrapper.appendChild(feedbackContent); return wrapper; }; /** * 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 = this.createXAPIEventTemplate('answered'); addQuestionToXAPI(xAPIEvent); xAPIEvent.setScoredResult(undefined, undefined, self, true); xAPIEvent.data.statement.result.response = answered; xAPIEvent.data.statement.timestamp = timestamp; return { statement: xAPIEvent.data.statement }; }; /** * If the chosen answer contains feedback data, adds an extension to the * provided extensions object, so that it can be included in reports. * * @param {object} extensions Existing object to use */ var addFeedbackInfoExtension = function (extensions) { const alternatives = parameters.branchingQuestion.alternatives; const chosen = alternatives[answered]; const feedback = chosen.feedback; if (!feedback.title && !feedback.subtitle && !feedback.image) { return; // Nothing to add } const xapiFeedback = {}; const converter = document.createElement('div'); if (feedback.image) { xapiFeedback.imageUrl = H5P.getPath( feedback.image.path, self.parent.contentId ); } if (feedback.title) { converter.innerHTML = feedback.title; xapiFeedback.title = converter.innerText.trim(); } if (feedback.subtitle) { converter.innerHTML = feedback.subtitle; xapiFeedback.subtitle = converter.innerText.trim(); } const key = 'https://h5p.org/x-api/branching-choice-feedback'; extensions[key] = xapiFeedback; }; /** * Determine whether the Branching Scenario is using dynamic score. * * @return {boolean} */ var contentIsUsingDynamicScore = function () { return ( self.parent && self.parent.params && self.parent.params.scoringOptionGroup && self.parent.params.scoringOptionGroup.scoringOption === 'dynamic-score' ); }; /** * If applicable, adds scoring and correctness information to the xAPI * statement for use in reports. * * @param {object} definition xAPI object definition * @param {array} alternatives Available branching choices */ var addScoringAndCorrectness = function (definition, alternatives) { // Only include scoring and correctness data for dynamic score option if (!contentIsUsingDynamicScore()) { return; } // Track each possible score and the alternatives that award it const scoreMap = new Map(); for (let i = 0; i < alternatives.length; i++) { const currentScore = alternatives[i].feedback.endScreenScore; if (typeof currentScore === 'number' && currentScore > 0) { if (scoreMap.has(currentScore)) { scoreMap.get(currentScore).push(i); } else { scoreMap.set(currentScore, [i]); } } } if (scoreMap.size > 0) { // All alternatives that give the max score are considered correct // See https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#correct-responses-pattern const maxScore = Math.max(...scoreMap.keys()); definition.correctResponsesPattern = scoreMap.get(maxScore); // Use an extension in order to provide the points awarded by each alternative const extensionKey = 'https://h5p.org/x-api/alternatives-with-score'; definition.extensions[extensionKey] = {}; scoreMap.forEach((alternatives, score) => { alternatives.forEach(alternative => { definition.extensions[extensionKey][alternative] = score; }); }); // Remove extension that indicates there is no correct answer delete definition.extensions['https://h5p.org/x-api/no-correct-answer']; } }; /** * Add the question to the given xAPIEvent * * @param {H5P.XAPIEvent} xAPIEvent */ var addQuestionToXAPI = function (xAPIEvent) { const converter = document.createElement('div'); var definition = xAPIEvent.getVerifiedStatementValue(['object', 'definition']); converter.innerHTML = parameters.branchingQuestion.question; definition.description = { 'en-US': converter.innerText }; definition.type = 'http://adlnet.gov/expapi/activities/cmi.interaction'; definition.interactionType = 'choice'; definition.correctResponsesPattern = []; definition.choices = []; definition.extensions = { 'https://h5p.org/x-api/no-correct-answer': 1 }; const alternatives = parameters.branchingQuestion.alternatives; for (let i = 0; i < alternatives.length; i++) { converter.innerHTML = alternatives[i].text; definition.choices[i] = { 'id': i + '', 'description': { 'en-US': converter.innerText } }; } addScoringAndCorrectness(definition, alternatives); if (answered !== undefined) { addFeedbackInfoExtension(definition.extensions); } }; /** * TODO */ self.attach = function ($container) { var questionContainer = document.createElement('div'); questionContainer.classList.add('h5p-branching-question-container'); var branchingQuestion = createWrapper(parameters); branchingQuestion = appendMultiChoiceSection(parameters, branchingQuestion); questionContainer.appendChild(branchingQuestion); $container.append(questionContainer); this.container = $container[0]; }; } return BranchingQuestion; })(); ; var H5P = H5P || {}; /** * Constructor. * * @param {object} params Options for this library. */ H5P.Text = function (params) { this.text = params.text === undefined ? 'New text' : params.text; }; /** * Wipe out the content of the wrapper and put our HTML in it. * * @param {jQuery} $wrapper */ H5P.Text.prototype.attach = function ($wrapper) { $wrapper.addClass('h5p-text').html(this.text); }; ; var H5P = H5P || {}; /** * Transition contains helper function relevant for transitioning */ H5P.Transition = (function ($) { /** * @class * @namespace H5P */ Transition = {}; /** * @private */ Transition.transitionEndEventNames = { 'WebkitTransition': 'webkitTransitionEnd', 'transition': 'transitionend', 'MozTransition': 'transitionend', 'OTransition': 'oTransitionEnd', 'msTransition': 'MSTransitionEnd' }; /** * @private */ Transition.cache = []; /** * Get the vendor property name for an event * * @function H5P.Transition.getVendorPropertyName * @static * @private * @param {string} prop Generic property name * @return {string} Vendor specific property name */ Transition.getVendorPropertyName = function (prop) { if (Transition.cache[prop] !== undefined) { return Transition.cache[prop]; } var div = document.createElement('div'); // Handle unprefixed versions (FF16+, for example) if (prop in div.style) { Transition.cache[prop] = prop; } else { var prefixes = ['Moz', 'Webkit', 'O', 'ms']; var prop_ = prop.charAt(0).toUpperCase() + prop.substr(1); if (prop in div.style) { Transition.cache[prop] = prop; } else { for (var i = 0; i < prefixes.length; ++i) { var vendorProp = prefixes[i] + prop_; if (vendorProp in div.style) { Transition.cache[prop] = vendorProp; break; } } } } return Transition.cache[prop]; }; /** * Get the name of the transition end event * * @static * @private * @return {string} description */ Transition.getTransitionEndEventName = function () { return Transition.transitionEndEventNames[Transition.getVendorPropertyName('transition')] || undefined; }; /** * Helper function for listening on transition end events * * @function H5P.Transition.onTransitionEnd * @static * @param {domElement} $element The element which is transitioned * @param {function} callback The callback to be invoked when transition is finished * @param {number} timeout Timeout in milliseconds. Fallback if transition event is never fired */ Transition.onTransitionEnd = function ($element, callback, timeout) { // Fallback on 1 second if transition event is not supported/triggered timeout = timeout || 1000; Transition.transitionEndEventName = Transition.transitionEndEventName || Transition.getTransitionEndEventName(); var callbackCalled = false; var doCallback = function () { if (callbackCalled) { return; } $element.off(Transition.transitionEndEventName, callback); callbackCalled = true; clearTimeout(timer); callback(); }; var timer = setTimeout(function () { doCallback(); }, timeout); $element.on(Transition.transitionEndEventName, function () { doCallback(); }); }; /** * Wait for a transition - when finished, invokes next in line * * @private * * @param {Object[]} transitions Array of transitions * @param {H5P.jQuery} transitions[].$element Dom element transition is performed on * @param {number=} transitions[].timeout Timeout fallback if transition end never is triggered * @param {bool=} transitions[].break If true, sequence breaks after this transition * @param {number} index The index for current transition */ var runSequence = function (transitions, index) { if (index >= transitions.length) { return; } var transition = transitions[index]; H5P.Transition.onTransitionEnd(transition.$element, function () { if (transition.end) { transition.end(); } if (transition.break !== true) { runSequence(transitions, index+1); } }, transition.timeout || undefined); }; /** * Run a sequence of transitions * * @function H5P.Transition.sequence * @static * @param {Object[]} transitions Array of transitions * @param {H5P.jQuery} transitions[].$element Dom element transition is performed on * @param {number=} transitions[].timeout Timeout fallback if transition end never is triggered * @param {bool=} transitions[].break If true, sequence breaks after this transition */ Transition.sequence = function (transitions) { runSequence(transitions, 0); }; return Transition; })(H5P.jQuery); ; /** * Defines the H5P.ImageHotspots class */ H5P.ImageHotspots = (function ($, EventDispatcher) { /** * Default font size * * @constant * @type {number} * @default */ var DEFAULT_FONT_SIZE = 24; /** * Creates a new Image hotspots instance * * @class * @augments H5P.EventDispatcher * @namespace H5P * @param {Object} options * @param {number} id */ function ImageHotspots(options, id) { EventDispatcher.call(this); // Extend defaults with provided options this.options = $.extend(true, {}, { image: null, hotspots: [], hotspotNumberLabel: 'Hotspot #num', closeButtonLabel: 'Close', iconType: 'icon', icon: 'plus' }, options); // Keep provided id. this.id = id; this.isSmallDevice = false; } // Extends the event dispatcher ImageHotspots.prototype = Object.create(EventDispatcher.prototype); ImageHotspots.prototype.constructor = ImageHotspots; /** * Attach function called by H5P framework to insert H5P content into * page * * @public * @param {H5P.jQuery} $container */ ImageHotspots.prototype.attach = function ($container) { var self = this; self.$container = $container; if (this.options.image === null || this.options.image === undefined) { $container.append('
Missing required background image
'); return; } // Need to know since ios uses :hover when clicking on an element if (/(iPad|iPhone|iPod)/g.test( navigator.userAgent ) === false) { $container.addClass('not-an-ios-device'); } $container.addClass('h5p-image-hotspots'); this.$hotspotContainer = $('
', { 'class': 'h5p-image-hotspots-container' }); if (this.options.image && this.options.image.path) { this.$image = $('', { 'class': 'h5p-image-hotspots-background', src: H5P.getPath(this.options.image.path, this.id) }).appendTo(this.$hotspotContainer); // Set alt text of image if (this.options.backgroundImageAltText) { this.$image.attr('alt', this.options.backgroundImageAltText); } else { // Ignore image if no alternative text for assistive technologies this.$image.attr('aria-hidden', true); } } var isSmallDevice = function () { return self.isSmallDevice; }; // Add hotspots var numHotspots = this.options.hotspots.length; this.hotspots = []; this.options.hotspots.sort(function (a, b) { // Sanity checks, move data to the back if invalid var firstIsValid = a.position && a.position.x && a.position.y; var secondIsValid = b.position && b.position.x && b.position.y; if (!firstIsValid) { return 1; } if (!secondIsValid) { return -1; } // Order top-to-bottom, left-to-right if (a.position.y !== b.position.y) { return a.position.y < b.position.y ? -1 : 1; } else { // a and b y position is equal, sort on x return a.position.x < b.position.x ? -1 : 1; } }); for (var i=0; i 1) { this.hotspots[this.hotspots.length - 1].setTrapFocusTo(this.hotspots[0]); this.hotspots[0].setTrapFocusTo(this.hotspots[this.hotspots.length - 1], true); } } else { // Untrap focus this.hotspots[this.hotspots.length - 1].releaseTrapFocus(); this.hotspots[0].releaseTrapFocus(); } }; /** * Handle resizing * @private * @param {Event} [e] * @param {boolean} [e.forceImageHeight] * @param {boolean} [e.decreaseSize] */ ImageHotspots.prototype.resize = function (e) { if (this.options.image === null) { return; } var self = this; var containerWidth = self.$container.width(); var containerHeight = self.$container.height(); var width = containerWidth; var height = Math.floor((width/self.options.image.width) * self.options.image.height); var forceImageHeight = e && e.data && e.data.forceImageHeight; // Check if decreasing iframe size var decreaseSize = e && e.data && e.data.decreaseSize; if (!decreaseSize) { self.$container.css('width', ''); } // If fullscreen & standalone if (this.isRoot() && H5P.isFullscreen) { // If fullscreen, we have both a max width and max height. if (!forceImageHeight && height > containerHeight) { height = containerHeight; width = Math.floor((height/self.options.image.height) * self.options.image.width); } // Check if we need to apply semi full screen fix. if (self.$container.is('.h5p-semi-fullscreen')) { // Reset semi fullscreen width self.$container.css('width', ''); // Decrease iframe size if (!decreaseSize) { self.$hotspotContainer.css('width', '10px'); self.$image.css('width', '10px'); // Trigger changes setTimeout(function () { self.trigger('resize', {decreaseSize: true}); }, 200); } // Set width equal to iframe parent width, since iframe content has not been updated yet. var $iframe = $(window.frameElement); if ($iframe) { var $iframeParent = $iframe.parent(); width = $iframeParent.width(); self.$container.css('width', width + 'px'); } } } self.$image.css({ width: width + 'px', height: height + 'px' }); if (!self.initialWidth) { self.initialWidth = self.$container.width(); } self.fontSize = Math.max(DEFAULT_FONT_SIZE, (DEFAULT_FONT_SIZE * (width/self.initialWidth))); self.$hotspotContainer.css({ width: width + 'px', height: height + 'px', fontSize: self.fontSize + 'px' }); self.isSmallDevice = (containerWidth / parseFloat($("body").css("font-size")) < 40); }; return ImageHotspots; })(H5P.jQuery, H5P.EventDispatcher); ; /** * Defines the ImageHotspots.Hotspot class */ (function ($, ImageHotspots) { /** * Creates a new Hotspot * * @class * @namespace H5P.ImageHotspots * @param {Object} config * @param {Object} options * @param {number} id * @param {boolean} isSmallDeviceCB * @param {H5P.ImageHotspots} parent */ ImageHotspots.Hotspot = function (config, options, id, isSmallDeviceCB, parent) { var self = this; this.config = config; this.visible = false; this.id = id; this.isSmallDeviceCB = isSmallDeviceCB; this.options = options; this.parent = parent; // A utility variable to check if a Predefined icon or an uploaded image should be used. var iconImageExists = (options.iconImage !== undefined && options.iconType === 'image'); if (this.config.content === undefined || this.config.content.length === 0) { throw new Error('Missing content configuration for hotspot. Please fix in editor.'); } // Check if there is an iconImage that should be used instead of fontawesome icons to determine the html element. this.$element = $(iconImageExists ? '' : '