Merge branch 'MDL-70112_310' of https://github.com/timhunt/moodle into MOODLE_310_STABLE
[moodle.git] / question / type / ddimageortext / amd / src / form.js
CommitLineData
ebf91776
TH
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/>.
15
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 */
24define(['jquery', 'core/dragdrop'], function($, dragDrop) {
25
26 "use strict";
27
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,
38
39 /**
40 * @var {Object} with properties width and height.
41 */
42 maxDragImageSize: null,
43
44 /**
45 * @var {object} for interacting with the file pickers.
46 */
47 fp: null, // Object containing functions associated with the file picker.
48
49 /**
50 * Initialise the form javascript features.
ebf91776 51 */
d0311165 52 init: function() {
ebf91776
TH
53 dragDropToImageForm.fp = dragDropToImageForm.filePickers();
54
55 $('#id_previewareaheader').append(
a05ef130 56 '<div class="ddarea que ddimageortext">' +
ebf91776
TH
57 ' <div class="droparea">' +
58 ' <img class="dropbackground" />' +
59 ' <div class="dropzones"></div>' +
60 ' </div>' +
61 ' <div class="dragitems"></div>' +
62 '</div>');
63
64 dragDropToImageForm.updateVisibilityOfFilePickers();
65 dragDropToImageForm.setOptionsForDragItemSelectors();
66 dragDropToImageForm.setupEventHandlers();
67 dragDropToImageForm.waitForFilePickerToInitialise();
68 },
69
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');
81
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.
a8efb077 85 $('form.mform[data-qtype="ddimageortext"]').on('change', '.filepickerhidden', function() {
ebf91776
TH
86 M.util.js_pending('dragDropToImageForm');
87 dragDropToImageForm.loadPreviewImage();
88 });
89
90 dragDropToImageForm.loadPreviewImage();
91 },
92
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 },
101
102 /**
103 * After the background image is loaded, continue setting up the preview.
104 */
105 afterPreviewImageLoaded: function() {
ebf91776
TH
106 dragDropToImageForm.createDropZones();
107 M.util.js_complete('dragDropToImageForm');
108 },
109
ebf91776
TH
110 /**
111 * Create, or recreate all the drop zones.
112 */
113 createDropZones: function() {
114 var dropZoneHolder = $('.dropzones');
115 dropZoneHolder.empty();
116
117 var bgimageurl = dragDropToImageForm.fp.file('bgimage').href;
118 if (bgimageurl === null) {
119 return; // There is not currently a valid preview to update.
120 }
121
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 + '">');
139
140 } else if (label !== '') {
141 dropZoneHolder.append('<div class="droppreview group' + group + ' drop' + dropNo +
142 '" data-drop-no="' + dropNo + '">' + label + '</div>');
143 }
144 }
145
146 dragDropToImageForm.waitForAllDropImagesToBeLoaded();
147 },
148
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 });
156
157 if (notYetLoadedImages.length > 0) {
158 setTimeout(function() {
159 dragDropToImageForm.waitForAllDropImagesToBeLoaded();
160 }, 100);
161 return;
162 }
163
164 dragDropToImageForm.updateDropZones();
165 },
166
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 },
176
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 }
185
186 var dropBackgroundPosition = $('fieldset#id_previewareaheader .dropbackground').offset(),
187 numDrops = dragDropToImageForm.form.getFormValue('nodropzone', []);
188
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;
196
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 });
203
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 }
211
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 },
219
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;
229
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 });
235
236 // The size we will want to set is a bit bigger than this.
237 maxWidth += 10;
238 maxHeight += 10;
239
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 },
253
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 }
265
266 dragDropToImageForm.setOptionsForDragItemSelectors();
267
268 if (input.is('.dragitemtype, .draggroup')) {
269 dragDropToImageForm.createDropZones();
270 } else if (input.is('.draglabel')) {
271 dragDropToImageForm.updateDropZones();
272 }
273 });
274
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 });
284
285 // Moving drop zones in the preview.
286 $('fieldset#id_previewareaheader').on('mousedown touchstart', '.droppreview', function(e) {
287 dragDropToImageForm.dragStart(e);
288 });
289
290 $(window).on('resize', function() {
291 dragDropToImageForm.updateDropZones();
292 });
293 },
294
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 },
309
310
311 setOptionsForDragItemSelectors: function() {
312 var dragItemOptions = {'0': ''},
313 numDrags = dragDropToImageForm.form.getFormValue('noitems', []),
314 numDrops = dragDropToImageForm.form.getFormValue('nodropzone', []);
315
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 }
326
327 // Initialise each select.
328 for (var dropNo = 0; dropNo < numDrops; dropNo++) {
329 var selector = $('#id_drops_' + dropNo + '_choice');
330
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 },
347
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 }
358
359 if (dragDropToImageForm.form.getFormValue('drags', [value - 1, 'infinite'])) {
360 return false; // Infinite, so can't be used up.
361 }
362
363 return $('fieldset#id_dropzoneheader select').filter(function(i, selectNode) {
364 return parseInt($(selectNode).val()) === value;
365 }).length !== 0;
366 },
367
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');
374
375 var info = dragDrop.prepare(e);
376 if (!info.start) {
377 return;
378 }
379
380 dragDrop.start(e, drop, function(x, y, drop) {
381 dragDropToImageForm.dragMove(drop);
382 }, function() {
383 dragDropToImageForm.dragEnd();
384 });
385 },
386
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);
399
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));
404
405 // Update the form.
406 dragDropToImageForm.form.setFormValue('drops', [dropNo, 'xleft'], left);
407 dragDropToImageForm.form.setFormValue('drops', [dropNo, 'ytop'], top);
408 },
409
410 /**
411 * Handles when the drag ends.
412 */
413 dragEnd: function() {
414 // Redraw, in case the position was constrained.
415 dragDropToImageForm.updateDropZones();
416 },
417
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 },
429
430 getEl: function(name, indexes) {
a8efb077 431 var form = $('form.mform[data-qtype="ddimageortext"]')[0];
ebf91776
TH
432 return form.elements[this.toNameWithIndex(name, indexes)];
433 },
434
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 },
453
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 },
470
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;
478
479 if (draftItemIdsToName === undefined) {
480 draftItemIdsToName = {};
481 nameToParentNode = {};
a8efb077 482 var fp = $('form.mform[data-qtype="ddimageortext"] input.filepickerhidden');
ebf91776
TH
483 fp.each(function(index, filepicker) {
484 draftItemIdsToName[filepicker.value] = filepicker.name;
485 nameToParentNode[filepicker.name] = filepicker.parentNode;
486 });
487 }
488
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 },
499
500 name: function(draftitemid) {
501 return draftItemIdsToName[draftitemid];
502 }
503 };
504 }
505 };
506
507 /**
508 * @alias module:qtype_ddimageortext/form
509 */
510 return {
511 /**
512 * Initialise the form JavaScript features.
ebf91776
TH
513 */
514 init: dragDropToImageForm.init
515 };
516});