', {
'class': 'h5p-question-explanation-text',
html: feedback.text,
appendTo: $explanationItem
});
}
}
};
createHTML();
/**
* Return the container HTMLElement
*
* @return {HTMLElement}
*/
self.getElement = function () {
return self.$explanation;
};
}
return Explainer;
})(H5P.jQuery);
;
(function (Question) {
/**
* Makes it easy to add animated score points for your question type.
*
* @class H5P.Question.ScorePoints
*/
Question.ScorePoints = function () {
var self = this;
var elements = [];
var showElementsTimer;
/**
* Create the element that displays the score point element for questions.
*
* @param {boolean} isCorrect
* @return {HTMLElement}
*/
self.getElement = function (isCorrect) {
var element = document.createElement('div');
element.classList.add(isCorrect ? 'h5p-question-plus-one' : 'h5p-question-minus-one');
element.classList.add('h5p-question-hidden-one');
elements.push(element);
// Schedule display animation of all added elements
if (showElementsTimer) {
clearTimeout(showElementsTimer);
}
showElementsTimer = setTimeout(showElements, 0);
return element;
};
/**
* @private
*/
var showElements = function () {
// Determine delay between triggering animations
var delay = 0;
var increment = 150;
var maxTime = 1000;
if (elements.length && elements.length > Math.ceil(maxTime / increment)) {
// Animations will run for more than ~1 second, reduce it.
increment = maxTime / elements.length;
}
for (var i = 0; i < elements.length; i++) {
// Use timer to trigger show
setTimeout(showElement(elements[i]), delay);
// Increse delay for next element
delay += increment;
}
};
/**
* Trigger transition animation for the given element
*
* @private
* @param {HTMLElement} element
* @return {function}
*/
var showElement = function (element) {
return function () {
element.classList.remove('h5p-question-hidden-one');
};
};
};
})(H5P.Question);
;
/**
* @class
* @classdesc Keyboard navigation for accessibility support
* @extends H5P.EventDispatcher
*/
H5P.KeyboardNav = (function (EventDispatcher) {
/**
* Construct a new KeyboardNav
* @constructor
*/
function KeyboardNav() {
EventDispatcher.call(this);
/** @member {boolean} */
this.selectability = true;
/** @member {HTMLElement[]|EventTarget[]} */
this.elements = [];
}
KeyboardNav.prototype = Object.create(EventDispatcher.prototype);
KeyboardNav.prototype.constructor = KeyboardNav;
/**
* Adds a new element to navigation
*
* @param {HTMLElement} el The element
* @public
*/
KeyboardNav.prototype.addElement = function(el){
const keyDown = this.handleKeyDown.bind(this);
const onClick = this.onClick.bind(this);
el.addEventListener('keydown', keyDown);
el.addEventListener('click', onClick);
// add to array to navigate over
this.elements.push({
el: el,
keyDown: keyDown,
onClick: onClick,
});
if(this.elements.length === 1){ // if first
this.setTabbableAt(0);
}
};
/**
* Select the previous element in the list. Select the last element,
* if the current element is the first element in the list.
*
* @param {Number} index The index of currently selected element
* @public
* @fires KeyboardNav#previousOption
*/
KeyboardNav.prototype.previousOption = function (index) {
var isFirstElement = index === 0;
if (isFirstElement) {
return;
}
this.focusOnElementAt(isFirstElement ? (this.elements.length - 1) : (index - 1));
/**
* Previous option event
*
* @event KeyboardNav#previousOption
* @type KeyboardNavigationEventData
*/
this.trigger('previousOption', this.createEventPayload(index));
};
/**
* Select the next element in the list. Select the first element,
* if the current element is the first element in the list.
*
* @param {Number} index The index of the currently selected element
* @public
* @fires KeyboardNav#previousOption
*/
KeyboardNav.prototype.nextOption = function (index) {
var isLastElement = index === this.elements.length - 1;
if (isLastElement) {
return;
}
this.focusOnElementAt(isLastElement ? 0 : (index + 1));
/**
* Previous option event
*
* @event KeyboardNav#nextOption
* @type KeyboardNavigationEventData
*/
this.trigger('nextOption', this.createEventPayload(index));
};
/**
* Focus on an element by index
*
* @param {Number} index The index of the element to focus on
* @public
*/
KeyboardNav.prototype.focusOnElementAt = function (index) {
this.setTabbableAt(index);
this.getElements()[index].focus();
};
/**
* Disable possibility to select a word trough click and space or enter
*
* @public
*/
KeyboardNav.prototype.disableSelectability = function () {
this.elements.forEach(function (el) {
el.el.removeEventListener('keydown', el.keyDown);
el.el.removeEventListener('click', el.onClick);
}.bind(this));
this.selectability = false;
};
/**
* Enable possibility to select a word trough click and space or enter
*
* @public
*/
KeyboardNav.prototype.enableSelectability = function () {
this.elements.forEach(function (el) {
el.el.addEventListener('keydown', el.keyDown);
el.el.addEventListener('click', el.onClick);
}.bind(this));
this.selectability = true;
};
/**
* Sets tabbable on a single element in the list, by index
* Also removes tabbable from all other elements in the list
*
* @param {Number} index The index of the element to set tabbale on
* @public
*/
KeyboardNav.prototype.setTabbableAt = function (index) {
this.removeAllTabbable();
this.getElements()[index].setAttribute('tabindex', '0');
};
/**
* Remove tabbable from all entries
*
* @public
*/
KeyboardNav.prototype.removeAllTabbable = function () {
this.elements.forEach(function(el){
el.el.removeAttribute('tabindex');
});
};
/**
* Toggles 'aria-selected' on an element, if selectability == true
*
* @param {EventTarget|HTMLElement} el The element to select/unselect
* @private
* @fires KeyboardNav#select
*/
KeyboardNav.prototype.toggleSelect = function(el){
if(this.selectability) {
// toggle selection
el.setAttribute('aria-selected', !isElementSelected(el));
// focus current
el.setAttribute('tabindex', '0');
el.focus();
var index = this.getElements().indexOf(el);
/**
* Previous option event
*
* @event KeyboardNav#select
* @type KeyboardNavigationEventData
*/
this.trigger('select', this.createEventPayload(index));
}
};
/**
* Handles key down
*
* @param {KeyboardEvent} event Keyboard event
* @private
*/
KeyboardNav.prototype.handleKeyDown = function(event){
var index;
switch (event.which) {
case 13: // Enter
case 32: // Space
// Select
this.toggleSelect(event.target);
event.preventDefault();
break;
case 37: // Left Arrow
case 38: // Up Arrow
// Go to previous Option
index = this.getElements().indexOf(event.currentTarget);
this.previousOption(index);
event.preventDefault();
break;
case 39: // Right Arrow
case 40: // Down Arrow
// Go to next Option
index = this.getElements().indexOf(event.currentTarget);
this.nextOption(index);
event.preventDefault();
break;
}
};
/**
* Get only elements from elements array
* @returns {Array}
*/
KeyboardNav.prototype.getElements = function () {
return this.elements.map(function (el) {
return el.el;
});
};
/**
* Handles element click. Toggles 'aria-selected' on element
*
* @param {MouseEvent} event Mouse click event
* @private
*/
KeyboardNav.prototype.onClick = function(event){
this.toggleSelect(event.currentTarget);
};
/**
* Creates a paylod for event that is fired
*
* @param {Number} index
* @return {KeyboardNavigationEventData}
*/
KeyboardNav.prototype.createEventPayload = function(index){
/**
* Data that is passed along as the event parameter
*
* @typedef {Object} KeyboardNavigationEventData
* @property {HTMLElement} element
* @property {number} index
* @property {boolean} selected
*/
return {
element: this.getElements()[index],
index: index,
selected: isElementSelected(this.getElements()[index])
};
};
/**
* Sets aria-selected="true" on an element
*
* @param {HTMLElement} el The element to set selected
* @return {boolean}
*/
var isElementSelected = function(el){
return el.getAttribute('aria-selected') === 'true';
};
return KeyboardNav;
})(H5P.EventDispatcher);
;
H5P.MarkTheWords = H5P.MarkTheWords || {};
/**
* Mark the words XapiGenerator
*/
H5P.MarkTheWords.XapiGenerator = (function ($) {
/**
* Xapi statements Generator
* @param {H5P.MarkTheWords} markTheWords
* @constructor
*/
function XapiGenerator(markTheWords) {
/**
* Generate answered event
* @return {H5P.XAPIEvent}
*/
this.generateAnsweredEvent = function () {
var xAPIEvent = markTheWords.createXAPIEventTemplate('answered');
// Extend definition
var objectDefinition = createDefinition(markTheWords);
$.extend(true, xAPIEvent.getVerifiedStatementValue(['object', 'definition']), objectDefinition);
// Set score
xAPIEvent.setScoredResult(markTheWords.getScore(),
markTheWords.getMaxScore(),
markTheWords,
true,
markTheWords.getScore() === markTheWords.getMaxScore()
);
// Extend user result
var userResult = {
response: getUserSelections(markTheWords)
};
$.extend(xAPIEvent.getVerifiedStatementValue(['result']), userResult);
return xAPIEvent;
};
}
/**
* Create object definition for question
*
* @param {H5P.MarkTheWords} markTheWords
* @return {Object} Object definition
*/
function createDefinition(markTheWords) {
var definition = {};
definition.description = {
'en-US': replaceLineBreaks(markTheWords.params.taskDescription)
};
definition.type = 'http://adlnet.gov/expapi/activities/cmi.interaction';
definition.interactionType = 'choice';
definition.correctResponsesPattern = [getCorrectResponsesPattern(markTheWords)];
definition.choices = getChoices(markTheWords);
definition.extensions = {
'https://h5p.org/x-api/line-breaks': markTheWords.getIndexesOfLineBreaks()
};
return definition;
}
/**
* Replace line breaks
*
* @param {string} description
* @return {string}
*/
function replaceLineBreaks(description) {
var sanitized = $('
' + description + '
').text();
return sanitized.replace(/(\n)+/g, '
');
}
/**
* Get all choices that it is possible to choose between
*
* @param {H5P.MarkTheWords} markTheWords
* @return {Array}
*/
function getChoices(markTheWords) {
return markTheWords.selectableWords.map(function (word, index) {
var text = word.getText();
if (text.charAt(0) === '*' && text.charAt(text.length - 1) === '*') {
text = text.substr(1, text.length - 2);
}
return {
id: index.toString(),
description: {
'en-US': $('
' + text + '
').text()
}
};
});
}
/**
* Get selected words as a user response pattern
*
* @param {H5P.MarkTheWords} markTheWords
* @return {string}
*/
function getUserSelections(markTheWords) {
return markTheWords.selectableWords
.reduce(function (prev, word, index) {
if (word.isSelected()) {
prev.push(index);
}
return prev;
}, []).join('[,]');
}
/**
* Get correct response pattern from correct words
*
* @param {H5P.MarkTheWords} markTheWords
* @return {string}
*/
function getCorrectResponsesPattern(markTheWords) {
return markTheWords.selectableWords
.reduce(function (prev, word, index) {
if (word.isAnswer()) {
prev.push(index);
}
return prev;
}, []).join('[,]');
}
return XapiGenerator;
})(H5P.jQuery);
;
H5P.MarkTheWords = H5P.MarkTheWords || {};
H5P.MarkTheWords.Word = (function () {
/**
* @constant
*
* @type {string}
*/
Word.ID_MARK_MISSED = "h5p-description-missed";
/**
* @constant
*
* @type {string}
*/
Word.ID_MARK_CORRECT = "h5p-description-correct";
/**
* @constant
*
* @type {string}
*/
Word.ID_MARK_INCORRECT = "h5p-description-incorrect";
/**
* Class for keeping track of selectable words.
*
* @class
* @param {jQuery} $word
*/
function Word($word, params) {
var self = this;
self.params = params;
H5P.EventDispatcher.call(self);
var input = $word.text();
var handledInput = input;
// Check if word is an answer
var isAnswer = checkForAnswer();
// Remove single asterisk and escape double asterisks.
handleAsterisks();
if (isAnswer) {
$word.text(handledInput);
}
const ariaText = document.createElement('span');
ariaText.classList.add('hidden-but-read');
$word[0].appendChild(ariaText);
/**
* Checks if the word is an answer by checking the first, second to last and last character of the word.
*
* @private
* @return {Boolean} Returns true if the word is an answer.
*/
function checkForAnswer() {
// Check last and next to last character, in case of punctuations.
var wordString = removeDoubleAsterisks(input);
if (wordString.charAt(0) === ('*') && wordString.length > 2) {
if (wordString.charAt(wordString.length - 1) === ('*')) {
handledInput = input.slice(1, input.length - 1);
return true;
}
// If punctuation, add the punctuation to the end of the word.
else if(wordString.charAt(wordString.length - 2) === ('*')) {
handledInput = input.slice(1, input.length - 2);
return true;
}
return false;
}
return false;
}
/**
* Removes double asterisks from string, used to handle input.
*
* @private
* @param {String} wordString The string which will be handled.
* @return {String} Returns a string without double asterisks.
*/
function removeDoubleAsterisks(wordString) {
var asteriskIndex = wordString.indexOf('*');
var slicedWord = wordString;
while (asteriskIndex !== -1) {
if (wordString.indexOf('*', asteriskIndex + 1) === asteriskIndex + 1) {
slicedWord = wordString.slice(0, asteriskIndex) + wordString.slice(asteriskIndex + 2, input.length);
}
asteriskIndex = wordString.indexOf('*', asteriskIndex + 1);
}
return slicedWord;
}
/**
* Escape double asterisks ** = *, and remove single asterisk.
*
* @private
*/
function handleAsterisks() {
var asteriskIndex = handledInput.indexOf('*');
while (asteriskIndex !== -1) {
handledInput = handledInput.slice(0, asteriskIndex) + handledInput.slice(asteriskIndex + 1, handledInput.length);
asteriskIndex = handledInput.indexOf('*', asteriskIndex + 1);
}
}
/**
* Removes any score points added to the marked word.
*/
self.clearScorePoint = function () {
const scorePoint = $word[0].querySelector('div');
if (scorePoint) {
scorePoint.parentNode.removeChild(scorePoint);
}
};
/**
* Get Word as a string
*
* @return {string} Word as text
*/
this.getText = function () {
return input;
};
/**
* Clears all marks from the word.
*
* @public
*/
this.markClear = function () {
$word
.attr('aria-selected', false)
.removeAttr('aria-describedby');
ariaText.innerHTML = '';
this.clearScorePoint();
};
/**
* Check if the word is correctly marked and style it accordingly.
* Reveal result
*
* @public
* @param {H5P.Question.ScorePoints} scorePoints
*/
this.markCheck = function (scorePoints) {
if (this.isSelected()) {
$word.attr('aria-describedby', isAnswer ? Word.ID_MARK_CORRECT : Word.ID_MARK_INCORRECT);
ariaText.innerHTML = isAnswer
? self.params.correctAnswer
: self.params.incorrectAnswer;
if (scorePoints) {
$word[0].appendChild(scorePoints.getElement(isAnswer));
}
}
else if (isAnswer) {
$word.attr('aria-describedby', Word.ID_MARK_MISSED);
ariaText.innerHTML = self.params.missedAnswer;
}
};
/**
* Checks if the word is marked correctly.
*
* @public
* @returns {Boolean} True if the marking is correct.
*/
this.isCorrect = function () {
return (isAnswer && this.isSelected());
};
/**
* Checks if the word is marked wrong.
*
* @public
* @returns {Boolean} True if the marking is wrong.
*/
this.isWrong = function () {
return (!isAnswer && this.isSelected());
};
/**
* Checks if the word is correct, but has not been marked.
*
* @public
* @returns {Boolean} True if the marking is missed.
*/
this.isMissed = function () {
return (isAnswer && !this.isSelected());
};
/**
* Checks if the word is an answer.
*
* @public
* @returns {Boolean} True if the word is an answer.
*/
this.isAnswer = function () {
return isAnswer;
};
/**
* Checks if the word is selected.
*
* @public
* @returns {Boolean} True if the word is selected.
*/
this.isSelected = function () {
return $word.attr('aria-selected') === 'true';
};
/**
* Sets that the Word is selected
*
* @public
*/
this.setSelected = function () {
$word.attr('aria-selected', 'true');
};
}
Word.prototype = Object.create(H5P.EventDispatcher.prototype);
Word.prototype.constructor = Word;
return Word;
})();
;
/*global H5P*/
/**
* Mark The Words module
* @external {jQuery} $ H5P.jQuery
*/
H5P.MarkTheWords = (function ($, Question, Word, KeyboardNav, XapiGenerator) {
/**
* Initialize module.
*
* @class H5P.MarkTheWords
* @extends H5P.Question
* @param {Object} params Behavior settings
* @param {Number} contentId Content identification
* @param {Object} contentData Object containing task specific content data
*
* @returns {Object} MarkTheWords Mark the words instance
*/
function MarkTheWords(params, contentId, contentData) {
var self = this;
this.contentId = contentId;
this.contentData = contentData;
this.introductionId = 'mark-the-words-introduction-' + contentId;
Question.call(this, 'mark-the-words');
// Set default behavior.
this.params = $.extend(true, {
taskDescription: "",
textField: "This is a *nice*, *flexible* content type.",
overallFeedback: [],
behaviour: {
enableRetry: true,
enableSolutionsButton: true,
enableCheckButton: true,
showScorePoints: true
},
checkAnswerButton: "Check",
tryAgainButton: "Retry",
showSolutionButton: "Show solution",
correctAnswer: "Correct!",
incorrectAnswer: "Incorrect!",
missedAnswer: "Answer not found!",
displaySolutionDescription: "Task is updated to contain the solution.",
scoreBarLabel: 'You got :num out of :total points',
a11yFullTextLabel: 'Full readable text',
a11yClickableTextLabel: 'Full text where words can be marked',
a11ySolutionModeHeader: 'Solution mode',
a11yCheckingHeader: 'Checking mode',
a11yCheck: 'Check the answers. The responses will be marked as correct, incorrect, or unanswered.',
a11yShowSolution: 'Show the solution. The task will be marked with its correct solution.',
a11yRetry: 'Retry the task. Reset all responses and start the task over again.',
}, params);
this.contentData = contentData;
if (this.contentData !== undefined && this.contentData.previousState !== undefined) {
this.previousState = this.contentData.previousState;
}
this.keyboardNavigators = [];
this.initMarkTheWords();
this.XapiGenerator = new XapiGenerator(this);
}
MarkTheWords.prototype = Object.create(H5P.EventDispatcher.prototype);
MarkTheWords.prototype.constructor = MarkTheWords;
/**
* Initialize Mark The Words task
*/
MarkTheWords.prototype.initMarkTheWords = function () {
this.$inner = $('
');
this.addTaskTo(this.$inner);
// Set user state
this.setH5PUserState();
};
/**
* Recursive function that creates html for the words
* @method createHtmlForWords
* @param {Array} nodes Array of dom nodes
* @return {string}
*/
MarkTheWords.prototype.createHtmlForWords = function (nodes) {
var self = this;
var html = '';
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
if (node instanceof Text) {
var text = $(node).text();
var selectableStrings = text.replace(/( |\r\n|\n|\r)/g, ' ')
.match(/ \*[^\* ]+\* |[^\s]+/g);
if (selectableStrings) {
selectableStrings.forEach(function (entry) {
entry = entry.trim();
// Words
if (html) {
// Add space before
html += ' ';
}
// Remove prefix punctuations from word
var prefix = entry.match(/^[\[\({⟨¿¡“"«„]+/);
var start = 0;
if (prefix !== null) {
start = prefix[0].length;
html += prefix;
}
// Remove suffix punctuations from word
var suffix = entry.match(/[",….:;?!\]\)}⟩»”]+$/);
var end = entry.length - start;
if (suffix !== null) {
end -= suffix[0].length;
}
// Word
entry = entry.substr(start, end);
if (entry.length) {
html += '
' + self.escapeHTML(entry) + '';
}
if (suffix !== null) {
html += suffix;
}
});
}
else if ((selectableStrings !== null) && text.length) {
html += '
' + this.escapeHTML(text) + '';
}
}
else {
if (node.nodeName === 'BR') {
html += '
';
}
else {
var attributes = ' ';
for (var j = 0; j < node.attributes.length; j++) {
attributes +=node.attributes[j].name + '="' + node.attributes[j].nodeValue + '" ';
}
html += '<' + node.nodeName + attributes + '>';
html += self.createHtmlForWords(node.childNodes);
html += '' + node.nodeName + '>';
}
}
}
return html;
};
/**
* Escapes HTML
*
* @param html
* @returns {jQuery}
*/
MarkTheWords.prototype.escapeHTML = function (html) {
return $('
').text(html).html();
};
/**
* Search for the last children in every paragraph and
* return their indexes in an array
*
* @returns {Array}
*/
MarkTheWords.prototype.getIndexesOfLineBreaks = function () {
var indexes = [];
var selectables = this.$wordContainer.find('span.h5p-word-selectable');
selectables.each(function(index, selectable) {
if ($(selectable).next().is('br')){
indexes.push(index);
}
if ($(selectable).parent('p') && !$(selectable).parent().is(':last-child') && $(selectable).is(':last-child')){
indexes.push(index);
}
});
return indexes;
};
/**
* Handle task and add it to container.
* @param {jQuery} $container The object which our task will attach to.
*/
MarkTheWords.prototype.addTaskTo = function ($container) {
var self = this;
self.selectableWords = [];
self.answers = 0;
// Wrapper
var $wordContainer = $('
', {
'class': 'h5p-word-selectable-words',
'aria-labelledby': self.introductionId,
'aria-multiselectable': 'true',
'role': 'listbox',
html: self.createHtmlForWords($.parseHTML(self.params.textField))
});
let isNewParagraph = true;
$wordContainer.find('[role="option"], br').each(function () {
if ($(this).is('br')) {
isNewParagraph = true;
return;
}
if (isNewParagraph) {
// Add keyboard navigation helper
self.currentKeyboardNavigator = new KeyboardNav();
// on word clicked
self.currentKeyboardNavigator.on('select', function () {
self.isAnswered = true;
self.triggerXAPI('interacted');
});
self.keyboardNavigators.push(self.currentKeyboardNavigator);
isNewParagraph = false;
}
self.currentKeyboardNavigator.addElement(this);
// Add keyboard navigation to this element
var selectableWord = new Word($(this), self.params);
if (selectableWord.isAnswer()) {
self.answers += 1;
}
self.selectableWords.push(selectableWord);
});
self.blankIsCorrect = (self.answers === 0);
if (self.blankIsCorrect) {
self.answers = 1;
}
// A11y full readable text
const $ariaTextWrapper = $('
', {
'class': 'hidden-but-read',
}).appendTo($container);
$('
', {
html: self.params.a11yFullTextLabel,
}).appendTo($ariaTextWrapper);
// Add space after each paragraph to read the sentences better
const ariaText = $('
', {
'html': $wordContainer.html().replace('', ' '),
}).text();
$('
', {
text: ariaText,
}).appendTo($ariaTextWrapper);
// A11y clickable list label
this.$a11yClickableTextLabel = $('
', {
'class': 'hidden-but-read',
html: self.params.a11yClickableTextLabel,
tabIndex: '-1',
}).appendTo($container);
$wordContainer.appendTo($container);
self.$wordContainer = $wordContainer;
};
/**
* Add check solution and retry buttons.
*/
MarkTheWords.prototype.addButtons = function () {
var self = this;
self.$buttonContainer = $('
', {
'class': 'h5p-button-bar'
});
if (this.params.behaviour.enableCheckButton) {
this.addButton('check-answer', this.params.checkAnswerButton, function () {
self.isAnswered = true;
var answers = self.calculateScore();
self.feedbackSelectedWords();
if (!self.showEvaluation(answers)) {
// Only show if a correct answer was not found.
if (self.params.behaviour.enableSolutionsButton && (answers.correct < self.answers)) {
self.showButton('show-solution');
}
if (self.params.behaviour.enableRetry) {
self.showButton('try-again');
}
}
// Set focus to start of text
self.$a11yClickableTextLabel.html(self.params.a11yCheckingHeader + ' - ' + self.params.a11yClickableTextLabel);
self.$a11yClickableTextLabel.focus();
self.hideButton('check-answer');
self.trigger(self.XapiGenerator.generateAnsweredEvent());
self.toggleSelectable(true);
}, true, {
'aria-label': this.params.a11yCheck,
});
}
this.addButton('try-again', this.params.tryAgainButton, this.resetTask.bind(this), false, {
'aria-label': this.params.a11yRetry,
});
this.addButton('show-solution', this.params.showSolutionButton, function () {
self.setAllMarks();
self.$a11yClickableTextLabel.html(self.params.a11ySolutionModeHeader + ' - ' + self.params.a11yClickableTextLabel);
self.$a11yClickableTextLabel.focus();
if (self.params.behaviour.enableRetry) {
self.showButton('try-again');
}
self.hideButton('check-answer');
self.hideButton('show-solution');
self.read(self.params.displaySolutionDescription);
self.toggleSelectable(true);
}, false, {
'aria-label': this.params.a11yShowSolution,
});
};
/**
* Toggle whether words can be selected
* @param {Boolean} disable
*/
MarkTheWords.prototype.toggleSelectable = function (disable) {
this.keyboardNavigators.forEach(function (navigator) {
if (disable) {
navigator.disableSelectability();
navigator.removeAllTabbable();
}
else {
navigator.enableSelectability();
navigator.setTabbableAt((0));
}
});
if (disable) {
this.$wordContainer.removeAttr('aria-multiselectable').removeAttr('role');
}
else {
this.$wordContainer.attr('aria-multiselectable', 'true')
.attr('role', 'listbox');
}
};
/**
* Get Xapi Data.
*
* @see used in contracts {@link https://h5p.org/documentation/developers/contracts#guides-header-6}
* @return {Object}
*/
MarkTheWords.prototype.getXAPIData = function () {
return {
statement: this.XapiGenerator.generateAnsweredEvent().data.statement
};
};
/**
* Mark the words as correct, wrong or missed.
*
* @fires MarkTheWords#resize
*/
MarkTheWords.prototype.setAllMarks = function () {
this.selectableWords.forEach(function (entry) {
entry.markCheck();
entry.clearScorePoint();
});
/**
* Resize event
*
* @event MarkTheWords#resize
*/
this.trigger('resize');
};
/**
* Mark the selected words as correct or wrong.
*
* @fires MarkTheWords#resize
*/
MarkTheWords.prototype.feedbackSelectedWords = function () {
var self = this;
var scorePoints;
if (self.params.behaviour.showScorePoints) {
scorePoints = new H5P.Question.ScorePoints();
}
this.selectableWords.forEach(function (entry) {
if (entry.isSelected()) {
entry.markCheck(scorePoints);
}
});
this.$wordContainer.addClass('h5p-disable-hover');
this.trigger('resize');
};
/**
* Evaluate task and display score text for word markings.
*
* @fires MarkTheWords#resize
* @return {Boolean} Returns true if maxScore was achieved.
*/
MarkTheWords.prototype.showEvaluation = function (answers) {
this.hideEvaluation();
var score = answers.score;
//replace editor variables with values, uses regexp to replace all instances.
var scoreText = H5P.Question.determineOverallFeedback(this.params.overallFeedback, score / this.answers).replace(/@score/g, score.toString())
.replace(/@total/g, this.answers.toString())
.replace(/@correct/g, answers.correct.toString())
.replace(/@wrong/g, answers.wrong.toString())
.replace(/@missed/g, answers.missed.toString());
this.setFeedback(scoreText, score, this.answers, this.params.scoreBarLabel);
this.trigger('resize');
return score === this.answers;
};
/**
* Clear the evaluation text.
*
* @fires MarkTheWords#resize
*/
MarkTheWords.prototype.hideEvaluation = function () {
this.removeFeedback();
this.trigger('resize');
};
/**
* Calculate the score.
*
* @return {Answers}
*/
MarkTheWords.prototype.calculateScore = function () {
var self = this;
/**
* @typedef {Object} Answers
* @property {number} correct The number of correct answers
* @property {number} wrong The number of wrong answers
* @property {number} missed The number of answers the user missed
* @property {number} score The calculated score
*/
var initial = {
correct: 0,
wrong: 0,
missed: 0,
score: 0
};
// iterate over words, and calculate score
var answers = self.selectableWords.reduce(function (result, word) {
if (word.isCorrect()) {
result.correct++;
}
else if (word.isWrong()) {
result.wrong++;
}
else if (word.isMissed()) {
result.missed++;
}
return result;
}, initial);
// if no wrong answers, and black is correct
if (answers.wrong === 0 && self.blankIsCorrect) {
answers.correct = 1;
}
// no negative score
answers.score = Math.max(answers.correct - answers.wrong, 0);
return answers;
};
/**
* Clear styling on marked words.
*
* @fires MarkTheWords#resize
*/
MarkTheWords.prototype.clearAllMarks = function () {
this.selectableWords.forEach(function (entry) {
entry.markClear();
});
this.$wordContainer.removeClass('h5p-disable-hover');
this.trigger('resize');
};
/**
* Returns true if task is checked or a word has been clicked
*
* @see {@link https://h5p.org/documentation/developers/contracts|Needed for contracts.}
* @returns {Boolean} Always returns true.
*/
MarkTheWords.prototype.getAnswerGiven = function () {
return this.blankIsCorrect ? true : this.isAnswered;
};
/**
* Counts the score, which is correct answers subtracted by wrong answers.
*
* @see {@link https://h5p.org/documentation/developers/contracts|Needed for contracts.}
* @returns {Number} score The amount of points achieved.
*/
MarkTheWords.prototype.getScore = function () {
return this.calculateScore().score;
};
/**
* Gets max score for this task.
*
* @see {@link https://h5p.org/documentation/developers/contracts|Needed for contracts.}
* @returns {Number} maxScore The maximum amount of points achievable.
*/
MarkTheWords.prototype.getMaxScore = function () {
return this.answers;
};
/**
* Get title
* @returns {string}
*/
MarkTheWords.prototype.getTitle = function () {
return H5P.createTitle((this.contentData && this.contentData.metadata && this.contentData.metadata.title) ? this.contentData.metadata.title : 'Mark the Words');
};
/**
* Display the evaluation of the task, with proper markings.
*
* @fires MarkTheWords#resize
* @see {@link https://h5p.org/documentation/developers/contracts|Needed for contracts.}
*/
MarkTheWords.prototype.showSolutions = function () {
var answers = this.calculateScore();
this.showEvaluation(answers);
this.setAllMarks();
this.read(this.params.displaySolutionDescription);
this.hideButton('try-again');
this.hideButton('show-solution');
this.hideButton('check-answer');
this.$a11yClickableTextLabel.html(this.params.a11ySolutionModeHeader + ' - ' + this.params.a11yClickableTextLabel);
this.toggleSelectable(true);
this.trigger('resize');
};
/**
* Resets the task back to its' initial state.
*
* @fires MarkTheWords#resize
* @see {@link https://h5p.org/documentation/developers/contracts|Needed for contracts.}
*/
MarkTheWords.prototype.resetTask = function () {
this.isAnswered = false;
this.clearAllMarks();
this.hideEvaluation();
this.hideButton('try-again');
this.hideButton('show-solution');
this.showButton('check-answer');
this.$a11yClickableTextLabel.html(this.params.a11yClickableTextLabel);
this.toggleSelectable(false);
this.trigger('resize');
};
/**
* Returns an object containing the selected words
*
* @public
* @returns {object} containing indexes of selected words
*/
MarkTheWords.prototype.getCurrentState = function () {
var selectedWordsIndexes = [];
if (this.selectableWords === undefined) {
return undefined;
}
this.selectableWords.forEach(function (selectableWord, swIndex) {
if (selectableWord.isSelected()) {
selectedWordsIndexes.push(swIndex);
}
});
return selectedWordsIndexes;
};
/**
* Sets answers to current user state
*/
MarkTheWords.prototype.setH5PUserState = function () {
var self = this;
// Do nothing if user state is undefined
if (this.previousState === undefined || this.previousState.length === undefined) {
return;
}
// Select words from user state
this.previousState.forEach(function (answeredWordIndex) {
if (isNaN(answeredWordIndex) || answeredWordIndex >= self.selectableWords.length || answeredWordIndex < 0) {
throw new Error('Stored user state is invalid');
}
self.selectableWords[answeredWordIndex].setSelected();
});
};
/**
* Register dom elements
*
* @see {@link https://github.com/h5p/h5p-question/blob/1558b6144333a431dd71e61c7021d0126b18e252/scripts/question.js#L1236|Called from H5P.Question}
*/
MarkTheWords.prototype.registerDomElements = function () {
// wrap introduction in div with id
var introduction = '
' + this.params.taskDescription + '
';
// Register description
this.setIntroduction(introduction);
// creates aria descriptions for correct/incorrect/missed
this.createDescriptionsDom().appendTo(this.$inner);
// Register content
this.setContent(this.$inner, {
'class': 'h5p-word'
});
// Register buttons
this.addButtons();
};
/**
* Creates dom with description to be used with aria-describedby
* @return {jQuery}
*/
MarkTheWords.prototype.createDescriptionsDom = function () {
var self = this;
var $el = $('
');
$('
' + self.params.correctAnswer + '
').appendTo($el);
$('
' + self.params.incorrectAnswer + '
').appendTo($el);
$('
' + self.params.missedAnswer + '
').appendTo($el);
return $el;
};
return MarkTheWords;
}(H5P.jQuery, H5P.Question, H5P.MarkTheWords.Word, H5P.KeyboardNav, H5P.MarkTheWords.XapiGenerator));
/**
* Static utility method for parsing H5P.MarkTheWords content item questions
* into format useful for generating reports.
*
* Example input: "
I like *pizza* and *burgers*.
"
*
* Produces the following:
* [
* {
* type: 'text',
* content: 'I like '
* },
* {
* type: 'answer',
* correct: 'pizza',
* },
* {
* type: 'text',
* content: ' and ',
* },
* {
* type: 'answer',
* correct: 'burgers'
* },
* {
* type: 'text',
* content: '.'
* }
* ]
*
* @param {string} question MarkTheWords textField (html)
*/
H5P.MarkTheWords.parseText = function (question) {
/**
* Separate all words surrounded by a space and an asterisk and any other
* sequence of non-whitespace characters from str into an array.
*
* @param {string} str
* @returns {string[]} array of all words in the given string
*/
function getWords(str) {
return str.match(/ \*[^\*]+\* |[^\s]+/g);
}
/**
* Replace each HTML tag in str with the provided value and return the resulting string
*
* Regexp expression explained:
* < - first character is '<'
* [^>]* - followed by zero or more occurences of any character except '>'
* > - last character is '>'
**/
function replaceHtmlTags(str, value) {
return str.replace(/<[^>]*>/g, value);
}
function startsAndEndsWith(char, str) {
return str.startsWith(char) && str.endsWith(char);
};
function removeLeadingPunctuation(str) {
return str.replace(/^[\[\({⟨¿¡“"«„]+/, '');
};
function removeTrailingPunctuation(str) {
return str.replace(/[",….:;?!\]\)}⟩»”]+$/, '');
};
/**
* Escape double asterisks ** = *, and remove single asterisk.
* @param {string} str
*/
function handleAsterisks(str) {
var asteriskIndex = str.indexOf('*');
while (asteriskIndex !== -1) {
str = str.slice(0, asteriskIndex) + str.slice(asteriskIndex + 1, str.length);
asteriskIndex = str.indexOf('*', asteriskIndex + 1);
}
return str;
};
/**
* Decode HTML entities (e.g. ) from the given string using the DOM API
* @param {string} str
*/
function decodeHtmlEntities(str) {
const el = document.createElement('textarea');
el.innerHTML = str;
return el.value;
};
const wordsWithAsterisksNotRemovedYet = getWords(replaceHtmlTags(decodeHtmlEntities(question), ' '))
.map(function(w) { return w.trim(); })
.map(function(w) { return removeLeadingPunctuation(w); })
.map(function(w) { return removeTrailingPunctuation(w); });
const allSelectableWords = wordsWithAsterisksNotRemovedYet
.map(function(w) { return handleAsterisks(w); });
const correctWordIndexes = [];
const correctWords = wordsWithAsterisksNotRemovedYet
.filter(function(w, i) {
if (startsAndEndsWith('*', w)) {
correctWordIndexes.push(i);
return true;
}
return false;
})
.map(function(w) { return handleAsterisks(w); });
const printableQuestion = replaceHtmlTags(decodeHtmlEntities(question), '')
.replace('\xa0', '\x20');
return {
alternatives: allSelectableWords,
correctWords: correctWords,
correctWordIndexes: correctWordIndexes,
textWithPlaceholders: printableQuestion.match(/[^\s]+/g)
.reduce(function(textWithPlaceholders, word, index) {
word = removeTrailingPunctuation(
removeLeadingPunctuation(word));
return textWithPlaceholders.replace(word, '%' + index);
}, printableQuestion)
};
};;