Merge branch 'MDL-68446' of https://github.com/timhunt/moodle
[moodle.git] / question / type / ddimageortext / amd / src / form.js
1 // This file is part of Moodle - http://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/>.
16 /*
17  * JavaScript to allow dragging options to slots (using mouse down or touch) or tab through slots using keyboard.
18  *
19  * @module     qtype_ddimageortext/form
20  * @package    qtype_ddimageortext
21  * @copyright  2018 The Open University
22  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23  */
24 define(['jquery', 'core/dragdrop'], function($, dragDrop) {
26     "use strict";
28     /**
29      * Singleton object to handle progressive enhancement of the
30      * drag-drop onto image question editing form.
31      * @type {Object}
32      */
33     var dragDropToImageForm = {
34         /**
35          * @var {Object} with properties width and height.
36          */
37         maxBgImageSize: null,
39         /**
40          * @var {Object} with properties width and height.
41          */
42         maxDragImageSize: null,
44         /**
45          * @var {object} for interacting with the file pickers.
46          */
47         fp: null, // Object containing functions associated with the file picker.
49         /**
50          * Initialise the form javascript features.
51          */
52         init: function() {
53             dragDropToImageForm.fp = dragDropToImageForm.filePickers();
55             $('#id_previewareaheader').append(
56                 '<div class="ddarea que ddimageortext">' +
57                 '  <div class="droparea">' +
58                 '    <img class="dropbackground" />' +
59                 '    <div class="dropzones"></div>' +
60                 '  </div>' +
61                 '  <div class="dragitems"></div>' +
62                 '</div>');
64             dragDropToImageForm.updateVisibilityOfFilePickers();
65             dragDropToImageForm.setOptionsForDragItemSelectors();
66             dragDropToImageForm.setupEventHandlers();
67             dragDropToImageForm.waitForFilePickerToInitialise();
68         },
70         /**
71          * Waits for the file-pickers to be sufficiently ready before initialising the preview.
72          */
73         waitForFilePickerToInitialise: function() {
74             if (dragDropToImageForm.fp.file('bgimage').href === null) {
75                 // It would be better to use an onload or onchange event rather than this timeout.
76                 // Unfortunately attempts to do this early are overwritten by filepicker during its loading.
77                 setTimeout(dragDropToImageForm.waitForFilePickerToInitialise, 1000);
78                 return;
79             }
80             M.util.js_pending('dragDropToImageForm');
82             // From now on, when a new file gets loaded into the filepicker, update the preview.
83             // This is not in the setupEventHandlers section as it needs to be delayed until
84             // after filepicker's javascript has finished.
85             $('form.mform').on('change', '.filepickerhidden', function() {
86                 M.util.js_pending('dragDropToImageForm');
87                 dragDropToImageForm.loadPreviewImage();
88             });
90             dragDropToImageForm.loadPreviewImage();
91         },
93         /**
94          * Loads the preview background image.
95          */
96         loadPreviewImage: function() {
97             $('fieldset#id_previewareaheader .dropbackground')
98                 .one('load', dragDropToImageForm.afterPreviewImageLoaded)
99                 .attr('src', dragDropToImageForm.fp.file('bgimage').href);
100         },
102         /**
103          * After the background image is loaded, continue setting up the preview.
104          */
105         afterPreviewImageLoaded: function() {
106             dragDropToImageForm.createDropZones();
107             M.util.js_complete('dragDropToImageForm');
108         },
110         /**
111          * Create, or recreate all the drop zones.
112          */
113         createDropZones: function() {
114             var dropZoneHolder = $('.dropzones');
115             dropZoneHolder.empty();
117             var bgimageurl = dragDropToImageForm.fp.file('bgimage').href;
118             if (bgimageurl === null) {
119                 return; // There is not currently a valid preview to update.
120             }
122             var numDrops = dragDropToImageForm.form.getFormValue('nodropzone', []);
123             for (var dropNo = 0; dropNo < numDrops; dropNo++) {
124                 var dragNo = dragDropToImageForm.form.getFormValue('drops', [dropNo, 'choice']);
125                 if (dragNo === '0') {
126                     continue;
127                 }
128                 dragNo = dragNo - 1;
129                 var group = dragDropToImageForm.form.getFormValue('drags', [dragNo, 'draggroup']),
130                     label = dragDropToImageForm.form.getFormValue('draglabel', [dragNo]);
131                 if ('image' === dragDropToImageForm.form.getFormValue('drags', [dragNo, 'dragitemtype'])) {
132                     var imgUrl = dragDropToImageForm.fp.file('dragitem[' + dragNo + ']').href;
133                     if (imgUrl === null) {
134                         continue;
135                     }
136                     // Althoug these are previews of drops, we also add the class name 'drag',
137                     dropZoneHolder.append('<img class="droppreview group' + group + ' drop' + dropNo +
138                             '" src="' + imgUrl + '" alt="' + label + '" data-drop-no="' + dropNo + '">');
140                 } else if (label !== '') {
141                     dropZoneHolder.append('<div class="droppreview group' + group + ' drop' + dropNo +
142                         '"  data-drop-no="' + dropNo + '">' + label + '</div>');
143                 }
144             }
146             dragDropToImageForm.waitForAllDropImagesToBeLoaded();
147         },
149         /**
150          * This polls until all the drop-zone images have loaded, and then calls updateDropZones().
151          */
152         waitForAllDropImagesToBeLoaded: function() {
153             var notYetLoadedImages = $('.dropzones img').not(function(i, imgNode) {
154                 return dragDropToImageForm.imageIsLoaded(imgNode);
155             });
157             if (notYetLoadedImages.length > 0) {
158                 setTimeout(function() {
159                     dragDropToImageForm.waitForAllDropImagesToBeLoaded();
160                 }, 100);
161                 return;
162             }
164             dragDropToImageForm.updateDropZones();
165         },
167         /**
168          * Check if an image has loaded without errors.
169          *
170          * @param {HTMLImageElement} imgElement an image.
171          * @returns {boolean} true if this image has loaded without errors.
172          */
173         imageIsLoaded: function(imgElement) {
174             return imgElement.complete && imgElement.naturalHeight !== 0;
175         },
177         /**
178          * Set the size and position of all the drop zones.
179          */
180         updateDropZones: function() {
181             var bgimageurl = dragDropToImageForm.fp.file('bgimage').href;
182             if (bgimageurl === null) {
183                 return; // There is not currently a valid preview to update.
184             }
186             var dropBackgroundPosition = $('fieldset#id_previewareaheader .dropbackground').offset(),
187                 numDrops = dragDropToImageForm.form.getFormValue('nodropzone', []);
189             // Move each drop to the right position and update the text.
190             for (var dropNo = 0; dropNo < numDrops; dropNo++) {
191                 var drop = $('.dropzones .drop' + dropNo);
192                 if (drop.length === 0) {
193                     continue;
194                 }
195                 var dragNo = dragDropToImageForm.form.getFormValue('drops', [dropNo, 'choice']) - 1;
197                 drop.offset({
198                     left: dropBackgroundPosition.left +
199                             parseInt(dragDropToImageForm.form.getFormValue('drops', [dropNo, 'xleft'])),
200                     top: dropBackgroundPosition.top +
201                             parseInt(dragDropToImageForm.form.getFormValue('drops', [dropNo, 'ytop']))
202                 });
204                 var label = dragDropToImageForm.form.getFormValue('draglabel', [dragNo]);
205                 if (drop.is('img')) {
206                     drop.attr('alt', label);
207                 } else {
208                     drop.html(label);
209                 }
210             }
212             // Resize them to the same size.
213             $('.dropzones .droppreview').css('padding', '0');
214             var numGroups = $('select.draggroup').first().find('option').length;
215             for (var group = 1; group <= numGroups; group++) {
216                 dragDropToImageForm.resizeAllDragsAndDropsInGroup(group);
217             }
218         },
220         /**
221          * In a given group, set all the drags and drops to be the same size.
222          *
223          * @param {int} group the group number.
224          */
225         resizeAllDragsAndDropsInGroup: function(group) {
226             var drops = $('.dropzones .droppreview.group' + group),
227                 maxWidth = 0,
228                 maxHeight = 0;
230             // Find the maximum size of any drag in this groups.
231             drops.each(function(i, drop) {
232                 maxWidth = Math.max(maxWidth, Math.ceil(drop.offsetWidth));
233                 maxHeight = Math.max(maxHeight, Math.ceil(drop.offsetHeight));
234             });
236             // The size we will want to set is a bit bigger than this.
237             maxWidth += 10;
238             maxHeight += 10;
240             // Set each drag home to that size.
241             drops.each(function(i, drop) {
242                 var left = Math.round((maxWidth - drop.offsetWidth) / 2),
243                     top = Math.floor((maxHeight - drop.offsetHeight) / 2);
244                 // Set top and left padding so the item is centred.
245                 $(drop).css({
246                     'padding-left': left + 'px',
247                     'padding-right': (maxWidth - drop.offsetWidth - left) + 'px',
248                     'padding-top': top + 'px',
249                     'padding-bottom': (maxHeight - drop.offsetHeight - top) + 'px'
250                 });
251             });
252         },
254         /**
255          * Events linked to form actions.
256          */
257         setupEventHandlers: function() {
258             // Changes to settings in the draggable items section.
259             $('fieldset#id_draggableitemheader')
260                 .on('change input', 'input, select', function(e) {
261                     var input = $(e.target).closest('select, input');
262                     if (input.hasClass('dragitemtype')) {
263                         dragDropToImageForm.updateVisibilityOfFilePickers();
264                     }
266                     dragDropToImageForm.setOptionsForDragItemSelectors();
268                     if (input.is('.dragitemtype, .draggroup')) {
269                         dragDropToImageForm.createDropZones();
270                     } else if (input.is('.draglabel')) {
271                         dragDropToImageForm.updateDropZones();
272                     }
273                 });
275             // Changes to Drop zones section: left, top and drag item.
276             $('fieldset#id_dropzoneheader').on('change input', 'input, select', function(e) {
277                 var input = $(e.target).closest('select, input');
278                 if (input.is('select')) {
279                     dragDropToImageForm.createDropZones();
280                 } else {
281                     dragDropToImageForm.updateDropZones();
282                 }
283             });
285             // Moving drop zones in the preview.
286             $('fieldset#id_previewareaheader').on('mousedown touchstart', '.droppreview', function(e) {
287                 dragDropToImageForm.dragStart(e);
288             });
290             $(window).on('resize', function() {
291                 dragDropToImageForm.updateDropZones();
292             });
293         },
295         /**
296          * Update all the drag item filepickers, so they are only shown for
297          */
298         updateVisibilityOfFilePickers: function() {
299             var numDrags = dragDropToImageForm.form.getFormValue('noitems', []);
300             for (var dragNo = 0; dragNo < numDrags; dragNo++) {
301                 var picker = $('input#id_dragitem_' + dragNo).closest('.fitem_ffilepicker');
302                 if ('image' === dragDropToImageForm.form.getFormValue('drags', [dragNo, 'dragitemtype'])) {
303                     picker.show();
304                 } else {
305                     picker.hide();
306                 }
307             }
308         },
311         setOptionsForDragItemSelectors: function() {
312             var dragItemOptions = {'0': ''},
313                 numDrags = dragDropToImageForm.form.getFormValue('noitems', []),
314                 numDrops = dragDropToImageForm.form.getFormValue('nodropzone', []);
316             // Work out the list of options.
317             for (var dragNo = 0; dragNo < numDrags; dragNo++) {
318                 var label = dragDropToImageForm.form.getFormValue('draglabel', [dragNo]);
319                 var file = dragDropToImageForm.fp.file(dragDropToImageForm.form.toNameWithIndex('dragitem', [dragNo]));
320                 if ('image' === dragDropToImageForm.form.getFormValue('drags', [dragNo, 'dragitemtype']) && file.name !== null) {
321                     dragItemOptions[dragNo + 1] = (dragNo + 1) + '. ' + label + ' (' + file.name + ')';
322                 } else if (label !== '') {
323                     dragItemOptions[dragNo + 1] = (dragNo + 1) + '. ' + label;
324                 }
325             }
327             // Initialise each select.
328             for (var dropNo = 0; dropNo < numDrops; dropNo++) {
329                 var selector = $('#id_drops_' + dropNo + '_choice');
331                 var selectedvalue = selector.val();
332                 selector.find('option').remove();
333                 for (var value in dragItemOptions) {
334                     if (!dragItemOptions.hasOwnProperty(value)) {
335                         continue;
336                     }
337                     selector.append('<option value="' + value + '">' + dragItemOptions[value] + '</option>');
338                     var optionnode = selector.find('option[value="' + value + '"]');
339                     if (parseInt(value) === parseInt(selectedvalue)) {
340                         optionnode.attr('selected', true);
341                     } else if (dragDropToImageForm.isItemUsed(parseInt(value))) {
342                         optionnode.attr('disabled', true);
343                     }
344                 }
345             }
346         },
348         /**
349          * Checks if the specified drag option is already used somewhere.
350          *
351          * @param {Number} value of the drag item to check
352          * @return {Boolean} true if item is allocated to dropzone
353          */
354         isItemUsed: function(value) {
355             if (value === 0) {
356                 return false; // None option can always be selected.
357             }
359             if (dragDropToImageForm.form.getFormValue('drags', [value - 1, 'infinite'])) {
360                 return false; // Infinite, so can't be used up.
361             }
363             return $('fieldset#id_dropzoneheader select').filter(function(i, selectNode) {
364                 return parseInt($(selectNode).val()) === value;
365             }).length !== 0;
366         },
368         /**
369          * Handles when a dropzone in dragged in the preview.
370          * @param {Object} e Event object
371          */
372         dragStart: function(e) {
373             var drop = $(e.target).closest('.droppreview');
375             var info = dragDrop.prepare(e);
376             if (!info.start) {
377                 return;
378             }
380             dragDrop.start(e, drop, function(x, y, drop) {
381                 dragDropToImageForm.dragMove(drop);
382             }, function() {
383                 dragDropToImageForm.dragEnd();
384             });
385         },
387         /**
388          * Handles update while a drop is being dragged.
389          *
390          * @param {jQuery} drop the drop preview being moved.
391          */
392         dragMove: function(drop) {
393             var backgroundImage = $('fieldset#id_previewareaheader .dropbackground'),
394                 backgroundPosition = backgroundImage.offset(),
395                 dropNo = drop.data('dropNo'),
396                 dropPosition = drop.offset(),
397                 left = Math.round(dropPosition.left - backgroundPosition.left),
398                 top = Math.round(dropPosition.top - backgroundPosition.top);
400             // Constrain coordinates to be inside the background.
401             // The -10 here matches the +10 in resizeAllDragsAndDropsInGroup().
402             left = Math.max(0, Math.min(left, backgroundImage.width() - drop.width() - 10));
403             top = Math.max(0, Math.min(top, backgroundImage.height() - drop.height() - 10));
405             // Update the form.
406             dragDropToImageForm.form.setFormValue('drops', [dropNo, 'xleft'], left);
407             dragDropToImageForm.form.setFormValue('drops', [dropNo, 'ytop'], top);
408         },
410         /**
411          * Handles when the drag ends.
412          */
413         dragEnd: function() {
414             // Redraw, in case the position was constrained.
415             dragDropToImageForm.updateDropZones();
416         },
418         /**
419          * Low level operations on form.
420          */
421         form: {
422             toNameWithIndex: function(name, indexes) {
423                 var indexString = name;
424                 for (var i = 0; i < indexes.length; i++) {
425                     indexString = indexString + '[' + indexes[i] + ']';
426                 }
427                 return indexString;
428             },
430             getEl: function(name, indexes) {
431                 var form = $('form.mform')[0];
432                 return form.elements[this.toNameWithIndex(name, indexes)];
433             },
435             /**
436              * Helper to get the value of a form elements with name like "drops[0][xleft]".
437              *
438              * @param {String} name the base name, e.g. 'drops'.
439              * @param {String[]} indexes the indexes, e.g. ['0', 'xleft'].
440              * @return {String} the value of that field.
441              */
442             getFormValue: function(name, indexes) {
443                 var el = this.getEl(name, indexes);
444                 if (!el.type) {
445                     el = el[el.length - 1];
446                 }
447                 if (el.type === 'checkbox') {
448                     return el.checked;
449                 } else {
450                     return el.value;
451                 }
452             },
454             /**
455              * Helper to get the value of a form elements with name like "drops[0][xleft]".
456              *
457              * @param {String} name the base name, e.g. 'drops'.
458              * @param {String[]} indexes the indexes, e.g. ['0', 'xleft'].
459              * @param {String|Number} value the value to set.
460              */
461             setFormValue: function(name, indexes, value) {
462                 var el = this.getEl(name, indexes);
463                 if (el.type === 'checkbox') {
464                     el.checked = value;
465                 } else {
466                     el.value = value;
467                 }
468             }
469         },
471         /**
472          * Utility to get the file name and url from the filepicker.
473          * @returns {Object} object containing functions {file, name}
474          */
475         filePickers: function() {
476             var draftItemIdsToName;
477             var nameToParentNode;
479             if (draftItemIdsToName === undefined) {
480                 draftItemIdsToName = {};
481                 nameToParentNode = {};
482                 var fp = $('form.mform input.filepickerhidden');
483                 fp.each(function(index, filepicker) {
484                     draftItemIdsToName[filepicker.value] = filepicker.name;
485                     nameToParentNode[filepicker.name] = filepicker.parentNode;
486                 });
487             }
489             return {
490                 file: function(name) {
491                     var parentNode = $(nameToParentNode[name]);
492                     var fileAnchor = parentNode.find('div.filepicker-filelist a');
493                     if (fileAnchor.length) {
494                         return {href: fileAnchor.get(0).href, name: fileAnchor.get(0).innerHTML};
495                     } else {
496                         return {href: null, name: null};
497                     }
498                 },
500                 name: function(draftitemid) {
501                     return draftItemIdsToName[draftitemid];
502                 }
503             };
504         }
505     };
507     /**
508      * @alias module:qtype_ddimageortext/form
509      */
510     return {
511         /**
512          * Initialise the form JavaScript features.
513          */
514         init: dragDropToImageForm.init
515     };
516 });