Proyectos de Subversion Moodle

Rev

Rev 4 | Ir a la última revisión | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
4 ariadna 1
// This file is part of Moodle - https://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
 
16
/**
17
 * Module to display and manage reactions and difficulty tracks on course page.
18
 * @copyright  2020 Quentin Fombaron, 2021 Astor Bizard
19
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
20
 */
21
define(['jquery', 'core/ajax', 'core/notification'], function($, ajax, notification) {
22
 
23
    /**
24
     * Call a function each time a course section is loaded.
25
     * @param {Function} call Function to call.
26
     */
27
    function callOnModulesListLoad(call) {
28
        call();
29
 
30
        // The following listener is needed for the Tiles course format, where sections are loaded on demand.
31
        $(document).ajaxComplete(function(event, xhr, settings) {
32
            if (typeof (settings.data) !== 'string') {
33
                return;
34
            }
35
            try {
36
                var data = JSON.parse(settings.data);
37
                if (data.length == 0 || typeof (data[0].methodname) === 'undefined') {
38
                    return;
39
                }
40
                if (data[0].methodname == 'format_tiles_get_single_section_page_html' // Tile load.
41
                    || data[0].methodname == 'format_tiles_log_tile_click') { // Tile load, cached.
42
                    call();
43
                }
44
            } catch (e) {
45
                // Something went wrong, it may not even be JSON. It is fine, this means it was not the call we were expecting.
46
                return;
47
            }
48
        });
49
    }
50
 
51
    /**
52
     * Set up difficulty tracks on course modules.
53
     * @param {Array} difficultyLevels Array of difficulty tracks, one entry for each course module.
54
     * @param {Array} trackColors Tracks colors, from block plugin configuration.
55
     */
5 ariadna 56
    function setUpDifficultyTracks(difficultyLevels, trackColors) {
4 ariadna 57
        difficultyLevels.forEach(function(module) {
58
            var difficultyLevel = parseInt(module.difficultyLevel);
59
            var title = '';
60
            if (difficultyLevel > 0) {
61
                var track = ['greentrack', 'bluetrack', 'redtrack', 'blacktrack'][difficultyLevel - 1];
62
                title = M.util.get_string(track, 'block_point_view');
63
            }
64
            var $track = $('<div>', {
65
                'class': 'block_point_view track',
66
                'title': title,
67
                'style': 'background-color: ' + trackColors[difficultyLevel] + ';'
68
            });
69
            // Decide where to put the track.
5 ariadna 70
            var $container = $('#module-' + module.id + ' .mod-indent-outer');
4 ariadna 71
 
72
            // Add the track.
73
            if ($container.find('.block_point_view.track').length === 0) {
74
                $container.prepend($track);
75
            }
76
            // If there is indentation, move the track after it.
77
            $container.find('.mod-indent').after($track);
78
 
79
        });
80
    }
81
 
5 ariadna 82
 
4 ariadna 83
    /**
84
     * Get a jQuery object in reaction zone for given module ID.
85
     * @param {Number} moduleId Module ID.
86
     * @param {String} selector (optional) Sub-selector.
87
     * @return {jQuery} If selector was provided, the corresponding jQuery object within the reaction zone.
88
     *  If not, the reaction zone jQuery object.
89
     */
90
    function $get(moduleId, selector) {
91
        var $element = $('#module-' + moduleId + ' .block_point_view.reactions-container');
92
        if (typeof (selector) === 'undefined') {
93
            return $element;
94
        } else {
95
            return $element.find(selector);
96
        }
97
    }
98
 
99
    // Enumeration of the possible reactions.
100
    var Reactions = {
101
            none: 0,
102
            easy: 1,
103
            better: 2,
104
            hard: 3
105
    };
106
 
107
    // Array of Reaction of the user for the activity.
108
    var reactionVotedArray = {};
109
 
110
    /**
111
     * Set up difficulty tracks on course modules.
112
     * @param {Number} courseId Course ID.
113
     * @param {Array} modulesWithReactions Array of reactions state, one entry for each course module with reactions enabled.
114
     * @param {String} reactionsHtml HTML fragment for reactions.
115
     * @param {Array} pixSrc Array of pictures sources for group images.
116
     */
5 ariadna 117
    function setUpReactions(courseId, modulesWithReactions, reactionsHtml, pixSrc) {
4 ariadna 118
        // For each selected module, create a reaction zone.
119
        modulesWithReactions.forEach(function(module) {
120
            var moduleId = parseInt(module.cmid);
121
            var uservote = parseInt(module.uservote);
122
 
123
            // Initialise reactionVotedArray.
124
            reactionVotedArray[moduleId] = uservote;
125
 
126
            if ($('#module-' + moduleId).length === 1 && $get(moduleId).length === 0) {
127
 
128
                // Add the reaction zone to the module.
5 ariadna 129
                $('#module-' + moduleId).prepend(reactionsHtml);
4 ariadna 130
 
131
                // Setup reaction change.
132
                var reactionsLock = false;
133
                $get(moduleId, '.reaction img')
134
                .click(function() {
135
                    // Use a mutex to avoid query / display inconsistencies.
136
                    // This is not a perfect mutex, but is actually enough for our needs.
137
                    if (reactionsLock === false) {
138
                        reactionsLock = true;
139
                        reactionChange(courseId, moduleId, $(this).data('reactionname'))
140
                        .always(function() {
141
                            reactionsLock = false;
142
                            updateGroupImgAndNb(moduleId, pixSrc);
143
                        });
144
                    }
145
                });
146
 
147
                // Initialize reactions state.
148
                $get(moduleId, '.reactionnb').each(function() {
149
                    var reactionName = $(this).data('reactionname');
150
                    var reactionNb = parseInt(module['total' + reactionName]);
151
                    updateReactionNb(moduleId, reactionName, reactionNb, uservote === Reactions[reactionName]);
152
                });
153
                updateGroupImgAndNb(moduleId, pixSrc);
154
 
155
                // Setup animations.
156
                setupReactionsAnimation(moduleId, pixSrc);
157
            }
158
        });
159
    }
160
 
161
    /**
162
     * Manage a reaction change (user added, removed or updated their vote).
163
     * @param {Number} courseId Course ID.
164
     * @param {Number} moduleId Module ID.
165
     * @param {String} reactionName The reaction being clicked.
166
     * @returns {Promise} A promise, result of the change operations (ajax call and UI update).
167
     */
168
    function reactionChange(courseId, moduleId, reactionName) {
169
 
170
        var reactionSelect = Reactions[reactionName];
171
        var previousReaction = reactionVotedArray[moduleId];
172
 
173
        // If the reaction being clicked is the current one, it is a vote remove.
174
        var newVote = (reactionSelect === previousReaction) ? Reactions.none : reactionSelect;
175
 
176
        return ajax.call([
177
            {
178
                methodname: 'block_point_view_update_db',
179
                args: {
180
                    func: 'update',
181
                    courseid: courseId,
182
                    cmid: moduleId,
183
                    vote: newVote
184
                }
185
            }
186
        ])[0]
187
        .done(function() {
188
            reactionVotedArray[moduleId] = newVote; // Set current reaction.
189
            if (previousReaction !== Reactions.none) {
190
                // User canceled their vote (or updated to another one).
191
                var previousReactionName = ['', 'easy', 'better', 'hard'][previousReaction];
192
                updateReactionNb(moduleId, previousReactionName, -1, false);
193
            }
194
            if (newVote !== Reactions.none) {
195
                // User added or updated their vote.
196
                updateReactionNb(moduleId, reactionName, +1, true); // Add new vote.
197
            }
198
        })
199
        .fail(notification.exception);
200
    }
201
 
202
    /**
203
     * Update the reactions group image and total number according to current votes.
204
     * @param {Number} moduleId Module ID.
205
     * @param {Array} pix Array of pictures sources for group images.
206
     */
207
    function updateGroupImgAndNb(moduleId, pix) {
208
        // Build group image name.
209
        var groupImg = 'group_';
210
        var totalNb = 0;
211
        $get(moduleId, '.reactionnb').each(function() {
212
            var reactionNb = parseInt($(this).text());
213
            if (reactionNb > 0) {
214
                groupImg += $(this).data('reactionname').toUpperCase().charAt(0); // Add E, B or H.
215
            }
216
            totalNb += reactionNb;
217
        });
218
        // Modify the image source of the reaction group.
219
        $get(moduleId, '.group_img').attr('src', pix[groupImg]);
220
 
221
        // Update the total number of votes.
222
        var $groupNbWrapper = $get(moduleId, '.group_nb');
223
        var $groupNb = $groupNbWrapper.find('span');
224
 
225
        $groupNb
226
        .text(totalNb)
227
        .attr('title', M.util.get_string('totalreactions', 'block_point_view', totalNb));
228
 
229
        $groupNbWrapper
230
        .toggleClass('invisible', totalNb === 0)
231
        .toggleClass('voted', reactionVotedArray[moduleId] !== Reactions.none);
232
 
233
        // Adjust the size to fit within a fixed space (useful for the green dot).
234
        var digits = Math.min(('' + totalNb).length, 5);
235
        $groupNb.css({
236
            'right': Math.max(0.25 * (digits - 2), 0) + 'em',
237
            'transform': 'scaleX(' + (1.0 + 0.03 * digits * digits - 0.35 * digits + 0.34) + ')'
238
        });
239
    }
240
 
241
    /**
242
     * Update a reaction number of votes.
243
     * @param {Number} moduleId Module ID.
244
     * @param {String} reactionName The reaction to update the number of.
245
     * @param {Number} diff Difference to apply (e.g. +1 for adding a vote, -1 for removing a vote).
246
     * @param {Boolean} isSelected Whether the reaction we are updating is the one now selected by user.
247
     */
248
    function updateReactionNb(moduleId, reactionName, diff, isSelected) {
249
        var $reactionNb = $get(moduleId, '.reactionnb[data-reactionname="' + reactionName + '"]');
250
        var nbReaction = parseInt($reactionNb.text()) + diff;
251
        $reactionNb
252
        .text(nbReaction)
253
        .toggleClass('nbselected', isSelected);
254
 
255
        $get(moduleId, '.reaction img[data-reactionname="' + reactionName + '"]')
256
        .toggleClass('novote', nbReaction === 0);
257
    }
258
 
259
    /**
260
     * Set up animations to swap between reactions preview and vote interface.
261
     * @param {Number} moduleId Module ID.
262
     */
263
    function setupReactionsAnimation(moduleId) {
264
 
265
        // Helpers to resize images for animations.
266
        var reactionImageSizeForRatio = function(ratio) {
267
            return {
268
                top: 15 - (10 * ratio),
269
                left: 10 - (10 * ratio),
270
                height: 20 * ratio
271
            };
272
        };
273
        var groupImageSizeForRatio = function(ratio) {
274
            return {
275
                left: -10 + (10 * ratio),
276
                height: 20 * ratio
277
            };
278
        };
279
 
280
        // Animation sequence to hide reactions preview and show vote interface.
281
        var showReactions = function(moduleId) {
282
            $get(moduleId, '.reactions').removeClass('invisible');
283
 
284
            $get(moduleId, '.group_img')
285
            .css({'pointer-events': 'none'})
286
            .animate(groupImageSizeForRatio(0), 300)
287
            .hide(0);
288
 
289
            $get(moduleId, '.group_nb').delay(200).hide(300);
290
 
5 ariadna 291
            $('#module-' + moduleId + ' .actions').delay(200).hide(300);
4 ariadna 292
 
293
            ['easy', 'better', 'hard'].forEach(function(reaction, index) {
294
                var delay = 50 + 150 * index; // Easy: 50, better: 200, hard: 350.
295
 
296
                $get(moduleId, '.reaction img[data-reactionname="' + reaction + '"]')
297
                .delay(delay).animate(reactionImageSizeForRatio(1), 300)
298
                .css({'pointer-events': 'auto'});
299
 
300
                $get(moduleId, '.reactionnb[data-reactionname="' + reaction + '"]')
301
                .delay(delay + 300)
302
                .queue(function(next) {
303
                    $(this).removeClass('invisible');
304
                    next();
305
                });
306
            });
307
        };
308
 
309
        // Animation sequence to hide vote interface and show reaction preview.
310
        var hideReactions = function(moduleId) {
311
            ['hard', 'better', 'easy'].forEach(function(reaction, index) {
312
                var delay = 50 + 250 * index; // Hard: 50, better: 300, easy: 550.
313
                $get(moduleId, '.reaction img[data-reactionname="' + reaction + '"]')
314
                .css({'pointer-events': 'none'})
315
                .delay(delay).animate(reactionImageSizeForRatio(0), 500);
316
 
317
                $get(moduleId, '.reactionnb[data-reactionname="' + reaction + '"]')
318
                .delay(delay)
319
                .queue(function(next) {
320
                    $(this).addClass('invisible');
321
                    next();
322
                });
323
            });
324
 
325
            // Show the reaction group image with nice animation.
326
            $get(moduleId, '.group_img')
327
            .delay(500)
328
            .show(0)
329
            .animate(groupImageSizeForRatio(1), 300)
330
            .queue(function(next) {
331
                $get(moduleId, '.reactions').addClass('invisible');
332
                next();
333
            })
334
            .css({'pointer-events': 'auto'});
335
 
336
            $get(moduleId, '.group_nb').delay(600).show(0);
337
 
5 ariadna 338
            $('#module-' + moduleId + ' .actions').delay(600).show(300);
4 ariadna 339
        };
340
 
341
        // Setup some timeouts and locks to trigger animations.
342
        var reactionsVisible = false;
343
        var groupTimeout = null;
344
        var reactionsTimeout = null;
345
 
346
        var triggerHideReactions = function() {
347
            reactionsTimeout = null;
348
            reactionsVisible = false;
349
            hideReactions(moduleId);
350
        };
351
 
352
        var triggerShowReactions = function() {
353
            groupTimeout = null;
354
            reactionsVisible = true;
355
            showReactions(moduleId);
356
            clearTimeout(reactionsTimeout);
357
            reactionsTimeout = setTimeout(triggerHideReactions, 2000); // Hide reactions after 2 seconds if mouse is already out.
358
        };
359
 
360
        // Reactions preview interactions.
361
        $get(moduleId, '.group_img')
362
        .mouseover(function() {
363
            $(this).stop().animate(groupImageSizeForRatio(1.15), 100); // Widen image a little on hover.
364
            groupTimeout = setTimeout(triggerShowReactions, 300); // Show vote interface after 0.3s hover.
365
        })
366
        .mouseout(function() {
367
            if (!reactionsVisible) {
368
                // Cancel mouseover actions.
369
                clearTimeout(groupTimeout);
370
                $(this).stop().animate(groupImageSizeForRatio(1), 100);
371
            }
372
        })
373
        .click(triggerShowReactions); // Show vote interface instantly on click.
374
 
375
        // Reactions images interactions.
376
        $get(moduleId, '.reaction img')
377
        .mouseover(function() {
378
            $(this).stop().animate(reactionImageSizeForRatio(2), 100); // Widen image a little on hover.
379
        })
380
        .mouseout(function() {
381
            $(this).stop().animate(reactionImageSizeForRatio(1), 100);
382
        });
383
 
384
        // Vote interface zone interactions
385
        $get(moduleId, '.reactions')
386
        .mouseout(function() {
387
            clearTimeout(reactionsTimeout);
388
            reactionsTimeout = setTimeout(triggerHideReactions, 1000); // Hide vote interface after 1s out of it.
389
        })
390
        .mouseover(function() {
391
            clearTimeout(reactionsTimeout);
392
        });
393
    }
394
 
395
    return {
396
        init: function(courseId) {
397
 
398
            // Wait that the DOM is fully loaded.
399
            $(function() {
400
 
401
                var blockData = $('.block_point_view[data-blockdata]').data('blockdata');
402
 
403
                callOnModulesListLoad(function() {
5 ariadna 404
                    setUpDifficultyTracks(blockData.difficultylevels, blockData.trackcolors);
405
                    setUpReactions(courseId, blockData.moduleswithreactions, blockData.reactionstemplate, blockData.pix);
4 ariadna 406
                });
407
 
408
            });
409
        }
410
    };
411
});