Proyectos de Subversion Moodle

Rev

Rev 5 | | 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.
6 ariadna 55
     * @param {Number|null} cmid The course module ID, if this page is a module view.
4 ariadna 56
     */
6 ariadna 57
    function setUpDifficultyTracks(difficultyLevels, trackColors, cmid) {
4 ariadna 58
        difficultyLevels.forEach(function(module) {
59
            var difficultyLevel = parseInt(module.difficultyLevel);
60
            var title = '';
61
            if (difficultyLevel > 0) {
62
                var track = ['greentrack', 'bluetrack', 'redtrack', 'blacktrack'][difficultyLevel - 1];
63
                title = M.util.get_string(track, 'block_point_view');
64
            }
65
            var $track = $('<div>', {
66
                'class': 'block_point_view track',
67
                'title': title,
68
                'style': 'background-color: ' + trackColors[difficultyLevel] + ';'
69
            });
70
            // Decide where to put the track.
6 ariadna 71
            var $container = $('#module-' + module.id + ' .activitytitle');
72
            if ($container.closest('.activity-grid').length) {
73
                // Moodle 4.3+.
74
                $container = $container.closest('.activity-grid');
75
            }
76
            if ($container.length === 0) {
77
                // This seems to be a label.
78
                $container = $('#module-' + module.id + ' .activity-item .description,' +
79
                                '#module-' + module.id + ' .activity-item .activity-altcontent').first();
80
            }
4 ariadna 81
 
82
            // Add the track.
83
            if ($container.find('.block_point_view.track').length === 0) {
84
                $container.prepend($track);
85
            }
86
            // If there is indentation, move the track after it.
87
            $container.find('.mod-indent').after($track);
88
 
6 ariadna 89
            if (cmid === module.id) {
90
                // This is a module page, add the track to the title.
91
                $('.page-context-header').prepend($track);
92
            }
4 ariadna 93
        });
94
    }
95
 
96
    /**
97
     * Get a jQuery object in reaction zone for given module ID.
98
     * @param {Number} moduleId Module ID.
99
     * @param {String} selector (optional) Sub-selector.
100
     * @return {jQuery} If selector was provided, the corresponding jQuery object within the reaction zone.
101
     *  If not, the reaction zone jQuery object.
102
     */
103
    function $get(moduleId, selector) {
104
        var $element = $('#module-' + moduleId + ' .block_point_view.reactions-container');
105
        if (typeof (selector) === 'undefined') {
106
            return $element;
107
        } else {
108
            return $element.find(selector);
109
        }
110
    }
111
 
112
    // Enumeration of the possible reactions.
113
    var Reactions = {
114
            none: 0,
115
            easy: 1,
116
            better: 2,
117
            hard: 3
118
    };
119
 
120
    // Array of Reaction of the user for the activity.
121
    var reactionVotedArray = {};
122
 
123
    /**
124
     * Set up difficulty tracks on course modules.
125
     * @param {Number} courseId Course ID.
126
     * @param {Array} modulesWithReactions Array of reactions state, one entry for each course module with reactions enabled.
127
     * @param {String} reactionsHtml HTML fragment for reactions.
128
     * @param {Array} pixSrc Array of pictures sources for group images.
6 ariadna 129
     * @param {Number|null} cmid The course module ID, if this page is a module view.
4 ariadna 130
     */
