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);
;
var H5P = H5P || {};
/**
* Class responsible for creating a help text dialog
*/
H5P.JoubelHelpTextDialog = (function ($) {
var numInstances = 0;
/**
* Display a pop-up containing a message.
*
* @param {H5P.jQuery} $container The container which message dialog will be appended to
* @param {string} message The message
* @param {string} closeButtonTitle The title for the close button
* @return {H5P.jQuery}
*/
function JoubelHelpTextDialog(header, message, closeButtonTitle) {
H5P.EventDispatcher.call(this);
var self = this;
numInstances++;
var headerId = 'joubel-help-text-header-' + numInstances;
var helpTextId = 'joubel-help-text-body-' + numInstances;
var $helpTextDialogBox = $('
'
).append([$tail, $innerBubble])
.appendTo($h5pContainer);
// Show speech bubble with transition
setTimeout(function () {
$currentSpeechBubble.addClass('show');
}, 0);
position($currentSpeechBubble, $currentContainer, maxWidth, $tail, $innerTail);
// Handle click to close
H5P.$body.on('mousedown.speechBubble', handleOutsideClick);
// Handle window resizing
H5P.$window.on('resize', '', handleResize);
// Handle clicks when inside IV which blocks bubbling.
$container.parents('.h5p-dialog')
.on('mousedown.speechBubble', handleOutsideClick);
if (iDevice) {
H5P.$body.css('cursor', 'pointer');
}
return this;
}
// Remove speechbubble if it belongs to a dom element that is about to be hidden
H5P.externalDispatcher.on('domHidden', function (event) {
if ($currentSpeechBubble !== undefined && event.data.$dom.find($currentContainer).length !== 0) {
remove();
}
});
/**
* Returns the closest h5p container for the given DOM element.
*
* @param {object} $container jquery element
* @return {object} the h5p container (jquery element)
*/
function getH5PContainer($container) {
var $h5pContainer = $container.closest('.h5p-frame');
// Check closest h5p frame first, then check for container in case there is no frame.
if (!$h5pContainer.length) {
$h5pContainer = $container.closest('.h5p-container');
}
return $h5pContainer;
}
/**
* Event handler that is called when the window is resized.
*/
function handleResize() {
position($currentSpeechBubble, $currentContainer, currentMaxWidth, $tail, $innerTail);
}
/**
* Repositions the speech bubble according to the position of the container.
*
* @param {object} $currentSpeechbubble the speech bubble that should be positioned
* @param {object} $container the container to which the speech bubble should point
* @param {number} maxWidth the maximum width of the speech bubble
* @param {object} $tail the tail (the triangle that points to the referenced container)
* @param {object} $innerTail the inner tail (the triangle that points to the referenced container)
*/
function position($currentSpeechBubble, $container, maxWidth, $tail, $innerTail) {
var $h5pContainer = getH5PContainer($container);
// Calculate offset between the button and the h5p frame
var offset = getOffsetBetween($h5pContainer, $container);
var direction = (offset.bottom > offset.top ? 'bottom' : 'top');
var tipWidth = offset.outerWidth * 0.9; // Var needs to be renamed to make sense
var bubbleWidth = tipWidth > maxWidth ? maxWidth : tipWidth;
var bubblePosition = getBubblePosition(bubbleWidth, offset);
var tailPosition = getTailPosition(bubbleWidth, bubblePosition, offset, $container.width());
// Need to set font-size, since element is appended to body.
// Using same font-size as parent. In that way it will grow accordingly
// when resizing
var fontSize = 16;//parseFloat($parent.css('font-size'));
// Set width and position of speech bubble
$currentSpeechBubble.css(bubbleCSS(
direction,
bubbleWidth,
bubblePosition,
fontSize
));
var preparedTailCSS = tailCSS(direction, tailPosition);
$tail.css(preparedTailCSS);
$innerTail.css(preparedTailCSS);
}
/**
* Static function for removing the speechbubble
*/
var remove = function () {
H5P.$body.off('mousedown.speechBubble');
H5P.$window.off('resize', '', handleResize);
$currentContainer.parents('.h5p-dialog').off('mousedown.speechBubble');
if (iDevice) {
H5P.$body.css('cursor', '');
}
if ($currentSpeechBubble !== undefined) {
// Apply transition, then remove speech bubble
$currentSpeechBubble.removeClass('show');
// Make sure we remove any old timeout before reassignment
clearTimeout(removeSpeechBubbleTimeout);
removeSpeechBubbleTimeout = setTimeout(function () {
$currentSpeechBubble.remove();
$currentSpeechBubble = undefined;
}, 500);
}
// Don't return false here. If the user e.g. clicks a button when the bubble is visible,
// we want the bubble to disapear AND the button to receive the event
};
/**
* Remove the speech bubble and container reference
*/
function handleOutsideClick(event) {
if (event.target === $currentContainer[0]) {
return; // Button clicks are not outside clicks
}
remove();
// There is no current container when a container isn't clicked
$currentContainer = undefined;
}
/**
* Calculate position for speech bubble
*
* @param {number} bubbleWidth The width of the speech bubble
* @param {object} offset
* @return {object} Return position for the speech bubble
*/
function getBubblePosition(bubbleWidth, offset) {
var bubblePosition = {};
var tailOffset = 9;
var widthOffset = bubbleWidth / 2;
// Calculate top position
bubblePosition.top = offset.top + offset.innerHeight;
// Calculate bottom position
bubblePosition.bottom = offset.bottom + offset.innerHeight + tailOffset;
// Calculate left position
if (offset.left < widthOffset) {
bubblePosition.left = 3;
}
else if ((offset.left + widthOffset) > offset.outerWidth) {
bubblePosition.left = offset.outerWidth - bubbleWidth - 3;
}
else {
bubblePosition.left = offset.left - widthOffset + (offset.innerWidth / 2);
}
return bubblePosition;
}
/**
* Calculate position for speech bubble tail
*
* @param {number} bubbleWidth The width of the speech bubble
* @param {object} bubblePosition Speech bubble position
* @param {object} offset
* @param {number} iconWidth The width of the tip icon
* @return {object} Return position for the tail
*/
function getTailPosition(bubbleWidth, bubblePosition, offset, iconWidth) {
var tailPosition = {};
// Magic numbers. Tuned by hand so that the tail fits visually within
// the bounds of the speech bubble.
var leftBoundary = 9;
var rightBoundary = bubbleWidth - 20;
tailPosition.left = offset.left - bubblePosition.left + (iconWidth / 2) - 6;
if (tailPosition.left < leftBoundary) {
tailPosition.left = leftBoundary;
}
if (tailPosition.left > rightBoundary) {
tailPosition.left = rightBoundary;
}
tailPosition.top = -6;
tailPosition.bottom = -6;
return tailPosition;
}
/**
* Return bubble CSS for the desired growth direction
*
* @param {string} direction The direction the speech bubble will grow
* @param {number} width The width of the speech bubble
* @param {object} position Speech bubble position
* @param {number} fontSize The size of the bubbles font
* @return {object} Return CSS
*/
function bubbleCSS(direction, width, position, fontSize) {
if (direction === 'top') {
return {
width: width + 'px',
bottom: position.bottom + 'px',
left: position.left + 'px',
fontSize: fontSize + 'px',
top: ''
};
}
else {
return {
width: width + 'px',
top: position.top + 'px',
left: position.left + 'px',
fontSize: fontSize + 'px',
bottom: ''
};
}
}
/**
* Return tail CSS for the desired growth direction
*
* @param {string} direction The direction the speech bubble will grow
* @param {object} position Tail position
* @return {object} Return CSS
*/
function tailCSS(direction, position) {
if (direction === 'top') {
return {
bottom: position.bottom + 'px',
left: position.left + 'px',
top: ''
};
}
else {
return {
top: position.top + 'px',
left: position.left + 'px',
bottom: ''
};
}
}
/**
* Calculates the offset between an element inside a container and the
* container. Only works if all the edges of the inner element are inside the
* outer element.
* Width/height of the elements is included as a convenience.
*
* @param {H5P.jQuery} $outer
* @param {H5P.jQuery} $inner
* @return {object} Position offset
*/
function getOffsetBetween($outer, $inner) {
var outer = $outer[0].getBoundingClientRect();
var inner = $inner[0].getBoundingClientRect();
return {
top: inner.top - outer.top,
right: outer.right - inner.right,
bottom: outer.bottom - inner.bottom,
left: inner.left - outer.left,
innerWidth: inner.width,
innerHeight: inner.height,
outerWidth: outer.width,
outerHeight: outer.height
};
}
return JoubelSpeechBubble;
})(H5P.jQuery);
;
var H5P = H5P || {};
H5P.JoubelThrobber = (function ($) {
/**
* Creates a new tip
*/
function JoubelThrobber() {
// h5p-throbber css is described in core
var $throbber = $('', {
'class': 'h5p-throbber'
});
return $throbber;
}
return JoubelThrobber;
}(H5P.jQuery));
;
H5P.JoubelTip = (function ($) {
var $conv = $('');
/**
* Creates a new tip element.
*
* NOTE that this may look like a class but it doesn't behave like one.
* It returns a jQuery object.
*
* @param {string} tipHtml The text to display in the popup
* @param {Object} [behaviour] Options
* @param {string} [behaviour.tipLabel] Set to use a custom label for the tip button (you want this for good A11Y)
* @param {boolean} [behaviour.helpIcon] Set to 'true' to Add help-icon classname to Tip button (changes the icon)
* @param {boolean} [behaviour.showSpeechBubble] Set to 'false' to disable functionality (you may this in the editor)
* @param {boolean} [behaviour.tabcontrol] Set to 'true' if you plan on controlling the tabindex in the parent (tabindex="-1")
* @return {H5P.jQuery|undefined} Tip button jQuery element or 'undefined' if invalid tip
*/
function JoubelTip(tipHtml, behaviour) {
// Keep track of the popup that appears when you click the Tip button
var speechBubble;
// Parse tip html to determine text
var tipText = $conv.html(tipHtml).text().trim();
if (tipText === '') {
return; // The tip has no textual content, i.e. it's invalid.
}
// Set default behaviour
behaviour = $.extend({
tipLabel: tipText,
helpIcon: false,
showSpeechBubble: true,
tabcontrol: false
}, behaviour);
// Create Tip button
var $tipButton = $('', {
class: 'joubel-tip-container' + (behaviour.showSpeechBubble ? '' : ' be-quiet'),
'aria-label': behaviour.tipLabel,
'aria-expanded': false,
role: 'button',
tabindex: (behaviour.tabcontrol ? -1 : 0),
click: function (event) {
// Toggle show/hide popup
toggleSpeechBubble();
event.preventDefault();
},
keydown: function (event) {
if (event.which === 32 || event.which === 13) { // Space & enter key
// Toggle show/hide popup
toggleSpeechBubble();
event.stopPropagation();
event.preventDefault();
}
else { // Any other key
// Toggle hide popup
toggleSpeechBubble(false);
}
},
// Add markup to render icon
html: '' +
'' +
'' +
'' +
''
// IMPORTANT: All of the markup elements must have 'pointer-events: none;'
});
const $tipAnnouncer = $('
', {
'class': 'hidden-but-read',
'aria-live': 'polite',
appendTo: $tipButton,
});
/**
* Tip button interaction handler.
* Toggle show or hide the speech bubble popup when interacting with the
* Tip button.
*
* @private
* @param {boolean} [force] 'true' shows and 'false' hides.
*/
var toggleSpeechBubble = function (force) {
if (speechBubble !== undefined && speechBubble.isCurrent($tipButton)) {
// Hide current popup
speechBubble.remove();
speechBubble = undefined;
$tipButton.attr('aria-expanded', false);
$tipAnnouncer.html('');
}
else if (force !== false && behaviour.showSpeechBubble) {
// Create and show new popup
speechBubble = H5P.JoubelSpeechBubble($tipButton, tipHtml);
$tipButton.attr('aria-expanded', true);
$tipAnnouncer.html(tipHtml);
}
};
return $tipButton;
}
return JoubelTip;
})(H5P.jQuery);
;
var H5P = H5P || {};
H5P.JoubelSlider = (function ($) {
/**
* Creates a new Slider
*
* @param {object} [params] Additional parameters
*/
function JoubelSlider(params) {
H5P.EventDispatcher.call(this);
this.$slider = $('
', $.extend({
'class': 'h5p-joubel-ui-slider'
}, params));
this.$slides = [];
this.currentIndex = 0;
this.numSlides = 0;
}
JoubelSlider.prototype = Object.create(H5P.EventDispatcher.prototype);
JoubelSlider.prototype.constructor = JoubelSlider;
JoubelSlider.prototype.addSlide = function ($content) {
$content.addClass('h5p-joubel-ui-slide').css({
'left': (this.numSlides*100) + '%'
});
this.$slider.append($content);
this.$slides.push($content);
this.numSlides++;
if(this.numSlides === 1) {
$content.addClass('current');
}
};
JoubelSlider.prototype.attach = function ($container) {
$container.append(this.$slider);
};
JoubelSlider.prototype.move = function (index) {
var self = this;
if(index === 0) {
self.trigger('first-slide');
}
if(index+1 === self.numSlides) {
self.trigger('last-slide');
}
self.trigger('move');
var $previousSlide = self.$slides[this.currentIndex];
H5P.Transition.onTransitionEnd(this.$slider, function () {
$previousSlide.removeClass('current');
self.trigger('moved');
});
this.$slides[index].addClass('current');
var translateX = 'translateX(' + (-index*100) + '%)';
this.$slider.css({
'-webkit-transform': translateX,
'-moz-transform': translateX,
'-ms-transform': translateX,
'transform': translateX
});
this.currentIndex = index;
};
JoubelSlider.prototype.remove = function () {
this.$slider.remove();
};
JoubelSlider.prototype.next = function () {
if(this.currentIndex+1 >= this.numSlides) {
return;
}
this.move(this.currentIndex+1);
};
JoubelSlider.prototype.previous = function () {
this.move(this.currentIndex-1);
};
JoubelSlider.prototype.first = function () {
this.move(0);
};
JoubelSlider.prototype.last = function () {
this.move(this.numSlides-1);
};
return JoubelSlider;
})(H5P.jQuery);
;
var H5P = H5P || {};
/**
* @module
*/
H5P.JoubelScoreBar = (function ($) {
/* Need to use an id for the star SVG since that is the only way to reference
SVG filters */
var idCounter = 0;
/**
* Creates a score bar
* @class H5P.JoubelScoreBar
* @param {number} maxScore Maximum score
* @param {string} [label] Makes it easier for readspeakers to identify the scorebar
* @param {string} [helpText] Score explanation
* @param {string} [scoreExplanationButtonLabel] Label for score explanation button
*/
function JoubelScoreBar(maxScore, label, helpText, scoreExplanationButtonLabel) {
var self = this;
self.maxScore = maxScore;
self.score = 0;
idCounter++;
/**
* @const {string}
*/
self.STAR_MARKUP = '';
/**
* @function appendTo
* @memberOf H5P.JoubelScoreBar#
* @param {H5P.jQuery} $wrapper Dom container
*/
self.appendTo = function ($wrapper) {
self.$scoreBar.appendTo($wrapper);
};
/**
* Create the text representation of the scorebar .
*
* @private
* @return {string}
*/
var createLabel = function (score) {
if (!label) {
return '';
}
return label.replace(':num', score).replace(':total', self.maxScore);
};
/**
* Creates the html for this widget
*
* @method createHtml
* @private
*/
var createHtml = function () {
// Container div
self.$scoreBar = $('
', {
'class': 'h5p-joubelui-score-bar',
});
var $visuals = $('
', {
'class': 'h5p-joubelui-score-bar-visuals',
appendTo: self.$scoreBar
});
// The progress bar wrapper
self.$progressWrapper = $('
', {
'class': 'h5p-joubelui-progressbar-background'
}).appendTo(this.$progressbar);
}
JoubelProgressbar.prototype = Object.create(H5P.EventDispatcher.prototype);
JoubelProgressbar.prototype.constructor = JoubelProgressbar;
JoubelProgressbar.prototype.updateAria = function () {
var self = this;
if (this.options.disableAria) {
return;
}
if (!this.$currentStatus) {
this.$currentStatus = $('
', {
'class': 'h5p-joubelui-progressbar-slide-status-text',
'aria-live': 'assertive'
}).appendTo(this.$progressbar);
}
var interpolatedProgressText = self.options.progressText
.replace(':num', self.currentStep)
.replace(':total', self.steps);
this.$currentStatus.html(interpolatedProgressText);
};
/**
* Appends to a container
* @method appendTo
* @param {H5P.jquery} $container
*/
JoubelProgressbar.prototype.appendTo = function ($container) {
this.$progressbar.appendTo($container);
};
/**
* Update progress
* @method setProgress
* @param {number} step
*/
JoubelProgressbar.prototype.setProgress = function (step) {
// Check for valid value:
if (step > this.steps || step < 0) {
return;
}
this.currentStep = step;
this.$background.css({
width: ((this.currentStep/this.steps)*100) + '%'
});
this.updateAria();
};
/**
* Increment progress with 1
* @method next
*/
JoubelProgressbar.prototype.next = function () {
this.setProgress(this.currentStep+1);
};
/**
* Reset progressbar
* @method reset
*/
JoubelProgressbar.prototype.reset = function () {
this.setProgress(0);
};
/**
* Check if last step is reached
* @method isLastStep
* @return {Boolean}
*/
JoubelProgressbar.prototype.isLastStep = function () {
return this.steps === this.currentStep;
};
return JoubelProgressbar;
})(H5P.jQuery);
;
var H5P = H5P || {};
/**
* H5P Joubel UI library.
*
* This is a utility library, which does not implement attach. I.e, it has to bee actively used by
* other libraries
* @module
*/
H5P.JoubelUI = (function ($) {
/**
* The internal object to return
* @class H5P.JoubelUI
* @static
*/
function JoubelUI() {}
/* Public static functions */
/**
* Create a tip icon
* @method H5P.JoubelUI.createTip
* @param {string} text The textual tip
* @param {Object} params Parameters
* @return {H5P.JoubelTip}
*/
JoubelUI.createTip = function (text, params) {
return new H5P.JoubelTip(text, params);
};
/**
* Create message dialog
* @method H5P.JoubelUI.createMessageDialog
* @param {H5P.jQuery} $container The dom container
* @param {string} message The message
* @return {H5P.JoubelMessageDialog}
*/
JoubelUI.createMessageDialog = function ($container, message) {
return new H5P.JoubelMessageDialog($container, message);
};
/**
* Create help text dialog
* @method H5P.JoubelUI.createHelpTextDialog
* @param {string} header The textual header
* @param {string} message The textual message
* @param {string} closeButtonTitle The title for the close button
* @return {H5P.JoubelHelpTextDialog}
*/
JoubelUI.createHelpTextDialog = function (header, message, closeButtonTitle) {
return new H5P.JoubelHelpTextDialog(header, message, closeButtonTitle);
};
/**
* Create progress circle
* @method H5P.JoubelUI.createProgressCircle
* @param {number} number The progress (0 to 100)
* @param {string} progressColor The progress color in hex value
* @param {string} fillColor The fill color in hex value
* @param {string} backgroundColor The background color in hex value
* @return {H5P.JoubelProgressCircle}
*/
JoubelUI.createProgressCircle = function (number, progressColor, fillColor, backgroundColor) {
return new H5P.JoubelProgressCircle(number, progressColor, fillColor, backgroundColor);
};
/**
* Create throbber for loading
* @method H5P.JoubelUI.createThrobber
* @return {H5P.JoubelThrobber}
*/
JoubelUI.createThrobber = function () {
return new H5P.JoubelThrobber();
};
/**
* Create simple rounded button
* @method H5P.JoubelUI.createSimpleRoundedButton
* @param {string} text The button label
* @return {H5P.SimpleRoundedButton}
*/
JoubelUI.createSimpleRoundedButton = function (text) {
return new H5P.SimpleRoundedButton(text);
};
/**
* Create Slider
* @method H5P.JoubelUI.createSlider
* @param {Object} [params] Parameters
* @return {H5P.JoubelSlider}
*/
JoubelUI.createSlider = function (params) {
return new H5P.JoubelSlider(params);
};
/**
* Create Score Bar
* @method H5P.JoubelUI.createScoreBar
* @param {number=} maxScore The maximum score
* @param {string} [label] Makes it easier for readspeakers to identify the scorebar
* @return {H5P.JoubelScoreBar}
*/
JoubelUI.createScoreBar = function (maxScore, label, helpText, scoreExplanationButtonLabel) {
return new H5P.JoubelScoreBar(maxScore, label, helpText, scoreExplanationButtonLabel);
};
/**
* Create Progressbar
* @method H5P.JoubelUI.createProgressbar
* @param {number=} numSteps The total numer of steps
* @param {Object} [options] Additional options
* @param {boolean} [options.disableAria] Disable readspeaker assistance
* @param {string} [options.progressText] A progress text for describing
* current progress out of total progress for readspeakers.
* e.g. "Slide :num of :total"
* @return {H5P.JoubelProgressbar}
*/
JoubelUI.createProgressbar = function (numSteps, options) {
return new H5P.JoubelProgressbar(numSteps, options);
};
/**
* Create standard Joubel button
*
* @method H5P.JoubelUI.createButton
* @param {object} params
* May hold any properties allowed by jQuery. If href is set, an A tag
* is used, if not a button tag is used.
* @return {H5P.jQuery} The jquery element created
*/
JoubelUI.createButton = function(params) {
var type = 'button';
if (params.href) {
type = 'a';
}
else {
params.type = 'button';
}
if (params.class) {
params.class += ' h5p-joubelui-button';
}
else {
params.class = 'h5p-joubelui-button';
}
return $('<' + type + '/>', params);
};
/**
* Fix for iframe scoll bug in IOS. When focusing an element that doesn't have
* focus support by default the iframe will scroll the parent frame so that
* the focused element is out of view. This varies dependening on the elements
* of the parent frame.
*/
if (H5P.isFramed && !H5P.hasiOSiframeScrollFix &&
/iPad|iPhone|iPod/.test(navigator.userAgent)) {
H5P.hasiOSiframeScrollFix = true;
// Keep track of original focus function
var focus = HTMLElement.prototype.focus;
// Override the original focus
HTMLElement.prototype.focus = function () {
// Only focus the element if it supports it natively
if ( (this instanceof HTMLAnchorElement ||
this instanceof HTMLInputElement ||
this instanceof HTMLSelectElement ||
this instanceof HTMLTextAreaElement ||
this instanceof HTMLButtonElement ||
this instanceof HTMLIFrameElement ||
this instanceof HTMLAreaElement) && // HTMLAreaElement isn't supported by Safari yet.
!this.getAttribute('role')) { // Focus breaks if a different role has been set
// In theory this.isContentEditable should be able to recieve focus,
// but it didn't work when tested.
// Trigger the original focus with the proper context
focus.call(this);
}
};
}
return JoubelUI;
})(H5P.jQuery);
;
H5P.Tooltip = H5P.Tooltip || function() {};
H5P.Question = (function ($, EventDispatcher, JoubelUI) {
/**
* Extending this class make it alot easier to create tasks for other
* content types.
*
* @class H5P.Question
* @extends H5P.EventDispatcher
* @param {string} type
*/
function Question(type) {
var self = this;
// Inheritance
EventDispatcher.call(self);
// Register default section order
self.order = ['video', 'image', 'audio', 'introduction', 'content', 'explanation', 'feedback', 'scorebar', 'buttons', 'read'];
// Keep track of registered sections
var sections = {};
// Buttons
var buttons = {};
var buttonOrder = [];
// Wrapper when attached
var $wrapper;
// Click element
var clickElement;
// ScoreBar
var scoreBar;
// Keep track of the feedback's visual status.
var showFeedback;
// Keep track of which buttons are scheduled for hiding.
var buttonsToHide = [];
// Keep track of which buttons are scheduled for showing.
var buttonsToShow = [];
// Keep track of the hiding and showing of buttons.
var toggleButtonsTimer;
var toggleButtonsTransitionTimer;
var buttonTruncationTimer;
// Keeps track of initialization of question
var initialized = false;
/**
* @type {Object} behaviour Behaviour of Question
* @property {Boolean} behaviour.disableFeedback Set to true to disable feedback section
*/
var behaviour = {
disableFeedback: false,
disableReadSpeaker: false
};
// Keeps track of thumb state
var imageThumb = true;
// Keeps track of image transitions
var imageTransitionTimer;
// Keep track of whether sections is transitioning.
var sectionsIsTransitioning = false;
// Keep track of auto play state
var disableAutoPlay = false;
// Feedback transition timer
var feedbackTransitionTimer;
// Used when reading messages to the user
var $read, readText;
/**
* Register section with given content.
*
* @private
* @param {string} section ID of the section
* @param {(string|H5P.jQuery)} [content]
*/
var register = function (section, content) {
sections[section] = {};
var $e = sections[section].$element = $('', {
'class': 'h5p-question-' + section,
});
if (content) {
$e[content instanceof $ ? 'append' : 'html'](content);
}
};
/**
* Update registered section with content.
*
* @private
* @param {string} section ID of the section
* @param {(string|H5P.jQuery)} content
*/
var update = function (section, content) {
if (content instanceof $) {
sections[section].$element.html('').append(content);
}
else {
sections[section].$element.html(content);
}
};
/**
* Insert element with given ID into the DOM.
*
* @private
* @param {array|Array|string[]} order
* List with ordered element IDs
* @param {string} id
* ID of the element to be inserted
* @param {Object} elements
* Maps ID to the elements
* @param {H5P.jQuery} $container
* Parent container of the elements
*/
var insert = function (order, id, elements, $container) {
// Try to find an element id should be after
for (var i = 0; i < order.length; i++) {
if (order[i] === id) {
// Found our pos
while (i > 0 &&
(elements[order[i - 1]] === undefined ||
!elements[order[i - 1]].isVisible)) {
i--;
}
if (i === 0) {
// We are on top.
elements[id].$element.prependTo($container);
}
else {
// Add after element
elements[id].$element.insertAfter(elements[order[i - 1]].$element);
}
elements[id].isVisible = true;
break;
}
}
};
/**
* Make feedback into a popup and position relative to click.
*
* @private
* @param {string} [closeText] Text for the close button
*/
var makeFeedbackPopup = function (closeText) {
var $element = sections.feedback.$element;
var $parent = sections.content.$element;
var $click = (clickElement != null ? clickElement.$element : null);
$element.appendTo($parent).addClass('h5p-question-popup');
if (sections.scorebar) {
sections.scorebar.$element.appendTo($element);
}
$parent.addClass('h5p-has-question-popup');
// Draw the tail
var $tail = $('', {
'class': 'h5p-question-feedback-tail'
}).hide()
.appendTo($parent);
// Draw the close button
var $close = $('', {
'class': 'h5p-question-feedback-close',
'tabindex': 0,
'title': closeText,
on: {
click: function (event) {
$element.remove();
$tail.remove();
event.preventDefault();
},
keydown: function (event) {
switch (event.which) {
case 13: // Enter
case 32: // Space
$element.remove();
$tail.remove();
event.preventDefault();
}
}
}
}).hide().appendTo($element);
if ($click != null) {
if ($click.hasClass('correct')) {
$element.addClass('h5p-question-feedback-correct');
$close.show();
sections.buttons.$element.hide();
}
else {
sections.buttons.$element.appendTo(sections.feedback.$element);
}
}
positionFeedbackPopup($element, $click);
};
/**
* Position the feedback popup.
*
* @private
* @param {H5P.jQuery} $element Feedback div
* @param {H5P.jQuery} $click Visual click div
*/
var positionFeedbackPopup = function ($element, $click) {
var $container = $element.parent();
var $tail = $element.siblings('.h5p-question-feedback-tail');
var popupWidth = $element.outerWidth();
var popupHeight = setElementHeight($element);
var space = 15;
var disableTail = false;
var positionY = $container.height() / 2 - popupHeight / 2;
var positionX = $container.width() / 2 - popupWidth / 2;
var tailX = 0;
var tailY = 0;
var tailRotation = 0;
if ($click != null) {
// Edge detection for click, takes space into account
var clickNearTop = ($click[0].offsetTop < space);
var clickNearBottom = ($click[0].offsetTop + $click.height() > $container.height() - space);
var clickNearLeft = ($click[0].offsetLeft < space);
var clickNearRight = ($click[0].offsetLeft + $click.width() > $container.width() - space);
// Click is not in a corner or close to edge, calculate position normally
positionX = $click[0].offsetLeft - popupWidth / 2 + $click.width() / 2;
positionY = $click[0].offsetTop - popupHeight - space;
tailX = positionX + popupWidth / 2 - $tail.width() / 2;
tailY = positionY + popupHeight - ($tail.height() / 2);
tailRotation = 225;
// If popup is outside top edge, position under click instead
if (popupHeight + space > $click[0].offsetTop) {
positionY = $click[0].offsetTop + $click.height() + space;
tailY = positionY - $tail.height() / 2 ;
tailRotation = 45;
}
// If popup is outside left edge, position left
if (positionX < 0) {
positionX = 0;
}
// If popup is outside right edge, position right
if (positionX + popupWidth > $container.width()) {
positionX = $container.width() - popupWidth;
}
// Special cases such as corner clicks, or close to an edge, they override X and Y positions if met
if (clickNearTop && (clickNearLeft || clickNearRight)) {
positionX = $click[0].offsetLeft + (clickNearLeft ? $click.width() : -popupWidth);
positionY = $click[0].offsetTop + $click.height();
disableTail = true;
}
else if (clickNearBottom && (clickNearLeft || clickNearRight)) {
positionX = $click[0].offsetLeft + (clickNearLeft ? $click.width() : -popupWidth);
positionY = $click[0].offsetTop - popupHeight;
disableTail = true;
}
else if (!clickNearTop && !clickNearBottom) {
if (clickNearLeft || clickNearRight) {
positionY = $click[0].offsetTop - popupHeight / 2 + $click.width() / 2;
positionX = $click[0].offsetLeft + (clickNearLeft ? $click.width() + space : -popupWidth + -space);
// Make sure this does not position the popup off screen
if (positionX < 0) {
positionX = 0;
disableTail = true;
}
else {
tailX = positionX + (clickNearLeft ? - $tail.width() / 2 : popupWidth - $tail.width() / 2);
tailY = positionY + popupHeight / 2 - $tail.height() / 2;
tailRotation = (clickNearLeft ? 315 : 135);
}
}
}
// Contain popup from overflowing bottom edge
if (positionY + popupHeight > $container.height()) {
positionY = $container.height() - popupHeight;
if (popupHeight > $container.height() - ($click[0].offsetTop + $click.height() + space)) {
disableTail = true;
}
}
}
else {
disableTail = true;
}
// Contain popup from ovreflowing top edge
if (positionY < 0) {
positionY = 0;
}
$element.css({top: positionY, left: positionX});
$tail.css({top: tailY, left: tailX});
if (!disableTail) {
$tail.css({
'left': tailX,
'top': tailY,
'transform': 'rotate(' + tailRotation + 'deg)'
}).show();
}
else {
$tail.hide();
}
};
/**
* Set element max height, used for animations.
*
* @param {H5P.jQuery} $element
*/
var setElementHeight = function ($element) {
if (!$element.is(':visible')) {
// No animation
$element.css('max-height', 'none');
return;
}
// If this element is shown in the popup, we can't set width to 100%,
// since it already has a width set in CSS
var isFeedbackPopup = $element.hasClass('h5p-question-popup');
// Get natural element height
var $tmp = $element.clone()
.css({
'position': 'absolute',
'max-height': 'none',
'width': isFeedbackPopup ? '' : '100%'
})
.appendTo($element.parent());
// Need to take margins into account when calculating available space
var sideMargins = parseFloat($element.css('margin-left'))
+ parseFloat($element.css('margin-right'));
var tmpElWidth = $tmp.css('width') ? $tmp.css('width') : '100%';
$tmp.css('width', 'calc(' + tmpElWidth + ' - ' + sideMargins + 'px)');
// Apply height to element
var h = Math.round($tmp.get(0).getBoundingClientRect().height);
var fontSize = parseFloat($element.css('fontSize'));
var relativeH = h / fontSize;
$element.css('max-height', relativeH + 'em');
$tmp.remove();
if (h > 0 && sections.buttons && sections.buttons.$element === $element) {
// Make sure buttons section is visible
showSection(sections.buttons);
// Resize buttons after resizing button section
setTimeout(resizeButtons, 150);
}
return h;
};
/**
* Does the actual job of hiding the buttons scheduled for hiding.
*
* @private
* @param {boolean} [relocateFocus] Find a new button to focus
*/
var hideButtons = function (relocateFocus) {
for (var i = 0; i < buttonsToHide.length; i++) {
hideButton(buttonsToHide[i].id);
}
buttonsToHide = [];
if (relocateFocus) {
self.focusButton();
}
};
/**
* Does the actual hiding.
* @private
* @param {string} buttonId
*/
var hideButton = function (buttonId) {
// Using detach() vs hide() makes it harder to cheat.
buttons[buttonId].$element.detach();
buttons[buttonId].isVisible = false;
};
/**
* Shows the buttons on the next tick. This is to avoid buttons flickering
* If they're both added and removed on the same tick.
*
* @private
*/
var toggleButtons = function () {
// If no buttons section, return
if (sections.buttons === undefined) {
return;
}
// Clear transition timer, reevaluate if buttons will be detached
clearTimeout(toggleButtonsTransitionTimer);
// Show buttons
for (var i = 0; i < buttonsToShow.length; i++) {
insert(buttonOrder, buttonsToShow[i].id, buttons, sections.buttons.$element);
buttons[buttonsToShow[i].id].isVisible = true;
}
buttonsToShow = [];
// Hide buttons
var numToHide = 0;
var relocateFocus = false;
for (var j = 0; j < buttonsToHide.length; j++) {
var button = buttons[buttonsToHide[j].id];
if (button.isVisible) {
numToHide += 1;
}
if (button.$element.is(':focus')) {
// Move focus to the first visible button.
relocateFocus = true;
}
}
var animationTimer = 150;
if (sections.feedback && sections.feedback.$element.hasClass('h5p-question-popup')) {
animationTimer = 0;
}
if (numToHide === sections.buttons.$element.children().length) {
// All buttons are going to be hidden. Hide container using transition.
hideSection(sections.buttons);
// Detach buttons
hideButtons(relocateFocus);
}
else {
hideButtons(relocateFocus);
// Show button section
if (!sections.buttons.$element.is(':empty')) {
showSection(sections.buttons);
setElementHeight(sections.buttons.$element);
// Trigger resize after animation
toggleButtonsTransitionTimer = setTimeout(function () {
self.trigger('resize');
}, animationTimer);
}
// Resize buttons to fit container
resizeButtons();
}
toggleButtonsTimer = undefined;
};
/**
* Allows for scaling of the question image.
*/
var scaleImage = function () {
var $imgSection = sections.image.$element;
clearTimeout(imageTransitionTimer);
// Add this here to avoid initial transition of the image making
// content overflow. Alternatively we need to trigger a resize.
$imgSection.addClass('animatable');
if (imageThumb) {
// Expand image
$(this).attr('aria-expanded', true);
$imgSection.addClass('h5p-question-image-fill-width');
imageThumb = false;
imageTransitionTimer = setTimeout(function () {
self.trigger('resize');
}, 600);
}
else {
// Scale down image
$(this).attr('aria-expanded', false);
$imgSection.removeClass('h5p-question-image-fill-width');
imageThumb = true;
imageTransitionTimer = setTimeout(function () {
self.trigger('resize');
}, 600);
}
};
/**
* Get scrollable ancestor of element
*
* @private
* @param {H5P.jQuery} $element
* @param {Number} [currDepth=0] Current recursive calls to ancestor, stop at maxDepth
* @param {Number} [maxDepth=5] Maximum depth for finding ancestor.
* @returns {H5P.jQuery} Parent element that is scrollable
*/
var findScrollableAncestor = function ($element, currDepth, maxDepth) {
if (!currDepth) {
currDepth = 0;
}
if (!maxDepth) {
maxDepth = 5;
}
// Check validation of element or if we have reached document root
if (!$element || !($element instanceof $) || document === $element.get(0) || currDepth >= maxDepth) {
return;
}
if ($element.css('overflow-y') === 'auto') {
return $element;
}
else {
return findScrollableAncestor($element.parent(), currDepth + 1, maxDepth);
}
};
/**
* Scroll to bottom of Question.
*
* @private
*/
var scrollToBottom = function () {
if (!$wrapper || ($wrapper.hasClass('h5p-standalone') && !H5P.isFullscreen)) {
return; // No scroll
}
var scrollableAncestor = findScrollableAncestor($wrapper);
// Scroll to bottom of scrollable ancestor
if (scrollableAncestor) {
scrollableAncestor.animate({
scrollTop: $wrapper.css('height')
}, "slow");
}
};
/**
* Resize buttons to fit container width
*
* @private
*/
var resizeButtons = function () {
if (!buttons || !sections.buttons) {
return;
}
var go = function () {
// Don't do anything if button elements are not visible yet
if (!sections.buttons.$element.is(':visible')) {
return;
}
// Width of all buttons
var buttonsWidth = {
max: 0,
min: 0,
current: 0
};
for (var i in buttons) {
var button = buttons[i];
if (button.isVisible) {
setButtonWidth(buttons[i]);
buttonsWidth.max += button.width.max;
buttonsWidth.min += button.width.min;
buttonsWidth.current += button.isTruncated ? button.width.min : button.width.max;
}
}
var makeButtonsFit = function (availableWidth) {
if (buttonsWidth.max < availableWidth) {
// It is room for everyone on the right side of the score bar (without truncating)
if (buttonsWidth.max !== buttonsWidth.current) {
// Need to make everyone big
restoreButtonLabels(buttonsWidth.current, availableWidth);
}
return true;
}
else if (buttonsWidth.min < availableWidth) {
// Is it room for everyone on the right side of the score bar with truncating?
if (buttonsWidth.current > availableWidth) {
removeButtonLabels(buttonsWidth.current, availableWidth);
}
else {
restoreButtonLabels(buttonsWidth.current, availableWidth);
}
return true;
}
return false;
};
toggleFullWidthScorebar(false);
var buttonSectionWidth = Math.floor(sections.buttons.$element.width()) - 1;
if (!makeButtonsFit(buttonSectionWidth)) {
// If we get here we need to wrap:
toggleFullWidthScorebar(true);
buttonSectionWidth = Math.floor(sections.buttons.$element.width()) - 1;
makeButtonsFit(buttonSectionWidth);
}
};
// If visible, resize right away
if (sections.buttons.$element.is(':visible')) {
go();
}
else { // If not visible, try on the next tick
// Clear button truncation timer if within a button truncation function
if (buttonTruncationTimer) {
clearTimeout(buttonTruncationTimer);
}
buttonTruncationTimer = setTimeout(function () {
buttonTruncationTimer = undefined;
go();
}, 0);
}
};
var toggleFullWidthScorebar = function (enabled) {
if (sections.scorebar &&
sections.scorebar.$element &&
sections.scorebar.$element.hasClass('h5p-question-visible')) {
sections.buttons.$element.addClass('has-scorebar');
sections.buttons.$element.toggleClass('wrap', enabled);
sections.scorebar.$element.toggleClass('full-width', enabled);
}
else {
sections.buttons.$element.removeClass('has-scorebar');
}
};
/**
* Remove button labels until they use less than max width.
*
* @private
* @param {Number} buttonsWidth Total width of all buttons
* @param {Number} maxButtonsWidth Max width allowed for buttons
*/
var removeButtonLabels = function (buttonsWidth, maxButtonsWidth) {
// Reverse traversal
for (var i = buttonOrder.length - 1; i >= 0; i--) {
var buttonId = buttonOrder[i];
var button = buttons[buttonId];
if (!button.isTruncated && button.isVisible) {
var $button = button.$element;
buttonsWidth -= button.width.max - button.width.min;
// Set tooltip (needed by H5P.Tooltip)
let buttonText = $button.text();
$button.attr('data-tooltip', buttonText);
// Use button text as aria label if a specific one isn't provided
if (!button.ariaLabel) {
$button.attr('aria-label', buttonText);
}
// Remove label
$button.html('').addClass('truncated');
button.isTruncated = true;
if (buttonsWidth <= maxButtonsWidth) {
// Buttons are small enough.
return;
}
}
}
};
/**
* Restore button labels until it fills maximum possible width without exceeding the max width.
*
* @private
* @param {Number} buttonsWidth Total width of all buttons
* @param {Number} maxButtonsWidth Max width allowed for buttons
*/
var restoreButtonLabels = function (buttonsWidth, maxButtonsWidth) {
for (var i = 0; i < buttonOrder.length; i++) {
var buttonId = buttonOrder[i];
var button = buttons[buttonId];
if (button.isTruncated && button.isVisible) {
// Calculate new total width of buttons with a static pixel for consistency cross-browser
buttonsWidth += button.width.max - button.width.min + 1;
if (buttonsWidth > maxButtonsWidth) {
return;
}
// Restore label
button.$element.html(button.text);
// Remove tooltip (used by H5P.Tooltip)
button.$element.removeAttr('data-tooltip');
// Remove aria-label if a specific one isn't provided
if (!button.ariaLabel) {
button.$element.removeAttr('aria-label');
}
button.$element.removeClass('truncated');
button.isTruncated = false;
}
}
};
/**
* Helper function for finding index of keyValue in array
*
* @param {String} keyValue Value to be found
* @param {String} key In key
* @param {Array} array In array
* @returns {number}
*/
var existsInArray = function (keyValue, key, array) {
var i;
for (i = 0; i < array.length; i++) {
if (array[i][key] === keyValue) {
return i;
}
}
return -1;
};
/**
* Show a section
* @param {Object} section
*/
var showSection = function (section) {
section.$element.addClass('h5p-question-visible');
section.isVisible = true;
};
/**
* Hide a section
* @param {Object} section
*/
var hideSection = function (section) {
section.$element.css('max-height', '');
section.isVisible = false;
setTimeout(function () {
// Only hide if section hasn't been set to visible in the meantime
if (!section.isVisible) {
section.$element.removeClass('h5p-question-visible');
}
}, 150);
};
/**
* Set behaviour for question.
*
* @param {Object} options An object containing behaviour that will be extended by Question
*/
self.setBehaviour = function (options) {
$.extend(behaviour, options);
};
/**
* A video to display above the task.
*
* @param {object} params
*/
self.setVideo = function (params) {
sections.video = {
$element: $('', {
'class': 'h5p-question-video'
})
};
if (disableAutoPlay && params.params.playback) {
params.params.playback.autoplay = false;
}
// Never fit to wrapper
if (!params.params.visuals) {
params.params.visuals = {};
}
params.params.visuals.fit = false;
sections.video.instance = H5P.newRunnable(params, self.contentId, sections.video.$element, true);
var fromVideo = false; // Hack to avoid never ending loop
sections.video.instance.on('resize', function () {
fromVideo = true;
self.trigger('resize');
fromVideo = false;
});
self.on('resize', function () {
if (!fromVideo) {
sections.video.instance.trigger('resize');
}
});
return self;
};
/**
* An audio player to display above the task.
*
* @param {object} params
*/
self.setAudio = function (params) {
params.params = params.params || {};
sections.audio = {
$element: $('', {
'class': 'h5p-question-audio',
})
};
if (disableAutoPlay) {
params.params.autoplay = false;
}
else if (params.params.playerMode === 'transparent') {
params.params.autoplay = true; // false doesn't make sense for transparent audio
}
sections.audio.instance = H5P.newRunnable(params, self.contentId, sections.audio.$element, true);
// The height value that is set by H5P.Audio is counter-productive here.
if (sections.audio.instance.audio) {
sections.audio.instance.audio.style.height = '';
}
return self;
};
/**
* Will stop any playback going on in the task.
*/
self.pause = function () {
if (sections.video && sections.video.isVisible) {
sections.video.instance.pause();
}
if (sections.audio && sections.audio.isVisible) {
sections.audio.instance.pause();
}
};
/**
* Start playback of video
*/
self.play = function () {
if (sections.video && sections.video.isVisible) {
sections.video.instance.play();
}
if (sections.audio && sections.audio.isVisible) {
sections.audio.instance.play();
}
};
/**
* Disable auto play, useful in editors.
*/
self.disableAutoPlay = function () {
disableAutoPlay = true;
};
/**
* Process HTML escaped string for use as attribute value,
* e.g. for alt text or title attributes.
*
* @param {string} value
* @return {string} WARNING! Do NOT use for innerHTML.
*/
self.massageAttributeOutput = function (value) {
const dparser = new DOMParser().parseFromString(value, 'text/html');
const div = document.createElement('div');
div.innerHTML = dparser.documentElement.textContent;;
return div.textContent || div.innerText || '';
};
/**
* Add task image.
*
* @param {string} path Relative
* @param {Object} [options] Options object
* @param {string} [options.alt] Text representation
* @param {string} [options.title] Hover text
* @param {Boolean} [options.disableImageZooming] Set as true to disable image zooming
* @param {string} [options.expandImage] Localization strings
* @param {string} [options.minimizeImage] Localization string
*/
self.setImage = function (path, options) {
options = options ? options : {};
sections.image = {};
// Image container
sections.image.$element = $('', {
'class': 'h5p-question-image h5p-question-image-fill-width'
});
// Inner wrap
var $imgWrap = $('', {
'class': 'h5p-question-image-wrap',
appendTo: sections.image.$element
});
// Image element
var $img = $('', {
src: H5P.getPath(path, self.contentId),
alt: (options.alt === undefined ? '' : self.massageAttributeOutput(options.alt)),
title: (options.title === undefined ? '' : self.massageAttributeOutput(options.title)),
on: {
load: function () {
self.trigger('imageLoaded', this);
self.trigger('resize');
}
},
appendTo: $imgWrap
});
// Disable image zooming
if (options.disableImageZooming) {
$img.css('maxHeight', 'none');
// Make sure we are using the correct amount of width at all times
var determineImgWidth = function () {
// Remove margins if natural image width is bigger than section width
var imageSectionWidth = sections.image.$element.get(0).getBoundingClientRect().width;
// Do not transition, for instant measurements
$imgWrap.css({
'-webkit-transition': 'none',
'transition': 'none'
});
// Margin as translateX on both sides of image.
var diffX = 2 * ($imgWrap.get(0).getBoundingClientRect().left -
sections.image.$element.get(0).getBoundingClientRect().left);
if ($img.get(0).naturalWidth >= imageSectionWidth - diffX) {
sections.image.$element.addClass('h5p-question-image-fill-width');
}
else { // Use margin for small res images
sections.image.$element.removeClass('h5p-question-image-fill-width');
}
// Reset transition rules
$imgWrap.css({
'-webkit-transition': '',
'transition': ''
});
};
// Determine image width
if ($img.is(':visible')) {
determineImgWidth();
}
else {
$img.on('load', determineImgWidth);
}
// Skip adding zoom functionality
return;
}
const setAriaLabel = () => {
const ariaLabel = $imgWrap.attr('aria-expanded') === 'true'
? options.minimizeImage
: options.expandImage;
$imgWrap.attr('aria-label', `${ariaLabel} ${options.alt}`);
};
var sizeDetermined = false;
var determineSize = function () {
if (sizeDetermined || !$img.is(':visible')) {
return; // Try again next time.
}
$imgWrap.addClass('h5p-question-image-scalable')
.attr('aria-expanded', false)
.attr('role', 'button')
.attr('tabIndex', '0')
.on('click', function (event) {
if (event.which === 1) {
scaleImage.apply(this); // Left mouse button click
setAriaLabel();
}
}).on('keypress', function (event) {
if (event.which === 32) {
event.preventDefault(); // Prevent default behaviour; page scroll down
scaleImage.apply(this); // Space bar pressed
setAriaLabel();
}
});
setAriaLabel();
sections.image.$element.removeClass('h5p-question-image-fill-width');
sizeDetermined = true; // Prevent any futher events
};
self.on('resize', determineSize);
return self;
};
/**
* Add the introduction section.
*
* @param {(string|H5P.jQuery)} content
*/
self.setIntroduction = function (content) {
register('introduction', content);
return self;
};
/**
* Add the content section.
*
* @param {(string|H5P.jQuery)} content
* @param {Object} [options]
* @param {string} [options.class]
*/
self.setContent = function (content, options) {
register('content', content);
if (options && options.class) {
sections.content.$element.addClass(options.class);
}
return self;
};
/**
* Force readspeaker to read text. Useful when you have to use
* setTimeout for animations.
*/
self.read = function (content) {
if (!$read) {
return; // Not ready yet
}
if (readText) {
// Combine texts if called multiple times
readText += (readText.substr(-1, 1) === '.' ? ' ' : '. ') + content;
}
else {
readText = content;
}
// Set text
$read.html(readText);
setTimeout(function () {
// Stop combining when done reading
readText = null;
$read.html('');
}, 100);
};
/**
* Read feedback
*/
self.readFeedback = function () {
var invalidFeedback =
behaviour.disableReadSpeaker ||
!showFeedback ||
!sections.feedback ||
!sections.feedback.$element;
if (invalidFeedback) {
return;
}
var $feedbackText = $('.h5p-question-feedback-content-text', sections.feedback.$element);
if ($feedbackText && $feedbackText.html() && $feedbackText.html().length) {
self.read($feedbackText.html());
}
};
/**
* Remove feedback
*
* @return {H5P.Question}
*/
self.removeFeedback = function () {
clearTimeout(feedbackTransitionTimer);
if (sections.feedback && showFeedback) {
showFeedback = false;
// Hide feedback & scorebar
hideSection(sections.scorebar);
hideSection(sections.feedback);
sectionsIsTransitioning = true;
// Detach after transition
feedbackTransitionTimer = setTimeout(function () {
// Avoiding Transition.onTransitionEnd since it will register multiple events, and there's no way to cancel it if the transition changes back to "show" while the animation is happening.
if (!showFeedback) {
sections.feedback.$element.children().detach();
sections.scorebar.$element.children().detach();
// Trigger resize after animation
self.trigger('resize');
}
sectionsIsTransitioning = false;
scoreBar.setScore(0);
}, 150);
if ($wrapper) {
$wrapper.find('.h5p-question-feedback-tail').remove();
}
}
return self;
};
/**
* Set feedback message.
*
* @param {string} [content]
* @param {number} score The score
* @param {number} maxScore The maximum score for this question
* @param {string} [scoreBarLabel] Makes it easier for readspeakers to identify the scorebar
* @param {string} [helpText] Help text that describes the score inside a tip icon
* @param {object} [popupSettings] Extra settings for popup feedback
* @param {boolean} [popupSettings.showAsPopup] Should the feedback display as popup?
* @param {string} [popupSettings.closeText] Translation for close button text
* @param {object} [popupSettings.click] Element representing where user clicked on screen
*/
self.setFeedback = function (content, score, maxScore, scoreBarLabel, helpText, popupSettings, scoreExplanationButtonLabel) {
// Feedback is disabled
if (behaviour.disableFeedback) {
return self;
}
// Need to toggle buttons right away to avoid flickering/blinking
// Note: This means content types should invoke hide/showButton before setFeedback
toggleButtons();
clickElement = (popupSettings != null && popupSettings.click != null ? popupSettings.click : null);
clearTimeout(feedbackTransitionTimer);
var $feedback = $('
', {
'class': 'h5p-question-feedback-container'
});
var $feedbackContent = $('
', {
'class': 'h5p-question-feedback-content'
}).appendTo($feedback);
// Feedback text
$('