var H5P = H5P || {};
/**
* DragQuestion module.
*
* @param {jQuery} $
*/
H5P.DragQuestion = (function ($) {
window.my_globals = {
correctDZs : 0
};
/**
* Initialize module.
*
* @class
* @extend H5P.Question
* @param {Object} options Run parameters
* @param {Number} id Content identification
*/
function C(options, contentId, contentData) {
var self = this;
var i, j;
this.id = this.contentId = contentId;
H5P.Question.call(self, 'dragquestion');
this.options = $.extend(true, {}, {
scoreShow: 'Check',
correct: 'Show solution',
tryAgain: 'Retry',
feedback: 'You got @score of @total points',
question: {
settings: {
questionTitle: 'Drag and drop',
size: {
width: 620,
height: 310
},
dropZoneHighlighting: 'dragging',
autoAlignSpacing: 2
},
task: {
elements: [],
dropZones: []
}
},
behaviour: {
enableRetry: true,
preventResize: false,
singlePoint: true,
showSolutionsRequiresInput: true
}
}, options);
this.draggables = [];
this.dropZones = [];
this.answered = (contentData && contentData.previousState !== undefined && contentData.previousState.answers !== undefined && contentData.previousState.answers.length);
this.blankIsCorrect = true;
this.backgroundOpacity = (this.options.backgroundOpacity === undefined || this.options.backgroundOpacity.trim() === '') ? undefined : this.options.backgroundOpacity;
// List of drop zones that has no elements, i.e. not used for the task
var dropZonesWithoutElements = [];
// Create map over correct drop zones for elements
var task = this.options.question.task;
this.correctDZs = [];
for (i = 0; i < task.dropZones.length; i++) {
dropZonesWithoutElements.push(true); // All true by default
var correctElements = task.dropZones[i].correctElements;
for (j = 0; j < correctElements.length; j++) {
var correctElement = correctElements[j];
if (this.correctDZs[correctElement] === undefined) {
this.correctDZs[correctElement] = [];
}
this.correctDZs[correctElement].push(i);
}
}
correctDZs = this.correctDZs;
this.weight = 1;
// Add draggable elements
for (i = 0; i < task.elements.length; i++) {
var element = task.elements[i];
if (element.dropZones === undefined || !element.dropZones.length) {
continue; // Not a draggable
}
if (this.backgroundOpacity !== undefined) {
element.backgroundOpacity = this.backgroundOpacity;
}
// Restore answers from last session
var answers = null;
if (contentData && contentData.previousState !== undefined && contentData.previousState.answers !== undefined && contentData.previousState.answers[i] !== undefined) {
answers = contentData.previousState.answers[i];
}
// Create new draggable instance
var draggable = new Draggable(element, i, answers);
if (self.options.question.settings.dropZoneHighlighting === 'dragging') {
draggable.on('drag', function () {
self.$container.addClass('h5p-dq-highlight-dz');
});
draggable.on('dropped', function (event) {
self.$container.removeClass('h5p-dq-highlight-dz');
});
}
draggable.on('interacted', function () {
self.answered = true;
self.triggerXAPIScored(self.getScore(), self.getMaxScore(), 'interacted');
});
draggable.on('leavingDropZone', function (event) {
self.dropZones[event.data.dropZone].removeAlignable(event.data.$);
});
this.draggables[i] = draggable;
for (j = 0; j < element.dropZones.length; j++) {
dropZonesWithoutElements[element.dropZones[j]] = false;
}
}
// Create a count to subtrack from score
this.numDropZonesWithoutElements = 0;
// Add drop zones
for (i = 0; i < task.dropZones.length; i++) {
var dropZone = task.dropZones[i];
if (dropZonesWithoutElements[i] === true) {
this.numDropZonesWithoutElements += 1;
}
if (this.blankIsCorrect && dropZone.correctElements.length) {
this.blankIsCorrect = false;
}
if (dropZone.autoAlign) {
dropZone.autoAlign = {
spacing: self.options.question.settings.autoAlignSpacing,
size: self.options.question.settings.size
};
}
this.dropZones[i] = new DropZone(dropZone, i);
}
this.on('resize', self.resize, self);
this.on('domChanged', function(event) {
if (self.contentId === event.data.contentId) {
self.trigger('resize');
}
});
this.on('enterFullScreen', function () {
if (self.$container) {
self.$container.parents('.h5p-content').css('height', '100%');
self.trigger('resize');
}
});
this.on('exitFullScreen', function () {
if (self.$container) {
self.$container.parents('.h5p-content').css('height', 'auto');
self.trigger('resize');
}
});
}
C.prototype = Object.create(H5P.Question.prototype);
C.prototype.constructor = C;
/**
* Registers this question type's DOM elements before they are attached.
* Called from H5P.Question.
*/
C.prototype.registerDomElements = function () {
var self = this;
// Register introduction section
self.setIntroduction('
' + self.options.question.settings.questionTitle + '
');
// Set class if no background
var classes = '';
if (this.options.question.settings.background !== undefined) {
classes += 'h5p-dragquestion-has-no-background';
}
if (self.options.question.settings.dropZoneHighlighting === 'always' ) {
if (classes) {
classes += ' ';
}
classes += 'h5p-dq-highlight-dz-always';
}
// Register task content area
self.setContent(self.createQuestionContent(), {
'class': classes
});
// ... and buttons
self.registerButtons();
setTimeout(function () {
self.trigger('resize');
}, 200);
};
/**
* Get xAPI data.
* Contract used by report rendering engine.
*
* @see contract at {@link https://h5p.test.schulcloud-thueringen.de/documentation/developers/contracts#guides-header-6}
*
* @return {Object} xAPI data
*/
C.prototype.getXAPIData = function () {
var xAPIEvent = this.createXAPIEventTemplate('answered');
this.addQuestionToXAPI(xAPIEvent);
this.addResponseToXAPI(xAPIEvent);
return {
statement: xAPIEvent.data.statement
}
};
/**
* Add the question itselt to the definition part of an xAPIEvent
*/
C.prototype.addQuestionToXAPI = function(xAPIEvent) {
var definition = xAPIEvent.getVerifiedStatementValue(['object', 'definition']);
$.extend(definition, this.getXAPIDefinition());
};
/**
* Get object definition for xAPI statement.
*
* @return {Object} xAPI object definition
*/
C.prototype.getXAPIDefinition = function () {
var definition = {};
definition.description = {
// Remove tags, must wrap in div tag because jQuery 1.9 will crash if the string isn't wrapped in a tag.
'en-US': $('' + this.options.question.settings.questionTitle + '
').text()
};
definition.type = 'http://adlnet.gov/expapi/activities/cmi.interaction';
definition.interactionType = 'matching';
// Add sources, i.e. draggables
definition.source = [];
for (var i = 0; i < this.options.question.task.elements.length; i++) {
var el = this.options.question.task.elements[i];
if (el.dropZones && el.dropZones.length) {
var desc = el.type.params.alt ? el.type.params.alt : el.type.params.text;
definition.source.push({
'id': '' + i,
'description': {
// Remove tags, must wrap in div tag because jQuery 1.9 will crash if the string isn't wrapped in a tag.
'en-US': $('' + desc + '
').text()
}
});
}
}
// Add targets, i.e. drop zones, and the correct response pattern.
definition.correctResponsesPattern = [''];
definition.target = [];
var firstCorrectPair = true;
for (var i = 0; i < this.options.question.task.dropZones.length; i++) {
definition.target.push({
'id': '' + i,
'description': {
// Remove tags, must wrap in div tag because jQuery 1.9 will crash if the string isn't wrapped in a tag.
'en-US': $('' + this.options.question.task.dropZones[i].label + '
').text()
}
});
if (this.options.question.task.dropZones[i].correctElements) {
for (var j = 0; j < this.options.question.task.dropZones[i].correctElements.length; j++) {
if (!firstCorrectPair) {
definition.correctResponsesPattern[0] += '[,]';
}
definition.correctResponsesPattern[0] += i + '[.]' + this.options.question.task.dropZones[i].correctElements[j];
firstCorrectPair = false;
}
}
}
return definition;
};
/**
* Add the response part to an xAPI event
*
* @param {H5P.XAPIEvent} xAPIEvent
* The xAPI event we will add a response to
*/
C.prototype.addResponseToXAPI = function(xAPIEvent) {
var maxScore = this.getMaxScore();
var score = this.getScore();
var success = score == maxScore ? true : false;
xAPIEvent.setScoredResult(score, maxScore, this, true, success);
xAPIEvent.data.statement.result.response = this.getUserXAPIResponse();
};
/**
* Get what the user has answered encoded as an xAPI response pattern
*
* @return {string} xAPI encoded user response pattern
*/
C.prototype.getUserXAPIResponse = function () {
var answers = this.getUserAnswers();
if (!answers) {
return response;
}
return answers
.filter(function (answerMapping) {
return answerMapping.elements.length;
})
.map(function (answerMapping, index) {
return answerMapping.elements
.filter(function (element) {
return element.dropZone !== undefined;
}).map(function (element) {
return element.dropZone + '[.]' + index;
}).join('[,]');
}).filter(function (pattern) {
return pattern !== undefined && pattern !== '';
}).join('[,]');
};
/**
* Returns user answers
*/
C.prototype.getUserAnswers = function () {
return this.draggables.map(function (draggable, index) {
return {
index: index,
draggable: draggable
};
}).filter(function (draggableMapping) {
return draggableMapping.draggable !== undefined &&
draggableMapping.draggable.elements;
}).map(function (draggableMapping) {
return {
index: draggableMapping.index,
elements: draggableMapping.draggable.elements
}
});
};
/**
* Append field to wrapper.
*/
C.prototype.createQuestionContent = function () {
var i;
// If reattaching, we no longer show solution. So forget that we
// might have done so before.
this.$container = $('');
if (this.options.question.settings.background !== undefined) {
this.$container.css('backgroundImage', 'url("' + H5P.getPath(this.options.question.settings.background.path, this.id) + '")');
}
var task = this.options.question.task;
// Add elements (static and draggable)
for (i = 0; i < task.elements.length; i++) {
var element = task.elements[i];
if (element.dropZones !== undefined && element.dropZones.length !== 0) {
// Attach draggable elements
this.draggables[i].appendTo(this.$container, this.id);
}
else {
// Add static element
var $element = this.addElement(element, 'static', i);
H5P.newRunnable(element.type, this.id, $element);
var timedOutOpacity = function($el, el) {
setTimeout(function () {
C.setOpacity($el, 'background', el.backgroundOpacity);
}, 0);
};
timedOutOpacity($element, element);
}
}
// Attach drop zones
for (i = 0; i < this.dropZones.length; i++) {
this.dropZones[i].appendTo(this.$container, this.draggables);
}
return this.$container;
};
C.prototype.registerButtons = function () {
// Add show score button
this.addSolutionButton();
this.addRetryButton();
};
/**
* Makes sure element gets correct opacity when hovered.
*
* @param {jQuery} $element
* @param {Object} element
*/
C.addHover = function ($element, backgroundOpacity) {
$element.hover(function () {
$element.addClass('h5p-draggable-hover');
if (!$element.parent().hasClass('h5p-dragging')) {
C.setElementOpacity($element, backgroundOpacity);
}
}, function () {
if (!$element.parent().hasClass('h5p-dragging')) {
setTimeout(function () {
$element.removeClass('h5p-draggable-hover');
C.setElementOpacity($element, backgroundOpacity);
}, 1);
}
});
C.setElementOpacity($element, backgroundOpacity);
};
/**
* Makes element background transparent.
*
* @param {jQuery} $element
* @param {Number} opacity
*/
C.setElementOpacity = function ($element, opacity) {
C.setOpacity($element, 'borderColor', opacity);
C.setOpacity($element, 'boxShadow', opacity);
C.setOpacity($element, 'background', opacity);
};
/**
* Add solution button to our container.
*/
C.prototype.addSolutionButton = function () {
var that = this;
this.addButton('check-answer', this.options.scoreShow, function () {
that.answered = true;
that.showAllSolutions();
that.showScore();
var xAPIEvent = that.createXAPIEventTemplate('answered');
that.addQuestionToXAPI(xAPIEvent);
that.addResponseToXAPI(xAPIEvent);
that.trigger(xAPIEvent);
});
};
/**
* Add retry button to our container.
*/
C.prototype.addRetryButton = function () {
var that = this;
this.addButton('try-again', this.options.tryAgain, function () {
that.resetTask();
that.showButton('check-answer');
that.hideButton('try-again');
}, false);
};
/**
* Add element/drop zone to task.
*
* @param {Object} element
* @param {String} type Class
* @param {Number} id
* @returns {jQuery}
*/
C.prototype.addElement = function (element, type, id) {
return $('').appendTo(this.$container).data('id', id);
};
/**
* Set correct height of container
*/
C.prototype.resize = function (e) {
var self = this;
// Make sure we use all the height we can get. Needed to scale up.
if (this.$container === undefined || !this.$container.is(':visible')) {
// Not yet attached or visible – not possible to resize correctly
return;
}
// Check if decreasing iframe size
var decreaseSize = e && e.data && e.data.decreaseSize;
if (!decreaseSize) {
this.$container.css('height', '99999px');
self.$container.parents('.h5p-standalone.h5p-dragquestion').css('width', '');
}
var size = this.options.question.settings.size;
var ratio = size.width / size.height;
var parentContainer = this.$container.parent();
// Use parent container as basis for resize.
var width = parentContainer.width() - parseFloat(parentContainer.css('margin-left')) - parseFloat(parentContainer.css('margin-right'));
// Check if we need to apply semi full screen fix.
var $semiFullScreen = self.$container.parents('.h5p-standalone.h5p-dragquestion.h5p-semi-fullscreen');
if ($semiFullScreen.length) {
// Reset semi fullscreen width
$semiFullScreen.css('width', '');
// Decrease iframe size
if (!decreaseSize) {
self.$container.css('width', '10px');
$semiFullScreen.css('width', '');
// Trigger changes
setTimeout(function () {
self.trigger('resize', {decreaseSize: true});
}, 200);
}
// Set width equal to iframe parent width, since iframe content has not been update yet.
var $iframe = $(window.frameElement);
if ($iframe) {
var $iframeParent = $iframe.parent();
width = $iframeParent.width();
$semiFullScreen.css('width', width + 'px');
}
}
var height = width / ratio;
// Set natural size if no parent width
if (width <= 0) {
width = size.width;
height = size.height;
}
this.$container.css({
width: width + 'px',
height: height + 'px',
fontSize: (16 * (width / size.width)) + 'px'
});
};
/**
* Get css position in percentage.
*
* @param {jQuery} $container
* @param {jQuery} $element
* @returns {Object} CSS position
*/
C.positionToPercentage = function ($container, $element) {
return {
top: (parseInt($element.css('top')) * 100 / $container.innerHeight()) + '%',
left: (parseInt($element.css('left')) * 100 / $container.innerWidth()) + '%'
};
};
/**
* Disables all draggables.
* @public
*/
C.prototype.disableDraggables = function () {
this.draggables.forEach(function (draggable) {
draggable.disable();
});
};
/**
* Enables all draggables.
* @public
*/
C.prototype.enableDraggables = function () {
this.draggables.forEach(function (draggable) {
draggable.enable();
});
};
/**
* Get amount of empty drop zones.
*
* @param {number} totalDropZones Total drop zones in question
* @param {Array} correctDZs Correct drop zones for draggables
* @return {number} Amount of empty drop zones in question
*/
C.prototype.getDropzoneWithoutAnswer = function (totalDropZones, correctDZs) {
//Index of correctDZs is the draggable, and value is the drop zone it belongs to
var correctDropZones = [];
correctDZs.forEach(function (draggable) {
if (draggable.length) {
draggable.forEach(function (dropZone) {
if (correctDropZones.indexOf(dropZone) < 0) {
correctDropZones.push(dropZone);
}
});
}
});
return totalDropZones - correctDropZones.length - this.numDropZonesWithoutElements;
};
/**
* Shows the correct solutions on the boxes and disables input and buttons depending on settings.
* @public
* @params {Boolean} skipVisuals Skip visual animations.
*/
C.prototype.showAllSolutions = function (skipVisuals) {
this.points = 0;
this.rawPoints = 0;
// One correct point for each "no solution" dropzone
var emptyDropzones = this.getDropzoneWithoutAnswer(this.dropZones.length, this.correctDZs);
this.points += emptyDropzones;
this.rawPoints += emptyDropzones;
for (var i = 0; i < this.draggables.length; i++) {
var draggable = this.draggables[i];
if (draggable === undefined) {
continue;
}
//Disable all draggables in check mode.
if (!skipVisuals) {
draggable.disable();
}
// Find out where we are.
this.points += draggable.results(skipVisuals, this.correctDZs[i]);
this.rawPoints += draggable.rawPoints;
}
if (this.points < 0) {
this.points = 0;
}
if (!this.answered && this.blankIsCorrect) {
this.points = this.weight;
}
if (this.options.behaviour.singlePoint) {
this.points = (this.points === this.calculateMaxScore() ? 1 : 0);
}
if (!skipVisuals) {
this.hideButton('check-answer');
}
if (this.options.behaviour.enableRetry && !skipVisuals) {
this.showButton('try-again');
}
if (this.hasButton('check-answer') && (this.options.behaviour.enableRetry === false || this.points === this.getMaxScore())) {
// Max score reached, or the user cannot try again.
this.hideButton('try-again');
}
};
/**
* Display the correct solutions, hides button and disables input.
* Used in contracts.
* @public
*/
C.prototype.showSolutions = function () {
this.showAllSolutions();
this.showScore();
//Hide solution button:
this.hideButton('check-answer');
this.hideButton('try-again');
//Disable dragging during "solution" mode
this.disableDraggables();
};
/**
* Resets the task.
* Used in contracts.
* @public
*/
C.prototype.resetTask = function () {
this.points = 0;
this.rawPoints = 0;
this.answered = false;
//Enables Draggables
this.enableDraggables();
//Reset position and feedback.
this.draggables.forEach(function (draggable) {
result = draggable.results(true, correctDZs[draggable.id]);
if (result < 0) {
draggable.resetPosition();
}
});
//Show solution button
this.showButton('check-answer');
this.hideButton('try-again');
this.setFeedback();
};
/**
* Calculates the real max score.
*
* @returns {Number} Max points
*/
C.prototype.calculateMaxScore = function () {
var max = 0;
if (this.blankIsCorrect) {
return this.getDropzoneWithoutAnswer(this.dropZones.length, this.correctDZs);
}
max += this.getDropzoneWithoutAnswer(this.dropZones.length, this.correctDZs);
var elements = this.options.question.task.elements;
for (var i = 0; i < elements.length; i++) {
var correctDropZones = this.correctDZs[i];
if (correctDropZones === undefined || !correctDropZones.length) {
continue;
}
if (elements[i].multiple) {
max += correctDropZones.length;
}
else {
max++;
}
}
return max;
};
/**
* Get maximum score.
*
* @returns {Number} Max points
*/
C.prototype.getMaxScore = function () {
return (this.options.behaviour.singlePoint ? this.weight : this.calculateMaxScore());
};
/**
* Count the number of correct answers.
* Only works while showing solution.
*
* @returns {Number} Points
*/
C.prototype.getScore = function () {
this.showAllSolutions(true);
var points = this.points;
delete this.points;
return points;
};
/**
* Checks if all has been answered.
*
* @returns {Boolean}
*/
C.prototype.getAnswerGiven = function () {
return !this.options.behaviour.showSolutionsRequiresInput || this.answered || this.blankIsCorrect;
};
/**
* Shows the score to the user when the score button i pressed.
*/
C.prototype.showScore = function () {
var maxScore = this.calculateMaxScore();
if (this.options.behaviour.singlePoint) {
maxScore = 1;
}
var scoreText = this.options.feedback.replace('@score', this.points).replace('@total', maxScore);
this.setFeedback(scoreText, this.points, maxScore);
};
/**
* Packs info about the current state of the task into a object for
* serialization.
*
* @public
* @returns {object}
*/
C.prototype.getCurrentState = function () {
var state = {answers: []};
for (var i = 0; i < this.draggables.length; i++) {
var draggable = this.draggables[i];
if (draggable === undefined) {
continue;
}
var draggableAnswers = [];
for (var j = 0; j < draggable.elements.length; j++) {
var element = draggable.elements[j];
if (element === undefined || element.dropZone === undefined) {
continue;
}
// Verify that draggables actually has its correct positions
// (could have been chaged by auto-align without being updated in draggable)
element.position = C.positionToPercentage(this.$container, element.$);
// Store position and drop zone.
draggableAnswers.push({
x: Number(element.position.left.replace('%', '')),
y: Number(element.position.top.replace('%', '')),
dz: element.dropZone
});
}
if (draggableAnswers.length) {
// Add answers to state object for storage
state.answers[i] = draggableAnswers;
}
}
return state;
};
/**
* Gather copyright information for the current content.
*
* @returns {H5P.ContentCopyright}
*/
C.prototype.getCopyrights = function () {
var self = this;
var info = new H5P.ContentCopyrights();
var background = self.options.question.settings.background;
if (background !== undefined && background.copyright !== undefined) {
var image = new H5P.MediaCopyright(background.copyright);
image.setThumbnail(new H5P.Thumbnail(H5P.getPath(background.path, self.id), background.width, background.height));
info.addMedia(image);
}
for (var i = 0; i < self.options.question.task.elements.length; i++) {
var element = self.options.question.task.elements[i];
var instance = H5P.newRunnable(element.type, self.id);
if (instance.getCopyrights !== undefined) {
var rights = instance.getCopyrights();
rights.setLabel((element.dropZones.length ? 'Draggable ' : 'Static ') + (element.type.params.contentName !== undefined ? element.type.params.contentName : 'element'));
info.addContent(rights);
}
}
return info;
};
C.prototype.getTitle = function() {
return H5P.createTitle(this.options.question.settings.questionTitle);
};
/**
* Makes element background, border and shadow transparent.
*
* @param {jQuery} $element
* @param {String} property
* @param {Number} opacity
*/
C.setOpacity = function ($element, property, opacity) {
if (property === 'background') {
// Set both color and gradient.
C.setOpacity($element, 'backgroundColor', opacity);
C.setOpacity($element, 'backgroundImage', opacity);
if (!opacity) {
$element.css({
"background-color": "rgba(245, 245, 245, 0)",
"background-image": "none"
});
}
return;
}
opacity = (opacity === undefined ? 1 : opacity / 100);
// Private. Get css properties objects.
function getProperties(property, value) {
switch (property) {
case 'borderColor':
return {
borderTopColor: value,
borderRightColor: value,
borderBottomColor: value,
borderLeftColor: value
};
default:
var properties = {};
properties[property] = value;
return properties;
}
}
var original = $element.css(property);
// Reset css to be sure we're using CSS and not inline values.
var properties = getProperties(property, '');
$element.css(properties);
// Determine prop and assume all props are the same and use the first.
for (var prop in properties) {
break;
}
// Get value from css
var style = $element.css(prop);
if (style === '' || style === 'none') {
// No value from CSS, fall back to original
style = original;
}
style = C.setAlphas(style, 'rgba(', opacity); // Update rgba
style = C.setAlphas(style, 'rgb(', opacity); // Convert rgb
$element.css(getProperties(property, style));
};
/**
* Updates alpha channel for colors in the given style.
*
* @param {String} style
* @param {String} prefix
* @param {Number} alpha
*/
C.setAlphas = function (style, prefix, alpha) {
// Style undefined
if (!style) {
return;
}
var colorStart = style.indexOf(prefix);
while (colorStart !== -1) {
var colorEnd = style.indexOf(')', colorStart);
var channels = style.substring(colorStart + prefix.length, colorEnd).split(',');
// Set alpha channel
channels[3] = (channels[3] !== undefined ? parseFloat(channels[3]) * alpha : alpha);
style = style.substring(0, colorStart) + 'rgba(' + channels.join(',') + style.substring(colorEnd, style.length);
// Look for more colors
colorStart = style.indexOf(prefix, colorEnd);
}
return style;
};
/**
* Creates a new draggable instance.
* Makes it easier to keep track of all instance variables and elements.
*
* @class
* @param {object} element
* @param {number} id
* @param {array} [answers] from last session
*/
function Draggable(element, id, answers) {
var self = this;
H5P.EventDispatcher.call(this);
self.$ = $(self);
self.id = id;
self.elements = [];
self.x = element.x;
self.y = element.y;
self.width = element.width;
self.height = element.height;
self.backgroundOpacity = element.backgroundOpacity;
self.dropZones = element.dropZones;
self.type = element.type;
self.multiple = element.multiple;
if (answers) {
if (self.multiple) {
// Add base element
self.elements.push({});
}
// Add answers
for (var i = 0; i < answers.length; i++) {
self.elements.push({
dropZone: answers[i].dz,
position: {
left: answers[i].x + '%',
top: answers[i].y + '%'
}
});
}
}
}
Draggable.prototype = Object.create(H5P.EventDispatcher.prototype);
Draggable.prototype.constructor = Draggable;
/**
* Insert draggable elements into the given container.
*
* @param {jQuery} $container
* @param {Number} contentId
* @returns {undefined}
*/
Draggable.prototype.appendTo = function ($container, contentId) {
var self = this;
if (!self.elements.length) {
self.attachElement(null, $container, contentId);
}
else {
for (var i = 0; i < self.elements.length; i++) {
self.attachElement(i, $container, contentId);
}
}
};
/**
* Attach the given element to the given container.
*
* @param {Number} index
* @param {jQuery} $container
* @param {Number} contentId
* @returns {undefined}
*/
Draggable.prototype.attachElement = function (index, $container, contentId) {
var self = this;
var element;
if (index === null) {
// Create new element
element = {};
self.elements.push(element);
index = self.elements.length - 1;
}
else {
// Get old element
element = self.elements[index];
}
// Attach element
element.$ = $('', {
class: 'h5p-draggable',
css: {
left: self.x + '%',
top: self.y + '%',
width: self.width + 'em',
height: self.height + 'em'
},
appendTo: $container
})
.draggable({
revert: function (dropZone) {
$container.removeClass('h5p-dragging');
var $this = $(this);
$this.removeClass('h5p-dropped').data("uiDraggable").originalPosition = {
top: self.y + '%',
left: self.x + '%'
};
C.setElementOpacity($this, self.backgroundOpacity);
self.trigger('dropped');
return !dropZone;
},
start: function(event, ui) {
var $this = $(this);
if (self.multiple && element.dropZone === undefined) {
// Leave a new element for next drag
self.attachElement(null, $container, contentId);
}
// Send element to the top!
$this.removeClass('h5p-wrong').detach().appendTo($container);
$container.addClass('h5p-dragging');
C.setElementOpacity($this, self.backgroundOpacity);
self.trigger('drag');
},
stop: function(event, ui) {
var $this = $(this);
// Convert position to % to support scaling.
element.position = C.positionToPercentage($container, $this);
$this.css(element.position);
var addToZone = $this.data('addToZone');
if (addToZone !== undefined) {
$this.removeData('addToZone');
if (self.multiple) {
// Check that we're the only element here
for (var i = 0; i < self.elements.length; i++) {
if (i !== index && self.elements[i] !== undefined && self.elements[i].dropZone === addToZone) {
// Remove element
if (self.elements[index].dropZone !== undefined && self.elements[index].dropZone !== addToZone) {
// Leaving old drop zone!
self.trigger('leavingDropZone', element);
}
$this.remove();
delete self.elements[index];
return;
}
}
}
if (element.dropZone !== undefined && element.dropZone !== addToZone) {
// Leaving old drop zone!
self.trigger('leavingDropZone', element);
}
element.dropZone = addToZone;
$this.addClass('h5p-dropped');
C.setElementOpacity($this, self.backgroundOpacity);
self.trigger('interacted');
}
else {
if (self.multiple) {
// Remove element
if (self.elements[index].dropZone !== undefined) {
self.trigger('leavingDropZone', self.elements[index]);
}
$this.remove();
delete self.elements[index];
}
else {
// Reset position and drop zone.
if (element.dropZone !== undefined) {
self.trigger('leavingDropZone', element);
delete element.dropZone;
}
delete element.position;
}
}
}
}).css('position', '');
self.element = element;
if (element.position) {
// Restore last position
element.$.css(element.position).addClass('h5p-dropped');
}
C.addHover(element.$, self.backgroundOpacity);
H5P.newRunnable(self.type, contentId, element.$);
// Update opacity when element is attached.
setTimeout(function () {
C.setElementOpacity(element.$, self.backgroundOpacity);
}, 0);
};
/**
* Check if this element can be dragged to the given drop zone.
*
* @param {Number} id
* @returns {Boolean}
*/
Draggable.prototype.hasDropZone = function (id) {
var self = this;
for (var i = 0; i < self.dropZones.length; i++) {
if (parseInt(self.dropZones[i]) === id) {
return true;
}
}
return false;
};
/**
* Resets the position of the draggable to its' original position.
* @public
*/
Draggable.prototype.resetPosition = function () {
var self = this;
this.elements.forEach(function (draggable) {
//If the draggable is in a dropzone reset its' position and feedback.
if (draggable.dropZone !== undefined) {
var element = draggable.$;
//Revert the button to initial position and then remove it.
element.animate({
left: self.x + '%',
top: self.y + '%'
}, function () {
//Remove the draggable if it is an infinity draggable.
if (self.multiple) {
if (element.dropZone !== undefined) {
self.trigger('leavingDropZone', element);
}
element.remove();
//Delete the element from elements list to avoid a cluster of draggables on top of infinity draggable.
if (self.elements.indexOf(draggable) >= 0) {
delete self.elements[self.elements.indexOf(draggable)];
}
}
});
// Reset element style
element.removeClass('h5p-wrong')
.removeClass('h5p-correct')
.removeClass('h5p-dropped')
.css({
border: '',
background: ''
});
C.setElementOpacity(element, self.backgroundOpacity);
}
});
// Draggable removed from dropzone.
if (self.element.dropZone !== undefined) {
self.trigger('leavingDropZone', self.element);
delete self.element.dropZone;
}
// Reset style on initial element
self.element.$.removeClass('h5p-wrong')
.removeClass('h5p-correct')
.removeClass('h5p-dropped')
.css({
border: '',
background: ''
});
C.setElementOpacity(self.element.$, self.backgroundOpacity);
};
/**
* Check if the given draggable dom element is a part of this draggable.
*
* @param {Object} draggable
* @returns {Boolean}
*/
Draggable.prototype.is = function (draggable) {
var self = this;
for (var i = 0; i < self.elements.length; i++) {
if (self.elements[i] !== undefined && self.elements[i].$.is(draggable)) {
return true;
}
}
return false;
};
/**
* Detemine if any of our elements is in the given drop zone.
*
* @param {Number} id
* @returns {Boolean}
*/
Draggable.prototype.isInDropZone = function (id) {
var self = this;
for (var i = 0; i < self.elements.length; i++) {
if (self.elements[i] !== undefined && self.elements[i].dropZone === id) {
return true;
}
}
return false;
};
/**
* Disables the draggable.
* @public
*/
Draggable.prototype.disable = function () {
var self = this;
for (var i = 0; i < self.elements.length; i++) {
if (self.elements[i] !== undefined) {
self.elements[i].$.draggable('disable');
}
}
};
/**
* Enables the draggable.
* @public
*/
Draggable.prototype.enable = function () {
var self = this;
for (var i = 0; i < self.elements.length; i++) {
if (self.elements[i] !== undefined) {
self.elements[i].$.draggable('enable');
}
}
};
/**
* Calculate score for this draggable.
*
* @param {Boolean} skipVisuals
* @param {Array} solutions
* @returns {Number}
*/
Draggable.prototype.results = function (skipVisuals, solutions) {
var self = this;
var i, j, element, dropZone, correct, points = 0;
self.rawPoints = 0;
if (solutions === undefined) {
// We should not be anywhere.
for (i = 0; i < self.elements.length; i++) {
element = self.elements[i];
if (element !== undefined && element.dropZone !== undefined) {
// ... but we are!
if (skipVisuals !== true) {
element.$.addClass('h5p-wrong');
C.setElementOpacity(element.$, self.backgroundOpacity);
}
points--;
}
}
return points;
}
// Are we somewhere we should be?
for (i = 0; i < self.elements.length; i++) {
element = self.elements[i];
if (element === undefined || element.dropZone === undefined) {
continue; // We have not been placed anywhere, we're neither wrong nor correct.
}
correct = false;
for (j = 0; j < solutions.length; j++) {
if (element.dropZone === solutions[j]) {
// Yepp!
if (skipVisuals !== true) {
element.$.addClass('h5p-correct').draggable('disable');
C.setElementOpacity(element.$, self.backgroundOpacity);
}
correct = true;
self.rawPoints++;
points++;
break;
}
}
if (!correct) {
// Nope, we're in another zone
if (skipVisuals !== true) {
element.$.addClass('h5p-wrong');
C.setElementOpacity(element.$, self.backgroundOpacity);
}
points--;
}
}
return points;
};
/**
* Creates a new drop zone instance.
* Makes it easy to keep track of all instance variables.
*
* @param {Object} dropZone
* @param {Number} id
* @returns {_L8.DropZone}
*/
function DropZone(dropZone, id) {
var self = this;
self.id = id;
self.showLabel = dropZone.showLabel;
self.label = dropZone.label;
self.x = dropZone.x;
self.y = dropZone.y;
self.width = dropZone.width;
self.height = dropZone.height;
self.backgroundOpacity = dropZone.backgroundOpacity;
self.tip = dropZone.tip;
self.single = dropZone.single;
self.autoAlignEnabled = dropZone.autoAlign;
self.alignables = [];
}
/**
* Insert drop zone in the given container.
*
* @param {jQuery} $container
* @param {Array} draggables
* @returns {undefined}
*/
DropZone.prototype.appendTo = function ($container, draggables) {
var self = this;
// Prepare inner html
var html = '';
var extraClass = '';
if (self.showLabel) {
html = '' + self.label + '
' + html;
extraClass = ' h5p-has-label';
}
// Create drop zone element
self.$dropZone = $('', {
class: 'h5p-dropzone' + extraClass,
css: {
left: self.x + '%',
top: self.y + '%',
width: self.width + 'em',
height: self.height + 'em'
},
html: html
})
.appendTo($container)
.children('.h5p-inner')
.droppable({
activeClass: 'h5p-active',
tolerance: 'intersect',
accept: function (draggable) {
var element;
for (var i = 0; i < draggables.length; i++) {
if (draggables[i] === undefined) {
continue;
}
if (self.single && draggables[i].isInDropZone(self.id)) {
// This drop zone is already occupied!
return false;
}
if (draggables[i].is(draggable)) {
// Found the draggable's instance
element = draggables[i];
if (!self.single) {
break;
}
}
}
if (element === undefined) {
return;
}
// Check to see if the draggable can be dropped in this zone
return element.hasDropZone(self.id);
},
drop: function (event, ui) {
var $this = $(this);
C.setOpacity($this.removeClass('h5p-over'), 'background', self.backgroundOpacity);
ui.draggable.data('addToZone', self.id);
if (self.autoAlignEnabled) {
if (self.getIndexOf(ui.draggable) === -1) {
// Add to alignables
self.alignables.push(ui.draggable);
}
// Trigger alignment
self.autoAlign();
}
},
over: function (event, ui) {
C.setOpacity($(this).addClass('h5p-over'), 'background', self.backgroundOpacity);
},
out: function (event, ui) {
C.setOpacity($(this).removeClass('h5p-over'), 'background', self.backgroundOpacity);
}
})
.end();
// Add tip after setOpacity(), so this does not get background opacity:
if (self.tip !== undefined && self.tip.trim().length) {
self.$dropZone.append(H5P.JoubelUI.createTip(self.tip));
}
if (self.autoAlignEnabled) {
draggables.forEach(function (draggable) {
var dragEl = draggable.element.$;
// Add to alignables
if (draggable.isInDropZone(self.id) && self.getIndexOf(dragEl) === -1) {
self.alignables.push(dragEl);
}
});
self.autoAlign();
}
// Set element opacity when element has been appended
setTimeout(function () {
C.setOpacity(self.$dropZone.children('.h5p-label'), 'background', self.backgroundOpacity);
C.setOpacity(self.$dropZone.children('.h5p-inner'), 'background', self.backgroundOpacity);
}, 0);
};
/**
* Find index of given alignable
*
* @param {jQuery} $alignable
* @return {number}
*/
DropZone.prototype.getIndexOf = function ($alignable) {
var self = this;
for (var i = 0; i < self.alignables.length; i++) {
if (self.alignables[i][0] === $alignable[0]) {
return i;
}
}
return -1
};
/**
* Remove alignable
*
* @param {jQuery} $alignable
*/
DropZone.prototype.removeAlignable = function ($alignable) {
var self = this;
// Find alignable index
var index = self.getIndexOf($alignable);
if (index !== -1) {
// Remove alignable
self.alignables.splice(index, 1);
if (self.autoAlignTimer === undefined) {
// Schedule re-aligment of alignables left
self.autoAlignTimer = setTimeout(function () {
delete self.autoAlignTimer;
self.autoAlign();
}, 1);
}
}
}
/**
* Auto-align alignable elements inside drop zone.
*/
DropZone.prototype.autoAlign = function () {
var self = this;
// Determine container size in order to calculate percetages
var containerSize = self.$dropZone.parent()[0].getBoundingClientRect();
// Calcuate borders and spacing values in percetage
var spacing = {
x: (self.autoAlignEnabled.spacing / self.autoAlignEnabled.size.width) * 100,
y: (self.autoAlignEnabled.spacing / self.autoAlignEnabled.size.height) * 100
};
// Determine coordinates for first 'spot'
var pos = {
x: self.x + spacing.x,
y: self.y + spacing.y
};
// Determine space inside drop zone
var dropZoneSize = self.$dropZone[0].getBoundingClientRect();
var space = {
x: dropZoneSize.width - (spacing.x * 2),
y: dropZoneSize.height - (spacing.y * 2)
};
// Set current space left inside drop zone
var spaceLeft = {
x: space.x,
y: space.y
};
// Set height for the active row of elements
var currentRowHeight = 0;
// Current alignable element and it's size
var $alignable, alignableSize;
/**
* Helper doing the actual positioning of the element + recalculating
* next position and space left.
*
* @private
*/
var alignElement = function () {
// Position element at current spot
$alignable.css({
left: pos.x + '%',
top: pos.y + '%'
});
// Update horizontal space left + next position
var spaceDiffX = (alignableSize.width + self.autoAlignEnabled.spacing);
spaceLeft.x -= spaceDiffX;
pos.x += (spaceDiffX / containerSize.width) * 100;
// Keep track of the highest element in this row
var spaceDiffY = (alignableSize.height + self.autoAlignEnabled.spacing);
if (spaceDiffY > currentRowHeight) {
currentRowHeight = spaceDiffY;
}
};
// Try to order and align the alignables inside the drop zone
// (in the order they were added)
for (var i = 0; i < self.alignables.length; i++) {
// Determine alignable size
$alignable = self.alignables[i];
alignableSize = $alignable[0].getBoundingClientRect();
// Try to fit on the current row
if (spaceLeft.x >= alignableSize.width) {
alignElement();
}
else {
// Did not fit, try next row
// Reset X values
spaceLeft.x = space.x;
pos.x = self.x + spacing.x;
// Bump Y values
if (currentRowHeight) {
// Update Y space and position according to previous row height
spaceLeft.y -= currentRowHeight;
pos.y += (currentRowHeight / containerSize.height) * 100;
// Reset
currentRowHeight = 0;
}
if (spaceLeft.y <= 0) {
return; // No more vertical space left, stop all aliging
}
alignElement();
}
}
};
return C;
})(H5P.jQuery);