6 ariadna 131
    function setUpReactions(courseId, modulesWithReactions, reactionsHtml, pixSrc, cmid) {
4 ariadna 132
        // For each selected module, create a reaction zone.
133
        modulesWithReactions.forEach(function(module) {
134
            var moduleId = parseInt(module.cmid);
135
            var uservote = parseInt(module.uservote);
136
 
137
            // Initialise reactionVotedArray.
138
            reactionVotedArray[moduleId] = uservote;
139
 
6 ariadna 140
            if (module.cmid === cmid) {
141
                // Simulate an activity row on course page (so we treat it the same for what's next).
142
                $('<div id="module-' + moduleId + '" class="activity-wrapper mr-5" style="width: 165px;">')
143
                .insertAfter($('.header-actions-container'))
144
                .prepend('<div class="activity-instance">');
145
            }
146
 
4 ariadna 147
            if ($('#module-' + moduleId).length === 1 && $get(moduleId).length === 0) {
148
 
149
                // Add the reaction zone to the module.
6 ariadna 150
                var $module = $('#module-' + moduleId);
151
                if ($module.is('.modtype_label')) {
152
                    // Label.
153
                    $module.find('.description, .activity-grid').first().before(reactionsHtml);
154
                } else if ($module.find('.tiles-activity-container').length) {
155
                    // Tiles format.
156
                    $module.find('.tiles-activity-container').after(reactionsHtml);
157
                } else {
158
                    $module.find('.activity-instance').after(reactionsHtml);
159
                }
4 ariadna 160
 
161
                // Setup reaction change.
162
                var reactionsLock = false;
163
                $get(moduleId, '.reaction img')
164
                .click(function() {
165
                    // Use a mutex to avoid query / display inconsistencies.
166
                    // This is not a perfect mutex, but is actually enough for our needs.
167
                    if (reactionsLock === false) {
168
                        reactionsLock = true;
169
                        reactionChange(courseId, moduleId, $(this).data('reactionname'))
170
                        .always(function() {
171
                            reactionsLock = false;
172
                            updateGroupImgAndNb(moduleId, pixSrc);
173
                        });
174
                    }
175
                });
176
 
177
                // Initialize reactions state.
178
                $get(moduleId, '.reactionnb').each(function() {
179
                    var reactionName = $(this).data('reactionname');
180
                    var reactionNb = parseInt(module['total' + reactionName]);
181
                    updateReactionNb(moduleId, reactionName, reactionNb, uservote === Reactions[reactionName]);
182
                });
183
                updateGroupImgAndNb(moduleId, pixSrc);
184
 
185
                // Setup animations.
186
                setupReactionsAnimation(moduleId, pixSrc);
187
            }
188
        });
189
    }
190
 
191
    /**
192
     * Manage a reaction change (user added, removed or updated their vote).
193
     * @param {Number} courseId Course ID.
194
     * @param {Number} moduleId Module ID.
195
     * @param {String} reactionName The reaction being clicked.
196
     * @returns {Promise} A promise, result of the change operations (ajax call and UI update).
197
     */
198
    function reactionChange(courseId, moduleId, reactionName) {
199
 
200
        var reactionSelect = Reactions[reactionName];
201
        var previousReaction = reactionVotedArray[moduleId];
202
 
203
        // If the reaction being clicked is the current one, it is a vote remove.
204
        var newVote = (reactionSelect === previousReaction) ? Reactions.none : reactionSelect;
205
 
206
        return ajax.call([
207
            {
208
                methodname: 'block_point_view_update_db',
209
                args: {
210
                    func: 'update',
211
                    courseid: courseId,
212
                    cmid: moduleId,
213
                    vote: newVote
214
                }
215
            }
216
        ])[0]
217
        .done(function() {
218
            reactionVotedArray[moduleId] = newVote; // Set current reaction.
219
            if (previousReaction !== Reactions.none) {
220
                // User canceled their vote (or updated to another one).
221
                var previousReactionName = ['', 'easy', 'better', 'hard'][previousReaction];
222
                updateReactionNb(moduleId, previousReactionName, -1, false);
223
            }
224
            if (newVote !== Reactions.none) {
225
                // User added or updated their vote.
226
                updateReactionNb(moduleId, reactionName, +1, true); // Add new vote.
227
            }
228
        })
229
        .fail(notification.exception);
230
    }
231
 
232
    /**
233
     * Update the reactions group image and total number according to current votes.
234
     * @param {Number} moduleId Module ID.
235
     * @param {Array} pix Array of pictures sources for group images.
236
     */
237
    function updateGroupImgAndNb(moduleId, pix) {
238
        // Build group image name.
239
        var groupImg = 'group_';
240
        var totalNb = 0;
241
        $get(moduleId, '.reactionnb').each(function() {
242
            var reactionNb = parseInt($(this).text());
243
            if (reactionNb > 0) {
244
                groupImg += $(this).data('reactionname').toUpperCase().charAt(0); // Add E, B or H.
245
            }
246
            totalNb += reactionNb;
247
        });
248
        // Modify the image source of the reaction group.
249
        $get(moduleId, '.group_img').attr('src', pix[groupImg]);
250
 
251
        // Update the total number of votes.
252
        var $groupNbWrapper = $get(moduleId, '.group_nb');
253
        var $groupNb = $groupNbWrapper.find('span');
254
 
255
        $groupNb
256
        .text(totalNb)
257
        .attr('title', M.util.get_string('totalreactions', 'block_point_view', totalNb));
258
 
259
        $groupNbWrapper
260
        .toggleClass('invisible', totalNb === 0)
261
        .toggleClass('voted', reactionVotedArray[moduleId] !== Reactions.none);
262
 
263
        // Adjust the size to fit within a fixed space (useful for the green dot).
264
        var digits = Math.min(('' + totalNb).length, 5);
265
        $groupNb.css({
266
            'right': Math.max(0.25 * (digits - 2), 0) + 'em',
267
            'transform': 'scaleX(' + (1.0 + 0.03 * digits * digits - 0.35 * digits + 0.34) + ')'
268
        });
269
    }
