Commit | Line | Data |
---|---|---|
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/question | |
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', 'core/key_codes'], function($, dragDrop, keys) { | |
25 | ||
26 | "use strict"; | |
27 | ||
28 | /** | |
29 | * Initialise one drag-drop onto image question. | |
30 | * | |
31 | * @param {String} containerId id of the outer div for this question. | |
32 | * @param {boolean} readOnly whether the question is being displayed read-only. | |
33 | * @param {Array} places Information about the drop places. | |
34 | * @constructor | |
35 | */ | |
36 | function DragDropOntoImageQuestion(containerId, readOnly, places) { | |
37 | this.containerId = containerId; | |
38 | M.util.js_pending('qtype_ddimageortext-init-' + this.containerId); | |
39 | this.places = places; | |
40 | this.allImagesLoaded = false; | |
41 | this.imageLoadingTimeoutId = null; | |
42 | if (readOnly) { | |
43 | this.getRoot().addClass('qtype_ddimageortext-readonly'); | |
44 | } | |
45 | ||
46 | var thisQ = this; | |
47 | this.getNotYetLoadedImages().one('load', function() { | |
48 | thisQ.waitForAllImagesToBeLoaded(); | |
49 | }); | |
50 | this.waitForAllImagesToBeLoaded(); | |
51 | } | |
52 | ||
53 | /** | |
54 | * Waits until all images are loaded before calling setupQuestion(). | |
55 | * | |
56 | * This function is called from the onLoad of each image, and also polls with | |
57 | * a time-out, because image on-loads are allegedly unreliable. | |
58 | */ | |
59 | DragDropOntoImageQuestion.prototype.waitForAllImagesToBeLoaded = function() { | |
60 | var thisQ = this; | |
61 | ||
62 | // This method may get called multiple times (via image on-loads or timeouts. | |
63 | // If we are already done, don't do it again. | |
64 | if (this.allImagesLoaded) { | |
65 | return; | |
66 | } | |
67 | ||
68 | // Clear any current timeout, if set. | |
69 | if (this.imageLoadingTimeoutId !== null) { | |
70 | clearTimeout(this.imageLoadingTimeoutId); | |
71 | } | |
72 | ||
73 | // If we have not yet loaded all images, set a timeout to | |
74 | // call ourselves again, since apparently images on-load | |
75 | // events are flakey. | |
76 | if (this.getNotYetLoadedImages().length > 0) { | |
77 | this.imageLoadingTimeoutId = setTimeout(function() { | |
78 | thisQ.waitForAllImagesToBeLoaded(); | |
79 | }, 100); | |
80 | return; | |
81 | } | |
82 | ||
83 | // We now have all images. Carry on, but only after giving the layout a chance to settle down. | |
84 | this.allImagesLoaded = true; | |
85 | thisQ.setupQuestion(); | |
86 | }; | |
87 | ||
88 | /** | |
89 | * Get any of the images in the drag-drop area that are not yet fully loaded. | |
90 | * | |
91 | * @returns {jQuery} those images. | |
92 | */ | |
93 | DragDropOntoImageQuestion.prototype.getNotYetLoadedImages = function() { | |
94 | var thisQ = this; | |
95 | return this.getRoot().find('.ddarea img').not(function(i, imgNode) { | |
96 | return thisQ.imageIsLoaded(imgNode); | |
97 | }); | |
98 | }; | |
99 | ||
100 | /** | |
101 | * Check if an image has loaded without errors. | |
102 | * | |
103 | * @param {HTMLImageElement} imgElement an image. | |
104 | * @returns {boolean} true if this image has loaded without errors. | |
105 | */ | |
106 | DragDropOntoImageQuestion.prototype.imageIsLoaded = function(imgElement) { | |
107 | return imgElement.complete && imgElement.naturalHeight !== 0; | |
108 | }; | |
109 | ||
110 | /** | |
111 | * Set up the question, once all images have been loaded. | |
112 | */ | |
113 | DragDropOntoImageQuestion.prototype.setupQuestion = function() { | |
114 | this.resizeAllDragsAndDrops(); | |
115 | this.cloneDrags(); | |
116 | this.positionDragsAndDrops(); | |
117 | M.util.js_complete('qtype_ddimageortext-init-' + this.containerId); | |
ebf91776 TH |
118 | }; |
119 | ||
120 | /** | |
121 | * In each group, resize all the items to be the same size. | |
122 | */ | |
123 | DragDropOntoImageQuestion.prototype.resizeAllDragsAndDrops = function() { | |
124 | var thisQ = this; | |
d434dd7a TH |
125 | this.getRoot().find('.draghomes > div').each(function(i, node) { |
126 | thisQ.resizeAllDragsAndDropsInGroup( | |
127 | thisQ.getClassnameNumericSuffix($(node), 'dragitemgroup')); | |
ebf91776 TH |
128 | }); |
129 | }; | |
130 | ||
131 | /** | |
132 | * In a given group, set all the drags and drops to be the same size. | |
133 | * | |
134 | * @param {int} group the group number. | |
135 | */ | |
136 | DragDropOntoImageQuestion.prototype.resizeAllDragsAndDropsInGroup = function(group) { | |
137 | var root = this.getRoot(), | |
138 | dragHomes = root.find('.dragitemgroup' + group + ' .draghome'), | |
139 | maxWidth = 0, | |
140 | maxHeight = 0; | |
141 | ||
142 | // Find the maximum size of any drag in this groups. | |
143 | dragHomes.each(function(i, drag) { | |
144 | maxWidth = Math.max(maxWidth, Math.ceil(drag.offsetWidth)); | |
145 | maxHeight = Math.max(maxHeight, Math.ceil(drag.offsetHeight)); | |
146 | }); | |
147 | ||
148 | // The size we will want to set is a bit bigger than this. | |
149 | maxWidth += 10; | |
150 | maxHeight += 10; | |
151 | ||
152 | // Set each drag home to that size. | |
153 | dragHomes.each(function(i, drag) { | |
154 | var left = Math.round((maxWidth - drag.offsetWidth) / 2), | |
155 | top = Math.floor((maxHeight - drag.offsetHeight) / 2); | |
156 | // Set top and left padding so the item is centred. | |
157 | $(drag).css({ | |
158 | 'padding-left': left + 'px', | |
159 | 'padding-right': (maxWidth - drag.offsetWidth - left) + 'px', | |
160 | 'padding-top': top + 'px', | |
161 | 'padding-bottom': (maxHeight - drag.offsetHeight - top) + 'px' | |
162 | }); | |
163 | }); | |
164 | ||
165 | // Create the drops and make them the right size. | |
166 | for (var i in this.places) { | |
167 | if (!this.places.hasOwnProperty((i))) { | |
168 | continue; | |
169 | } | |
170 | var place = this.places[i], | |
171 | label = place.text; | |
172 | if (parseInt(place.group) !== group) { | |
173 | continue; | |
174 | } | |
175 | if (label === '') { | |
176 | label = M.util.get_string('blank', 'qtype_ddimageortext'); | |
177 | } | |
178 | root.find('.dropzones').append('<div class="dropzone group' + place.group + | |
179 | ' place' + i + '" tabindex="0">' + | |
180 | '<span class="accesshide">' + label + '</span> </div>'); | |
181 | root.find('.dropzone.place' + i).width(maxWidth - 2).height(maxHeight - 2); | |
182 | } | |
183 | }; | |
184 | ||
185 | /** | |
186 | * Invisible 'drag homes' are output by the renderer. These have the same properties | |
187 | * as the drag items but are invisible. We clone these invisible elements to make the | |
188 | * actual drag items. | |
189 | */ | |
190 | DragDropOntoImageQuestion.prototype.cloneDrags = function() { | |
191 | var thisQ = this; | |
192 | this.getRoot().find('.ddarea .draghome').each(function(index, dragHome) { | |
193 | thisQ.cloneDragsForOneChoice($(dragHome)); | |
194 | }); | |
195 | }; | |
196 | ||
197 | /** | |
198 | * Clone drag item for one choice. | |
199 | * | |
200 | * @param {jQuery} dragHome the drag home to clone. | |
201 | */ | |
202 | DragDropOntoImageQuestion.prototype.cloneDragsForOneChoice = function(dragHome) { | |
203 | if (dragHome.hasClass('infinite')) { | |
204 | var noOfDrags = this.noOfDropsInGroup(this.getGroup(dragHome)); | |
205 | for (var i = 0; i < noOfDrags; i++) { | |
206 | this.cloneDrag(dragHome); | |
207 | } | |
208 | } else { | |
209 | this.cloneDrag(dragHome); | |
210 | } | |
211 | }; | |
212 | ||
213 | /** | |
214 | * Clone drag item. | |
215 | * | |
216 | * @param {jQuery} dragHome | |
217 | */ | |
218 | DragDropOntoImageQuestion.prototype.cloneDrag = function(dragHome) { | |
219 | var drag = dragHome.clone(); | |
220 | drag.removeClass('draghome') | |
221 | .addClass('drag unplaced moodle-has-zindex') | |
222 | .offset(dragHome.offset()); | |
223 | this.getRoot().find('.dragitems').append(drag); | |
224 | }; | |
225 | ||
226 | /** | |
227 | * Update the position of drags. | |
228 | */ | |
229 | DragDropOntoImageQuestion.prototype.positionDragsAndDrops = function() { | |
230 | var thisQ = this, | |
231 | root = this.getRoot(), | |
232 | bgPosition = this.bgImage().offset(); | |
233 | ||
234 | // Move the drops into position. | |
235 | root.find('.ddarea .dropzone').each(function(i, dropNode) { | |
236 | var drop = $(dropNode), | |
237 | place = thisQ.places[thisQ.getPlace(drop)]; | |
238 | // The xy values come from PHP as strings, so we need parseInt to stop JS doing string concatenation. | |
239 | drop.offset({ | |
240 | left: bgPosition.left + parseInt(place.xy[0]), | |
241 | top: bgPosition.top + parseInt(place.xy[1])}); | |
242 | }); | |
243 | ||
244 | // First move all items back home. | |
245 | root.find('.ddarea .drag').each(function(i, dragNode) { | |
246 | var drag = $(dragNode), | |
247 | currentPlace = thisQ.getClassnameNumericSuffix(drag, 'inplace'); | |
248 | drag.addClass('unplaced') | |
249 | .removeClass('placed') | |
250 | .offset(thisQ.getDragHome(thisQ.getGroup(drag), thisQ.getChoice(drag)).offset()); | |
251 | if (currentPlace !== null) { | |
252 | drag.removeClass('inplace' + currentPlace); | |
253 | } | |
254 | }); | |
255 | ||
256 | // Then place the ones that should be placed. | |
257 | root.find('input.placeinput').each(function(i, inputNode) { | |
258 | var input = $(inputNode), | |
259 | choice = input.val(); | |
260 | if (choice === '0') { | |
261 | // No item in this place. | |
262 | return; | |
263 | } | |
264 | ||
265 | var place = thisQ.getPlace(input); | |
266 | thisQ.getUnplacedChoice(thisQ.getGroup(input), choice) | |
267 | .removeClass('unplaced') | |
268 | .addClass('placed inplace' + place) | |
269 | .offset(root.find('.dropzone.place' + place).offset()); | |
270 | }); | |
e5153d93 TH |
271 | |
272 | this.bgImage().data('prev-top', bgPosition.top).data('prev-left', bgPosition.left); | |
273 | }; | |
274 | ||
275 | /** | |
276 | * Check to see if the background image has moved. If so, refresh the layout. | |
277 | */ | |
278 | DragDropOntoImageQuestion.prototype.fixLayoutIfBackgroundMoved = function() { | |
279 | var bgImage = this.bgImage(), | |
280 | bgPosition = bgImage.offset(), | |
281 | prevTop = bgImage.data('prev-top'), | |
282 | prevLeft = bgImage.data('prev-left'); | |
283 | if (prevLeft === undefined || prevTop === undefined) { | |
284 | // Question is not set up yet. Nothing to do. | |
285 | return; | |
286 | } | |
287 | if (prevTop === bgPosition.top && prevLeft === bgPosition.left) { | |
288 | // Things have not moved. | |
289 | return; | |
290 | } | |
291 | // We need to reposition things. | |
292 | this.positionDragsAndDrops(); | |
ebf91776 TH |
293 | }; |
294 | ||
295 | /** | |
296 | * Handles the start of dragging an item. | |
297 | * | |
298 | * @param {Event} e the touch start or mouse down event. | |
299 | */ | |
300 | DragDropOntoImageQuestion.prototype.handleDragStart = function(e) { | |
301 | var thisQ = this, | |
302 | drag = $(e.target).closest('.drag'); | |
303 | ||
304 | var info = dragDrop.prepare(e); | |
305 | if (!info.start) { | |
306 | return; | |
307 | } | |
308 | ||
309 | var currentPlace = this.getClassnameNumericSuffix(drag, 'inplace'); | |
310 | if (currentPlace !== null) { | |
311 | this.setInputValue(currentPlace, 0); | |
312 | drag.removeClass('inplace' + currentPlace); | |
313 | } | |
314 | ||
315 | drag.addClass('beingdragged'); | |
316 | dragDrop.start(e, drag, function(x, y, drag) { | |
317 | thisQ.dragMove(x, y, drag); | |
318 | }, function(x, y, drag) { | |
319 | thisQ.dragEnd(x, y, drag); | |
320 | }); | |
321 | }; | |
322 | ||
323 | /** | |
324 | * Called whenever the currently dragged items moves. | |
325 | * | |
326 | * @param {Number} pageX the x position. | |
327 | * @param {Number} pageY the y position. | |
328 | * @param {jQuery} drag the item being moved. | |
329 | */ | |
330 | DragDropOntoImageQuestion.prototype.dragMove = function(pageX, pageY, drag) { | |
331 | var thisQ = this; | |
332 | this.getRoot().find('.dropzone.group' + this.getGroup(drag)).each(function(i, dropNode) { | |
333 | var drop = $(dropNode); | |
334 | if (thisQ.isPointInDrop(pageX, pageY, drop)) { | |
335 | drop.addClass('valid-drag-over-drop'); | |
336 | } else { | |
337 | drop.removeClass('valid-drag-over-drop'); | |
338 | } | |
339 | }); | |
340 | }; | |
341 | ||
342 | /** | |
343 | * Called when user drops a drag item. | |
344 | * | |
345 | * @param {Number} pageX the x position. | |
346 | * @param {Number} pageY the y position. | |
347 | * @param {jQuery} drag the item being moved. | |
348 | */ | |
349 | DragDropOntoImageQuestion.prototype.dragEnd = function(pageX, pageY, drag) { | |
350 | var thisQ = this, | |
351 | root = this.getRoot(), | |
352 | placed = false; | |
353 | root.find('.dropzone.group' + this.getGroup(drag)).each(function(i, dropNode) { | |
354 | var drop = $(dropNode); | |
355 | if (!thisQ.isPointInDrop(pageX, pageY, drop)) { | |
356 | // Not this drop. | |
357 | return true; | |
358 | } | |
359 | ||
360 | // Now put this drag into the drop. | |
361 | drop.removeClass('valid-drag-over-drop'); | |
362 | thisQ.sendDragToDrop(drag, drop); | |
363 | placed = true; | |
364 | return false; // Stop the each() here. | |
365 | }); | |
366 | ||
367 | if (!placed) { | |
368 | this.sendDragHome(drag); | |
369 | } | |
370 | }; | |
371 | ||
372 | /** | |
373 | * Animate a drag item into a given place (or back home). | |
374 | * | |
375 | * @param {jQuery|null} drag the item to place. If null, clear the place. | |
376 | * @param {jQuery} drop the place to put it. | |
377 | */ | |
378 | DragDropOntoImageQuestion.prototype.sendDragToDrop = function(drag, drop) { | |
379 | // Is there already a drag in this drop? if so, evict it. | |
380 | var oldDrag = this.getCurrentDragInPlace(this.getPlace(drop)); | |
381 | if (oldDrag.length !== 0) { | |
382 | this.sendDragHome(oldDrag); | |
383 | } | |
384 | ||
385 | if (drag.length === 0) { | |
386 | this.setInputValue(this.getPlace(drop), 0); | |
387 | } else { | |
388 | this.setInputValue(this.getPlace(drop), this.getChoice(drag)); | |
389 | drag.removeClass('unplaced') | |
390 | .addClass('placed inplace' + this.getPlace(drop)); | |
391 | this.animateTo(drag, drop); | |
392 | } | |
393 | }; | |
394 | ||
395 | /** | |
396 | * Animate a drag back to its home. | |
397 | * | |
398 | * @param {jQuery} drag the item being moved. | |
399 | */ | |
400 | DragDropOntoImageQuestion.prototype.sendDragHome = function(drag) { | |
401 | drag.removeClass('placed').addClass('unplaced'); | |
402 | var currentPlace = this.getClassnameNumericSuffix(drag, 'inplace'); | |
403 | if (currentPlace !== null) { | |
404 | drag.removeClass('inplace' + currentPlace); | |
405 | } | |
406 | ||
407 | this.animateTo(drag, this.getDragHome(this.getGroup(drag), this.getChoice(drag))); | |
408 | }; | |
409 | ||
410 | /** | |
411 | * Handles keyboard events on drops. | |
412 | * | |
413 | * Drops are focusable. Once focused, right/down/space switches to the next choice, and | |
414 | * left/up switches to the previous. Escape clear. | |
415 | * | |
416 | * @param {KeyboardEvent} e | |
417 | */ | |
418 | DragDropOntoImageQuestion.prototype.handleKeyPress = function(e) { | |
419 | var drop = $(e.target).closest('.dropzone'), | |
420 | currentDrag = this.getCurrentDragInPlace(this.getPlace(drop)), | |
421 | nextDrag = $(); | |
422 | ||
423 | switch (e.keyCode) { | |
424 | case keys.space: | |
425 | case keys.arrowRight: | |
426 | case keys.arrowDown: | |
427 | nextDrag = this.getNextDrag(this.getGroup(drop), currentDrag); | |
428 | break; | |
429 | ||
430 | case keys.arrowLeft: | |
431 | case keys.arrowUp: | |
432 | nextDrag = this.getPreviousDrag(this.getGroup(drop), currentDrag); | |
433 | break; | |
434 | ||
435 | case keys.escape: | |
436 | break; | |
437 | ||
438 | default: | |
439 | return; // To avoid the preventDefault below. | |
440 | } | |
441 | ||
442 | e.preventDefault(); | |
443 | this.sendDragToDrop(nextDrag, drop); | |
444 | }; | |
445 | ||
446 | /** | |
447 | * Choose the next drag in a group. | |
448 | * | |
449 | * @param {int} group which group. | |
450 | * @param {jQuery} drag current choice (empty jQuery if there isn't one). | |
451 | * @return {jQuery} the next drag in that group, or null if there wasn't one. | |
452 | */ | |
453 | DragDropOntoImageQuestion.prototype.getNextDrag = function(group, drag) { | |
454 | var choice, | |
455 | numChoices = this.noOfChoicesInGroup(group); | |
456 | ||
457 | if (drag.length === 0) { | |
458 | choice = 1; // Was empty, so we want to select the first choice. | |
459 | } else { | |
460 | choice = this.getChoice(drag) + 1; | |
461 | } | |
462 | ||
463 | var next = this.getUnplacedChoice(group, choice); | |
464 | while (next.length === 0 && choice < numChoices) { | |
465 | choice++; | |
466 | next = this.getUnplacedChoice(group, choice); | |
467 | } | |
468 | ||
469 | return next; | |
470 | }; | |
471 | ||
472 | /** | |
473 | * Choose the previous drag in a group. | |
474 | * | |
475 | * @param {int} group which group. | |
476 | * @param {jQuery} drag current choice (empty jQuery if there isn't one). | |
477 | * @return {jQuery} the next drag in that group, or null if there wasn't one. | |
478 | */ | |
479 | DragDropOntoImageQuestion.prototype.getPreviousDrag = function(group, drag) { | |
480 | var choice; | |
481 | ||
482 | if (drag.length === 0) { | |
483 | choice = this.noOfChoicesInGroup(group); | |
484 | } else { | |
485 | choice = this.getChoice(drag) - 1; | |
486 | } | |
487 | ||
488 | var previous = this.getUnplacedChoice(group, choice); | |
489 | while (previous.length === 0 && choice > 1) { | |
490 | choice--; | |
491 | previous = this.getUnplacedChoice(group, choice); | |
492 | } | |
493 | ||
494 | // Does this choice exist? | |
495 | return previous; | |
496 | }; | |
497 | ||
498 | /** | |
499 | * Animate an object to the given destination. | |
500 | * | |
501 | * @param {jQuery} drag the element to be animated. | |
502 | * @param {jQuery} target element marking the place to move it to. | |
503 | */ | |
504 | DragDropOntoImageQuestion.prototype.animateTo = function(drag, target) { | |
505 | var currentPos = drag.offset(), | |
506 | targetPos = target.offset(); | |
507 | drag.addClass('beingdragged'); | |
508 | ||
509 | // Animate works in terms of CSS position, whereas locating an object | |
510 | // on the page works best with jQuery offset() function. So, to get | |
511 | // the right target position, we work out the required change in | |
512 | // offset() and then add that to the current CSS position. | |
513 | drag.animate( | |
514 | { | |
515 | left: parseInt(drag.css('left')) + targetPos.left - currentPos.left, | |
516 | top: parseInt(drag.css('top')) + targetPos.top - currentPos.top | |
517 | }, | |
518 | { | |
519 | duration: 'fast', | |
520 | done: function() { | |
521 | drag.removeClass('beingdragged'); | |
522 | // It seems that the animation sometimes leaves the drag | |
523 | // one pixel out of position. Put it in exactly the right place. | |
524 | drag.offset(targetPos); | |
525 | } | |
526 | } | |
527 | ); | |
528 | }; | |
529 | ||
530 | /** | |
531 | * Detect if a point is inside a given DOM node. | |
532 | * | |
533 | * @param {Number} pageX the x position. | |
534 | * @param {Number} pageY the y position. | |
535 | * @param {jQuery} drop the node to check (typically a drop). | |
536 | * @return {boolean} whether the point is inside the node. | |
537 | */ | |
538 | DragDropOntoImageQuestion.prototype.isPointInDrop = function(pageX, pageY, drop) { | |
539 | var position = drop.offset(); | |
540 | return pageX >= position.left && pageX < position.left + drop.width() | |
541 | && pageY >= position.top && pageY < position.top + drop.height(); | |
542 | }; | |
543 | ||
544 | /** | |
545 | * Set the value of the hidden input for a place, to record what is currently there. | |
546 | * | |
547 | * @param {int} place which place to set the input value for. | |
548 | * @param {int} choice the value to set. | |
549 | */ | |
550 | DragDropOntoImageQuestion.prototype.setInputValue = function(place, choice) { | |
551 | this.getRoot().find('input.placeinput.place' + place).val(choice); | |
552 | }; | |
553 | ||
554 | /** | |
555 | * Get the outer div for this question. | |
556 | * | |
557 | * @returns {jQuery} containing that div. | |
558 | */ | |
559 | DragDropOntoImageQuestion.prototype.getRoot = function() { | |
560 | return $(document.getElementById(this.containerId)); | |
561 | }; | |
562 | ||
563 | /** | |
564 | * Get the img that is the background image. | |
565 | * @returns {jQuery} containing that img. | |
566 | */ | |
567 | DragDropOntoImageQuestion.prototype.bgImage = function() { | |
568 | return this.getRoot().find('img.dropbackground'); | |
569 | }; | |
570 | ||
571 | /** | |
572 | * Get drag home for a given choice. | |
573 | * | |
574 | * @param {int} group the group. | |
575 | * @param {int} choice the choice number. | |
576 | * @returns {jQuery} containing that div. | |
577 | */ | |
578 | DragDropOntoImageQuestion.prototype.getDragHome = function(group, choice) { | |
579 | return this.getRoot().find('.ddarea .draghome.group' + group + '.choice' + choice); | |
580 | }; | |
581 | ||
582 | /** | |
583 | * Get an unplaced choice for a particular group. | |
584 | * | |
585 | * @param {int} group the group. | |
586 | * @param {int} choice the choice number. | |
587 | * @returns {jQuery} jQuery wrapping the unplaced choice. If there isn't one, the jQuery will be empty. | |
588 | */ | |
589 | DragDropOntoImageQuestion.prototype.getUnplacedChoice = function(group, choice) { | |
590 | return this.getRoot().find('.ddarea .drag.group' + group + '.choice' + choice + '.unplaced').slice(0, 1); | |
591 | }; | |
592 | ||
593 | /** | |
594 | * Get the drag that is currently in a given place. | |
595 | * | |
596 | * @param {int} place the place number. | |
597 | * @return {jQuery} the current drag (or an empty jQuery if none). | |
598 | */ | |
599 | DragDropOntoImageQuestion.prototype.getCurrentDragInPlace = function(place) { | |
600 | return this.getRoot().find('.ddarea .drag.inplace' + place); | |
601 | }; | |
602 | ||
603 | /** | |
604 | * Return the number of blanks in a given group. | |
605 | * | |
606 | * @param {int} group the group number. | |
607 | * @returns {int} the number of drops. | |
608 | */ | |
609 | DragDropOntoImageQuestion.prototype.noOfDropsInGroup = function(group) { | |
610 | return this.getRoot().find('.dropzone.group' + group).length; | |
611 | }; | |
612 | ||
613 | /** | |
614 | * Return the number of choices in a given group. | |
615 | * | |
616 | * @param {int} group the group number. | |
617 | * @returns {int} the number of choices. | |
618 | */ | |
619 | DragDropOntoImageQuestion.prototype.noOfChoicesInGroup = function(group) { | |
620 | return this.getRoot().find('.dragitemgroup' + group + ' .draghome').length; | |
621 | }; | |
622 | ||
623 | /** | |
624 | * Return the number at the end of the CSS class name with the given prefix. | |
625 | * | |
626 | * @param {jQuery} node | |
627 | * @param {String} prefix name prefix | |
628 | * @returns {Number|null} the suffix if found, else null. | |
629 | */ | |
630 | DragDropOntoImageQuestion.prototype.getClassnameNumericSuffix = function(node, prefix) { | |
631 | var classes = node.attr('class'); | |
632 | if (classes !== '') { | |
633 | var classesArr = classes.split(' '); | |
634 | for (var index = 0; index < classesArr.length; index++) { | |
635 | var patt1 = new RegExp('^' + prefix + '([0-9])+$'); | |
636 | if (patt1.test(classesArr[index])) { | |
637 | var patt2 = new RegExp('([0-9])+$'); | |
638 | var match = patt2.exec(classesArr[index]); | |
639 | return Number(match[0]); | |
640 | } | |
641 | } | |
642 | } | |
643 | return null; | |
644 | }; | |
645 | ||
646 | /** | |
647 | * Get the choice number of a drag. | |
648 | * | |
649 | * @param {jQuery} drag the drag. | |
650 | * @returns {Number} the choice number. | |
651 | */ | |
652 | DragDropOntoImageQuestion.prototype.getChoice = function(drag) { | |
653 | return this.getClassnameNumericSuffix(drag, 'choice'); | |
654 | }; | |
655 | ||
656 | /** | |
657 | * Given a DOM node that is significant to this question | |
658 | * (drag, drop, ...) get the group it belongs to. | |
659 | * | |
660 | * @param {jQuery} node a DOM node. | |
661 | * @returns {Number} the group it belongs to. | |
662 | */ | |
663 | DragDropOntoImageQuestion.prototype.getGroup = function(node) { | |
664 | return this.getClassnameNumericSuffix(node, 'group'); | |
665 | }; | |
666 | ||
667 | /** | |
668 | * Get the place number of a drop, or its corresponding hidden input. | |
669 | * | |
670 | * @param {jQuery} node the DOM node. | |
671 | * @returns {Number} the place number. | |
672 | */ | |
673 | DragDropOntoImageQuestion.prototype.getPlace = function(node) { | |
674 | return this.getClassnameNumericSuffix(node, 'place'); | |
675 | }; | |
676 | ||
677 | /** | |
678 | * Singleton object that handles all the DragDropOntoImageQuestions | |
679 | * on the page, and deals with event dispatching. | |
680 | * @type {Object} | |
681 | */ | |
682 | var questionManager = { | |
683 | ||
684 | /** | |
685 | * {boolean} ensures that the event handlers are only initialised once per page. | |
686 | */ | |
687 | eventHandlersInitialised: false, | |
688 | ||
689 | /** | |
690 | * {Object} all the questions on this page, indexed by containerId (id on the .que div). | |
691 | */ | |
692 | questions: {}, // An object containing all the information about each question on the page. | |
693 | ||
694 | /** | |
695 | * Initialise one question. | |
696 | * | |
697 | * @param {String} containerId the id of the div.que that contains this question. | |
698 | * @param {boolean} readOnly whether the question is read-only. | |
699 | * @param {Array} places data. | |
700 | */ | |
701 | init: function(containerId, readOnly, places) { | |
702 | questionManager.questions[containerId] = | |
703 | new DragDropOntoImageQuestion(containerId, readOnly, places); | |
704 | if (!questionManager.eventHandlersInitialised) { | |
705 | questionManager.setupEventHandlers(); | |
706 | questionManager.eventHandlersInitialised = true; | |
707 | } | |
708 | }, | |
709 | ||
710 | /** | |
711 | * Set up the event handlers that make this question type work. (Done once per page.) | |
712 | */ | |
713 | setupEventHandlers: function() { | |
714 | $('body') | |
715 | .on('mousedown touchstart', | |
716 | '.que.ddimageortext:not(.qtype_ddimageortext-readonly) .dragitems .drag', | |
717 | questionManager.handleDragStart) | |
718 | .on('keydown', | |
719 | '.que.ddimageortext:not(.qtype_ddimageortext-readonly) .dropzones .dropzone', | |
720 | questionManager.handleKeyPress); | |
721 | $(window).on('resize', questionManager.handleWindowResize); | |
e5153d93 | 722 | setTimeout(questionManager.fixLayoutIfThingsMoved, 100); |
ebf91776 TH |
723 | }, |
724 | ||
725 | /** | |
726 | * Handle mouse down / touch start events on drags. | |
727 | * @param {Event} e the DOM event. | |
728 | */ | |
729 | handleDragStart: function(e) { | |
730 | e.preventDefault(); | |
731 | var question = questionManager.getQuestionForEvent(e); | |
732 | if (question) { | |
733 | question.handleDragStart(e); | |
734 | } | |
735 | }, | |
736 | ||
737 | /** | |
738 | * Handle key down / press events on drags. | |
739 | * @param {KeyboardEvent} e | |
740 | */ | |
741 | handleKeyPress: function(e) { | |
742 | var question = questionManager.getQuestionForEvent(e); | |
743 | if (question) { | |
744 | question.handleKeyPress(e); | |
745 | } | |
746 | }, | |
747 | ||
748 | /** | |
749 | * Handle when the window is resized. | |
750 | */ | |
751 | handleWindowResize: function() { | |
752 | for (var containerId in questionManager.questions) { | |
753 | if (questionManager.questions.hasOwnProperty(containerId)) { | |
754 | questionManager.questions[containerId].positionDragsAndDrops(); | |
755 | } | |
756 | } | |
757 | }, | |
758 | ||
e5153d93 TH |
759 | /** |
760 | * Sometimes, despite our best efforts, things change in a way that cannot | |
761 | * be specifically caught (e.g. dock expanding or collapsing in Boost). | |
762 | * Therefore, we need to periodically check everything is in the right position. | |
763 | */ | |
764 | fixLayoutIfThingsMoved: function() { | |
765 | for (var containerId in questionManager.questions) { | |
766 | if (questionManager.questions.hasOwnProperty(containerId)) { | |
767 | questionManager.questions[containerId].fixLayoutIfBackgroundMoved(); | |
768 | } | |
769 | } | |
770 | ||
771 | // We use setTimeout after finishing work, rather than setInterval, | |
772 | // in case positioning things is slow. We want 100 ms gap | |
773 | // between executions, not what setInterval does. | |
774 | setTimeout(questionManager.fixLayoutIfThingsMoved, 100); | |
775 | }, | |
776 | ||
ebf91776 TH |
777 | /** |
778 | * Given an event, work out which question it effects. | |
779 | * @param {Event} e the event. | |
780 | * @returns {DragDropOntoImageQuestion|undefined} The question, or undefined. | |
781 | */ | |
782 | getQuestionForEvent: function(e) { | |
783 | var containerId = $(e.currentTarget).closest('.que.ddimageortext').attr('id'); | |
784 | return questionManager.questions[containerId]; | |
785 | } | |
786 | }; | |
787 | ||
788 | /** | |
789 | * @alias module:qtype_ddimageortext/question | |
790 | */ | |
791 | return { | |
792 | /** | |
793 | * Initialise one drag-drop onto image question. | |
794 | * | |
795 | * @param {String} containerId id of the outer div for this question. | |
796 | * @param {boolean} readOnly whether the question is being displayed read-only. | |
797 | * @param {Array} Information about the drop places. | |
798 | */ | |
799 | init: questionManager.init | |
800 | }; | |
801 | }); |