Rev 5 | AutorÃa | Comparar con el anterior | Ultima modificación | Ver Log |
// This file is part of Moodle - https://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Module to display and manage reactions and difficulty tracks on course page.
* @copyright 2020 Quentin Fombaron, 2021 Astor Bizard
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define(['jquery', 'core/ajax', 'core/notification'], function($, ajax, notification) {
/**
* Call a function each time a course section is loaded.
* @param {Function} call Function to call.
*/
function callOnModulesListLoad(call) {
call();
// The following listener is needed for the Tiles course format, where sections are loaded on demand.
$(document).ajaxComplete(function(event, xhr, settings) {
if (typeof (settings.data) !== 'string') {
return;
}
try {
var data = JSON.parse(settings.data);
if (data.length == 0 || typeof (data[0].methodname) === 'undefined') {
return;
}
if (data[0].methodname == 'format_tiles_get_single_section_page_html' // Tile load.
|| data[0].methodname == 'format_tiles_log_tile_click') { // Tile load, cached.
call();
}
} catch (e) {
// Something went wrong, it may not even be JSON. It is fine, this means it was not the call we were expecting.
return;
}
});
}
/**
* Set up difficulty tracks on course modules.
* @param {Array} difficultyLevels Array of difficulty tracks, one entry for each course module.
* @param {Array} trackColors Tracks colors, from block plugin configuration.
* @param {Number|null} cmid The course module ID, if this page is a module view.
*/
function setUpDifficultyTracks(difficultyLevels, trackColors, cmid) {
difficultyLevels.forEach(function(module) {
var difficultyLevel = parseInt(module.difficultyLevel);
var title = '';
if (difficultyLevel > 0) {
var track = ['greentrack', 'bluetrack', 'redtrack', 'blacktrack'][difficultyLevel - 1];
title = M.util.get_string(track, 'block_point_view');
}
var $track = $('<div>', {
'class': 'block_point_view track',
'title': title,
'style': 'background-color: ' + trackColors[difficultyLevel] + ';'
});
// Decide where to put the track.
var $container = $('#module-' + module.id + ' .activitytitle');
if ($container.closest('.activity-grid').length) {
// Moodle 4.3+.
$container = $container.closest('.activity-grid');
}
if ($container.length === 0) {
// This seems to be a label.
$container = $('#module-' + module.id + ' .activity-item .description,' +
'#module-' + module.id + ' .activity-item .activity-altcontent').first();
}
// Add the track.
if ($container.find('.block_point_view.track').length === 0) {
$container.prepend($track);
}
// If there is indentation, move the track after it.
$container.find('.mod-indent').after($track);
if (cmid === module.id) {
// This is a module page, add the track to the title.
$('.page-context-header').prepend($track);
}
});
}
/**
* Get a jQuery object in reaction zone for given module ID.
* @param {Number} moduleId Module ID.
* @param {String} selector (optional) Sub-selector.
* @return {jQuery} If selector was provided, the corresponding jQuery object within the reaction zone.
* If not, the reaction zone jQuery object.
*/
function $get(moduleId, selector) {
var $element = $('#module-' + moduleId + ' .block_point_view.reactions-container');
if (typeof (selector) === 'undefined') {
return $element;
} else {
return $element.find(selector);
}
}
// Enumeration of the possible reactions.
var Reactions = {
none: 0,
easy: 1,
better: 2,
hard: 3
};
// Array of Reaction of the user for the activity.
var reactionVotedArray = {};
/**
* Set up difficulty tracks on course modules.
* @param {Number} courseId Course ID.
* @param {Array} modulesWithReactions Array of reactions state, one entry for each course module with reactions enabled.
* @param {String} reactionsHtml HTML fragment for reactions.
* @param {Array} pixSrc Array of pictures sources for group images.
* @param {Number|null} cmid The course module ID, if this page is a module view.
*/
function setUpReactions(courseId, modulesWithReactions, reactionsHtml, pixSrc, cmid) {
// For each selected module, create a reaction zone.
modulesWithReactions.forEach(function(module) {
var moduleId = parseInt(module.cmid);
var uservote = parseInt(module.uservote);
// Initialise reactionVotedArray.
reactionVotedArray[moduleId] = uservote;
if (module.cmid === cmid) {
// Simulate an activity row on course page (so we treat it the same for what's next).
$('<div id="module-' + moduleId + '" class="activity-wrapper mr-5" style="width: 165px;">')
.insertAfter($('.header-actions-container'))
.prepend('<div class="activity-instance">');
}
if ($('#module-' + moduleId).length === 1 && $get(moduleId).length === 0) {
// Add the reaction zone to the module.
var $module = $('#module-' + moduleId);
if ($module.is('.modtype_label')) {
// Label.
$module.find('.description, .activity-grid').first().before(reactionsHtml);
} else if ($module.find('.tiles-activity-container').length) {
// Tiles format.
$module.find('.tiles-activity-container').after(reactionsHtml);
} else {
$module.find('.activity-instance').after(reactionsHtml);
}
// Setup reaction change.
var reactionsLock = false;
$get(moduleId, '.reaction img')
.click(function() {
// Use a mutex to avoid query / display inconsistencies.
// This is not a perfect mutex, but is actually enough for our needs.
if (reactionsLock === false) {
reactionsLock = true;
reactionChange(courseId, moduleId, $(this).data('reactionname'))
.always(function() {
reactionsLock = false;
updateGroupImgAndNb(moduleId, pixSrc);
});
}
});
// Initialize reactions state.
$get(moduleId, '.reactionnb').each(function() {
var reactionName = $(this).data('reactionname');
var reactionNb = parseInt(module['total' + reactionName]);
updateReactionNb(moduleId, reactionName, reactionNb, uservote === Reactions[reactionName]);
});
updateGroupImgAndNb(moduleId, pixSrc);
// Setup animations.
setupReactionsAnimation(moduleId, pixSrc);
}
});
}
/**
* Manage a reaction change (user added, removed or updated their vote).
* @param {Number} courseId Course ID.
* @param {Number} moduleId Module ID.
* @param {String} reactionName The reaction being clicked.
* @returns {Promise} A promise, result of the change operations (ajax call and UI update).
*/
function reactionChange(courseId, moduleId, reactionName) {
var reactionSelect = Reactions[reactionName];
var previousReaction = reactionVotedArray[moduleId];
// If the reaction being clicked is the current one, it is a vote remove.
var newVote = (reactionSelect === previousReaction) ? Reactions.none : reactionSelect;
return ajax.call([
{
methodname: 'block_point_view_update_db',
args: {
func: 'update',
courseid: courseId,
cmid: moduleId,
vote: newVote
}
}
])[0]
.done(function() {
reactionVotedArray[moduleId] = newVote; // Set current reaction.
if (previousReaction !== Reactions.none) {
// User canceled their vote (or updated to another one).
var previousReactionName = ['', 'easy', 'better', 'hard'][previousReaction];
updateReactionNb(moduleId, previousReactionName, -1, false);
}
if (newVote !== Reactions.none) {
// User added or updated their vote.
updateReactionNb(moduleId, reactionName, +1, true); // Add new vote.
}
})
.fail(notification.exception);
}
/**
* Update the reactions group image and total number according to current votes.
* @param {Number} moduleId Module ID.
* @param {Array} pix Array of pictures sources for group images.
*/
function updateGroupImgAndNb(moduleId, pix) {
// Build group image name.
var groupImg = 'group_';
var totalNb = 0;
$get(moduleId, '.reactionnb').each(function() {
var reactionNb = parseInt($(this).text());
if (reactionNb > 0) {
groupImg += $(this).data('reactionname').toUpperCase().charAt(0); // Add E, B or H.
}
totalNb += reactionNb;
});
// Modify the image source of the reaction group.
$get(moduleId, '.group_img').attr('src', pix[groupImg]);
// Update the total number of votes.
var $groupNbWrapper = $get(moduleId, '.group_nb');
var $groupNb = $groupNbWrapper.find('span');
$groupNb
.text(totalNb)
.attr('title', M.util.get_string('totalreactions', 'block_point_view', totalNb));
$groupNbWrapper
.toggleClass('invisible', totalNb === 0)
.toggleClass('voted', reactionVotedArray[moduleId] !== Reactions.none);
// Adjust the size to fit within a fixed space (useful for the green dot).
var digits = Math.min(('' + totalNb).length, 5);
$groupNb.css({
'right': Math.max(0.25 * (digits - 2), 0) + 'em',
'transform': 'scaleX(' + (1.0 + 0.03 * digits * digits - 0.35 * digits + 0.34) + ')'
});
}
/**
* Update a reaction number of votes.
* @param {Number} moduleId Module ID.
* @param {String} reactionName The reaction to update the number of.
* @param {Number} diff Difference to apply (e.g. +1 for adding a vote, -1 for removing a vote).
* @param {Boolean} isSelected Whether the reaction we are updating is the one now selected by user.
*/
function updateReactionNb(moduleId, reactionName, diff, isSelected) {
var $reactionNb = $get(moduleId, '.reactionnb[data-reactionname="' + reactionName + '"]');
var nbReaction = parseInt($reactionNb.text()) + diff;
$reactionNb
.text(nbReaction)
.toggleClass('nbselected', isSelected);
$get(moduleId, '.reaction img[data-reactionname="' + reactionName + '"]')
.toggleClass('novote', nbReaction === 0);
}
/**
* Set up animations to swap between reactions preview and vote interface.
* @param {Number} moduleId Module ID.
*/
function setupReactionsAnimation(moduleId) {
// Helpers to resize images for animations.
var reactionImageSizeForRatio = function(ratio) {
return {
top: 15 - (10 * ratio),
left: 10 - (10 * ratio),
height: 20 * ratio
};
};
var groupImageSizeForRatio = function(ratio) {
return {
left: -10 + (10 * ratio),
height: 20 * ratio
};
};
// Animation sequence to hide reactions preview and show vote interface.
var showReactions = function(moduleId) {
$get(moduleId, '.reactions').removeClass('invisible');
$get(moduleId, '.group_img')
.css({'pointer-events': 'none'})
.animate(groupImageSizeForRatio(0), 300)
.hide(0);
$get(moduleId, '.group_nb').delay(200).hide(300);
$('#module-' + moduleId + ' button[data-action="toggle-manual-completion"],' +
'#module-' + moduleId + ' .activity-info .automatic-completion-conditions > span.badge:first-of-type,' +
'#module-' + moduleId + ' .activity-information [data-region="completionrequirements"]')
.delay(200).queue(function(next) {
// Use opacity transition for a smooth hiding.
$(this).css({
opacity: 0,
transition: 'opacity 0.3s ease-in-out'
});
next();
}).delay(300).queue(function(next) {
// Actually make the element invisible to avoid accidental clicking on transparent element.
$(this).addClass('invisible');
next();
});
['easy', 'better', 'hard'].forEach(function(reaction, index) {
var delay = 50 + 150 * index; // Easy: 50, better: 200, hard: 350.
$get(moduleId, '.reaction img[data-reactionname="' + reaction + '"]')
.delay(delay).animate(reactionImageSizeForRatio(1), 300)
.css({'pointer-events': 'auto'});
$get(moduleId, '.reactionnb[data-reactionname="' + reaction + '"]')
.delay(delay + 300)
.queue(function(next) {
$(this).removeClass('invisible');
next();
});
});
};
// Animation sequence to hide vote interface and show reaction preview.
var hideReactions = function(moduleId) {
['hard', 'better', 'easy'].forEach(function(reaction, index) {
var delay = 50 + 250 * index; // Hard: 50, better: 300, easy: 550.
$get(moduleId, '.reaction img[data-reactionname="' + reaction + '"]')
.css({'pointer-events': 'none'})
.delay(delay).animate(reactionImageSizeForRatio(0), 500);
$get(moduleId, '.reactionnb[data-reactionname="' + reaction + '"]')
.delay(delay)
.queue(function(next) {
$(this).addClass('invisible');
next();
});
});
// Show the reaction group image with nice animation.
$get(moduleId, '.group_img')
.delay(500)
.show(0)
.animate(groupImageSizeForRatio(1), 300)
.queue(function(next) {
$get(moduleId, '.reactions').addClass('invisible');
next();
})
.css({'pointer-events': 'auto'});
$get(moduleId, '.group_nb').delay(600).show(0);
$('#module-' + moduleId + ' button[data-action="toggle-manual-completion"],' +
'#module-' + moduleId + ' .activity-info .automatic-completion-conditions > span.badge:first-of-type,' +
'#module-' + moduleId + ' .activity-information [data-region="completionrequirements"]')
.delay(600).queue(function(next) {
$(this).removeClass('invisible');
// Use opacity transition for a smooth showing back.
$(this).css({
opacity: 1,
transition: 'opacity 0.3s ease-in-out'
});
next();
});
};
// Setup some timeouts and locks to trigger animations.
var reactionsVisible = false;
var groupTimeout = null;
var reactionsTimeout = null;
var triggerHideReactions = function() {
reactionsTimeout = null;
reactionsVisible = false;
hideReactions(moduleId);
};
var triggerShowReactions = function() {
groupTimeout = null;
reactionsVisible = true;
showReactions(moduleId);
clearTimeout(reactionsTimeout);
reactionsTimeout = setTimeout(triggerHideReactions, 2000); // Hide reactions after 2 seconds if mouse is already out.
};
// Reactions preview interactions.
$get(moduleId, '.group_img')
.mouseover(function() {
$(this).stop().animate(groupImageSizeForRatio(1.15), 100); // Widen image a little on hover.
groupTimeout = setTimeout(triggerShowReactions, 300); // Show vote interface after 0.3s hover.
})
.mouseout(function() {
if (!reactionsVisible) {
// Cancel mouseover actions.
clearTimeout(groupTimeout);
$(this).stop().animate(groupImageSizeForRatio(1), 100);
}
})
.click(triggerShowReactions); // Show vote interface instantly on click.
// Reactions images interactions.
$get(moduleId, '.reaction img')
.mouseover(function() {
$(this).stop().animate(reactionImageSizeForRatio(2), 100); // Widen image a little on hover.
})
.mouseout(function() {
$(this).stop().animate(reactionImageSizeForRatio(1), 100);
});
// Vote interface zone interactions
$get(moduleId, '.reactions')
.mouseout(function() {
clearTimeout(reactionsTimeout);
reactionsTimeout = setTimeout(triggerHideReactions, 1000); // Hide vote interface after 1s out of it.
})
.mouseover(function() {
clearTimeout(reactionsTimeout);
});
}
return {
init: function(courseId) {
// Wait that the DOM is fully loaded.
$(function() {
var blockData = $('.block_point_view[data-blockdata]').data('blockdata');
var cmid = null; // If this page is a course module view, retrieve the module ID.
document.body.classList.forEach(function(bodyClass) {
var matches = bodyClass.match(/cmid-(\d+)/);
cmid = matches ? matches[1] : cmid;
});
callOnModulesListLoad(function() {
setUpDifficultyTracks(blockData.difficultylevels, blockData.trackcolors, cmid);
setUpReactions(courseId, blockData.moduleswithreactions, blockData.reactionstemplate, blockData.pix, cmid);
});
});
}
};
});