270
 
271
    /**
272
     * Update a reaction number of votes.
273
     * @param {Number} moduleId Module ID.
274
     * @param {String} reactionName The reaction to update the number of.
275
     * @param {Number} diff Difference to apply (e.g. +1 for adding a vote, -1 for removing a vote).
276
     * @param {Boolean} isSelected Whether the reaction we are updating is the one now selected by user.
277
     */
278
    function updateReactionNb(moduleId, reactionName, diff, isSelected) {
279
        var $reactionNb = $get(moduleId, '.reactionnb[data-reactionname="' + reactionName + '"]');
280
        var nbReaction = parseInt($reactionNb.text()) + diff;
281
        $reactionNb
282
        .text(nbReaction)
283
        .toggleClass('nbselected', isSelected);
284
 
285
        $get(moduleId, '.reaction img[data-reactionname="' + reactionName + '"]')
286
        .toggleClass('novote', nbReaction === 0);
287
    }
288
 
289
    /**
290
     * Set up animations to swap between reactions preview and vote interface.
291
     * @param {Number} moduleId Module ID.
292
     */
293
    function setupReactionsAnimation(moduleId) {
294
 
295
        // Helpers to resize images for animations.
296
        var reactionImageSizeForRatio = function(ratio) {
297
            return {
298
                top: 15 - (10 * ratio),
299
                left: 10 - (10 * ratio),
300
                height: 20 * ratio
301
            };
302
        };
303
        var groupImageSizeForRatio = function(ratio) {
304
            return {
305
                left: -10 + (10 * ratio),
306
                height: 20 * ratio
307
            };
308
        };
309
 
310
        // Animation sequence to hide reactions preview and show vote interface.
311
        var showReactions = function(moduleId) {
312
            $get(moduleId, '.reactions').removeClass('invisible');
313
 
314
            $get(moduleId, '.group_img')
315
            .css({'pointer-events': 'none'})
316
            .animate(groupImageSizeForRatio(0), 300)
317
            .hide(0);
318
 
319
            $get(moduleId, '.group_nb').delay(200).hide(300);
320
 
6 ariadna 321
            $('#module-' + moduleId + ' button[data-action="toggle-manual-completion"],' +
322
              '#module-' + moduleId + ' .activity-info .automatic-completion-conditions > span.badge:first-of-type,' +
323
              '#module-' + moduleId + ' .activity-information [data-region="completionrequirements"]')
324
            .delay(200).queue(function(next) {
325
                // Use opacity transition for a smooth hiding.
326
                $(this).css({
327
                    opacity: 0,
328
                    transition: 'opacity 0.3s ease-in-out'
329
                });
330
                next();
331
            }).delay(300).queue(function(next) {
332
                // Actually make the element invisible to avoid accidental clicking on transparent element.
333
                $(this).addClass('invisible');
334
                next();
335
            });
4 ariadna 336
 
337
            ['easy', 'better', 'hard'].forEach(function(reaction, index) {
338
                var delay = 50 + 150 * index; // Easy: 50, better: 200, hard: 350.
339
 
340
                $get(moduleId, '.reaction img[data-reactionname="' + reaction + '"]')
341
                .delay(delay).animate(reactionImageSizeForRatio(1), 300)
342
                .css({'pointer-events': 'auto'});
343
 
344
                $get(moduleId, '.reactionnb[data-reactionname="' + reaction + '"]')
345
                .delay(delay + 300)
346
                .queue(function(next) {
347
                    $(this).removeClass('invisible');
348
                    next();
349
                });
350
            });
351
        };
352
 
353
        // Animation sequence to hide vote interface and show reaction preview.
354
        var hideReactions = function(moduleId) {
355
            ['hard', 'better', 'easy'].forEach(function(reaction, index) {
356
                var delay = 50 + 250 * index; // Hard: 50, better: 300, easy: 550.
357
                $get(moduleId, '.reaction img[data-reactionname="' + reaction + '"]')
358
                .css({'pointer-events': 'none'})
359
                .delay(delay).animate(reactionImageSizeForRatio(0), 500);
360
 
361
                $get(moduleId, '.reactionnb[data-reactionname="' + reaction + '"]')
362
                .delay(delay)
363
                .queue(function(next) {
364
                    $(this).addClass('invisible');
365
                    next();
366
                });
367
            });
368
 
369
            // Show the reaction group image with nice animation.
370
            $get(moduleId, '.group_img')
371
            .delay(500)
372
            .show(0)
373
            .animate(groupImageSizeForRatio(1), 300)
374
            .queue(function(next) {
375
                $get(moduleId, '.reactions').addClass('invisible');
376
                next();
377
            })
378
            .css({'pointer-events': 'auto'});
379
 
380
            $get(moduleId, '.group_nb').delay(600).show(0);
381
 
6 ariadna 382
            $('#module-' + moduleId + ' button[data-action="toggle-manual-completion"],' +
383
              '#module-' + moduleId + ' .activity-info .automatic-completion-conditions > span.badge:first-of-type,' +
384
              '#module-' + moduleId + ' .activity-information [data-region="completionrequirements"]')
385
            .delay(600).queue(function(next) {
386
                $(this).removeClass('invisible');
387
                // Use opacity transition for a smooth showing back.
388
                $(this).css({
389
                    opacity: 1,
390
                    transition: 'opacity 0.3s ease-in-out'
391
                });
392
                next();
393
            });
4 ariadna 394
        };
395
 
396
        // Setup some timeouts and locks to trigger animations.
397
        var reactionsVisible = false;
398
        var groupTimeout = null;
399
        var reactionsTimeout = null;
400
 
401
        var triggerHideReactions = function() {
402
            reactionsTimeout = null;
403
            reactionsVisible = false;
404
            hideReactions(moduleId);
405
        };
406
 
407
        var triggerShowReactions = function() {
408
            groupTimeout = null;
409
            reactionsVisible = true;
410
            showReactions(moduleId);
411
            clearTimeout(reactionsTimeout);
412
            reactionsTimeout = setTimeout(triggerHideReactions, 2000); // Hide reactions after 2 seconds if mouse is already out.
413
        };
414
 
415
        // Reactions preview interactions.
416
        $get(moduleId, '.group_img')
417
        .mouseover(function() {
418
            $(this).stop().animate(groupImageSizeForRatio(1.15), 100); // Widen image a little on hover.
419
            groupTimeout = setTimeout(triggerShowReactions, 300); // Show vote interface after 0.3s hover.
420
        })
421
        .mouseout(function() {
422
            if (!reactionsVisible) {
423
                // Cancel mouseover actions.
424
                clearTimeout(groupTimeout);
425
                $(this).stop().animate(groupImageSizeForRatio(1), 100);
426
            }
427
        })
428
        .click(triggerShowReactions); // Show vote interface instantly on click.
429
 
430
        // Reactions images interactions.
431
        $get(moduleId, '.reaction img')
432
        .mouseover(function() {
433
            $(this).stop().animate(reactionImageSizeForRatio(2), 100); // Widen image a little on hover.
434
        })
435
        .mouseout(function() {
436
            $(this).stop().animate(reactionImageSizeForRatio(1), 100);
437
        });
438
 
439
        // Vote interface zone interactions
440
        $get(moduleId, '.reactions')
441
        .mouseout(function() {
442
            clearTimeout(reactionsTimeout);
443
            reactionsTimeout = setTimeout(triggerHideReactions, 1000); // Hide vote interface after 1s out of it.
444
        })
445
        .mouseover(function() {
446
            clearTimeout(reactionsTimeout);
447
        });
448
    }
449
 
450
    return {
451
        init: function(courseId) {
452
 
453
            // Wait that the DOM is fully loaded.
454
            $(function() {
455
 
456
                var blockData = $('.block_point_view[data-blockdata]').data('blockdata');
457
 
6 ariadna 458
                var cmid = null; // If this page is a course module view, retrieve the module ID.
459
                document.body.classList.forEach(function(bodyClass) {
460
                    var matches = bodyClass.match(/cmid-(\d+)/);
461
                    cmid = matches ? matches[1] : cmid;
462
                });
463
 
4 ariadna 464
                callOnModulesListLoad(function() {
6 ariadna 465
                    setUpDifficultyTracks(blockData.difficultylevels, blockData.trackcolors, cmid);
466
                    setUpReactions(courseId, blockData.moduleswithreactions, blockData.reactionstemplate, blockData.pix, cmid);
4 ariadna 467
                });
468
 
469
            });
470
        }
471
    };
472
});