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); | |
118 | ||
119 | }; | |
120 | ||
121 | /** | |
122 | * In each group, resize all the items to be the same size. | |
123 | */ | |
124 | DragDropOntoImageQuestion.prototype.resizeAllDragsAndDrops = function() { | |
125 | var thisQ = this; | |
126 | this.getRoot().find('.draghomes > div').each(function(i) { | |
127 | thisQ.resizeAllDragsAndDropsInGroup(i + 1); | |
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 | }); | |
271 | }; | |
272 | ||
273 | /** | |
274 | * Handles the start of dragging an item. | |
275 | * | |
276 | * @param {Event} e the touch start or mouse down event. | |
277 | */ | |
278 | DragDropOntoImageQuestion.prototype.handleDragStart = function(e) { | |
279 | var thisQ = this, | |
280 | drag = $(e.target).closest('.drag'); | |
281 | ||
282 | var info = dragDrop.prepare(e); | |
283 | if (!info.start) { | |
284 | return; | |
285 | } | |
286 | ||
287 | var currentPlace = this.getClassnameNumericSuffix(drag, 'inplace'); | |
288 | if (currentPlace !== null) { | |
289 | this.setInputValue(currentPlace, 0); | |
290 | drag.removeClass('inplace' + currentPlace); | |
291 | } | |
292 | ||
293 | drag.addClass('beingdragged'); | |
294 | dragDrop.start(e, drag, function(x, y, drag) { | |
295 | thisQ.dragMove(x, y, drag); | |
296 | }, function(x, y, drag) { | |
297 | thisQ.dragEnd(x, y, drag); | |
298 | }); | |
299 | }; | |
300 | ||
301 | /** | |
302 | * Called whenever the currently dragged items moves. | |
303 | * | |
304 | * @param {Number} pageX the x position. | |
305 | * @param {Number} pageY the y position. | |
306 | * @param {jQuery} drag the item being moved. | |
307 | */ | |
308 | DragDropOntoImageQuestion.prototype.dragMove = function(pageX, pageY, drag) { | |
309 | var thisQ = this; | |
310 | this.getRoot().find('.dropzone.group' + this.getGroup(drag)).each(function(i, dropNode) { | |
311 | var drop = $(dropNode); | |
312 | if (thisQ.isPointInDrop(pageX, pageY, drop)) { | |
313 | drop.addClass('valid-drag-over-drop'); | |
314 | } else { | |
315 | drop.removeClass('valid-drag-over-drop'); | |
316 | } | |
317 | }); | |
318 | }; | |
319 | ||
320 | /** | |
321 | * Called when user drops a drag item. | |
322 | * | |
323 | * @param {Number} pageX the x position. | |
324 | * @param {Number} pageY the y position. | |
325 | * @param {jQuery} drag the item being moved. | |
326 | */ | |
327 | DragDropOntoImageQuestion.prototype.dragEnd = function(pageX, pageY, drag) { | |
328 | var thisQ = this, | |
329 | root = this.getRoot(), | |
330 | placed = false; | |
331 | root.find('.dropzone.group' + this.getGroup(drag)).each(function(i, dropNode) { | |
332 | var drop = $(dropNode); | |
333 | if (!thisQ.isPointInDrop(pageX, pageY, drop)) { | |
334 | // Not this drop. | |
335 | return true; | |
336 | } | |
337 | ||
338 | // Now put this drag into the drop. | |
339 | drop.removeClass('valid-drag-over-drop'); | |
340 | thisQ.sendDragToDrop(drag, drop); | |
341 | placed = true; | |
342 | return false; // Stop the each() here. | |
343 | }); | |
344 | ||
345 | if (!placed) { | |
346 | this.sendDragHome(drag); | |
347 | } | |
348 | }; | |
349 | ||
350 | /** | |
351 | * Animate a drag item into a given place (or back home). | |
352 | * | |
353 | * @param {jQuery|null} drag the item to place. If null, clear the place. | |
354 | * @param {jQuery} drop the place to put it. | |
355 | */ | |
356 | DragDropOntoImageQuestion.prototype.sendDragToDrop = function(drag, drop) { | |
357 | // Is there already a drag in this drop? if so, evict it. | |
358 | var oldDrag = this.getCurrentDragInPlace(this.getPlace(drop)); | |
359 | if (oldDrag.length !== 0) { | |
360 | this.sendDragHome(oldDrag); | |
361 | } | |
362 | ||
363 | if (drag.length === 0) { | |
364 | this.setInputValue(this.getPlace(drop), 0); | |
365 | } else { | |
366 | this.setInputValue(this.getPlace(drop), this.getChoice(drag)); | |
367 | drag.removeClass('unplaced') | |
368 | .addClass('placed inplace' + this.getPlace(drop)); | |
369 | this.animateTo(drag, drop); | |
370 | } | |
371 | }; | |
372 | ||
373 | /** | |
374 | * Animate a drag back to its home. | |
375 | * | |
376 | * @param {jQuery} drag the item being moved. | |
377 | */ | |
378 | DragDropOntoImageQuestion.prototype.sendDragHome = function(drag) { | |
379 | drag.removeClass('placed').addClass('unplaced'); | |
380 | var currentPlace = this.getClassnameNumericSuffix(drag, 'inplace'); | |
381 | if (currentPlace !== null) { | |
382 | drag.removeClass('inplace' + currentPlace); | |
383 | } | |
384 | ||
385 | this.animateTo(drag, this.getDragHome(this.getGroup(drag), this.getChoice(drag))); | |
386 | }; | |
387 | ||
388 | /** | |
389 | * Handles keyboard events on drops. | |
390 | * | |
391 | * Drops are focusable. Once focused, right/down/space switches to the next choice, and | |
392 | * left/up switches to the previous. Escape clear. | |
393 | * | |
394 | * @param {KeyboardEvent} e | |
395 | */ | |
396 | DragDropOntoImageQuestion.prototype.handleKeyPress = function(e) { | |
397 | var drop = $(e.target).closest('.dropzone'), | |
398 | currentDrag = this.getCurrentDragInPlace(this.getPlace(drop)), | |
399 | nextDrag = $(); | |
400 | ||
401 | switch (e.keyCode) { | |
402 | case keys.space: | |
403 | case keys.arrowRight: | |
404 | case keys.arrowDown: | |
405 | nextDrag = this.getNextDrag(this.getGroup(drop), currentDrag); | |
406 | break; | |
407 | ||
408 | case keys.arrowLeft: | |
409 | case keys.arrowUp: | |
410 | nextDrag = this.getPreviousDrag(this.getGroup(drop), currentDrag); | |
411 | break; | |
412 | ||
413 | case keys.escape: | |
414 | break; | |
415 | ||
416 | default: | |
417 | return; // To avoid the preventDefault below. | |
418 | } | |
419 | ||
420 | e.preventDefault(); | |
421 | this.sendDragToDrop(nextDrag, drop); | |
422 | }; | |
423 | ||
424 | /** | |
425 | * Choose the next drag in a group. | |
426 | * | |
427 | * @param {int} group which group. | |
428 | * @param {jQuery} drag current choice (empty jQuery if there isn't one). | |
429 | * @return {jQuery} the next drag in that group, or null if there wasn't one. | |
430 | */ | |
431 | DragDropOntoImageQuestion.prototype.getNextDrag = function(group, drag) { | |
432 | var choice, | |
433 | numChoices = this.noOfChoicesInGroup(group); | |
434 | ||
435 | if (drag.length === 0) { | |
436 | choice = 1; // Was empty, so we want to select the first choice. | |
437 | } else { | |
438 | choice = this.getChoice(drag) + 1; | |
439 | } | |
440 | ||
441 | var next = this.getUnplacedChoice(group, choice); | |
442 | while (next.length === 0 && choice < numChoices) { | |
443 | choice++; | |
444 | next = this.getUnplacedChoice(group, choice); | |
445 | } | |
446 | ||
447 | return next; | |
448 | }; | |
449 | ||
450 | /** | |
451 | * Choose the previous drag in a group. | |
452 | * | |
453 | * @param {int} group which group. | |
454 | * @param {jQuery} drag current choice (empty jQuery if there isn't one). | |
455 | * @return {jQuery} the next drag in that group, or null if there wasn't one. | |
456 | */ | |
457 | DragDropOntoImageQuestion.prototype.getPreviousDrag = function(group, drag) { | |
458 | var choice; | |
459 | ||
460 | if (drag.length === 0) { | |
461 | choice = this.noOfChoicesInGroup(group); | |
462 | } else { | |
463 | choice = this.getChoice(drag) - 1; | |
464 | } | |
465 | ||
466 | var previous = this.getUnplacedChoice(group, choice); | |
467 | while (previous.length === 0 && choice > 1) { | |
468 | choice--; | |
469 | previous = this.getUnplacedChoice(group, choice); | |
470 | } | |
471 | ||
472 | // Does this choice exist? | |
473 | return previous; | |
474 | }; | |
475 | ||
476 | /** | |
477 | * Animate an object to the given destination. | |
478 | * | |
479 | * @param {jQuery} drag the element to be animated. | |
480 | * @param {jQuery} target element marking the place to move it to. | |
481 | */ | |
482 | DragDropOntoImageQuestion.prototype.animateTo = function(drag, target) { | |
483 | var currentPos = drag.offset(), | |
484 | targetPos = target.offset(); | |
485 | drag.addClass('beingdragged'); | |
486 | ||
487 | // Animate works in terms of CSS position, whereas locating an object | |
488 | // on the page works best with jQuery offset() function. So, to get | |
489 | // the right target position, we work out the required change in | |
490 | // offset() and then add that to the current CSS position. | |
491 | drag.animate( | |
492 | { | |
493 | left: parseInt(drag.css('left')) + targetPos.left - currentPos.left, | |
494 | top: parseInt(drag.css('top')) + targetPos.top - currentPos.top | |
495 | }, | |
496 | { | |
497 | duration: 'fast', | |
498 | done: function() { | |
499 | drag.removeClass('beingdragged'); | |
500 | // It seems that the animation sometimes leaves the drag | |
501 | // one pixel out of position. Put it in exactly the right place. | |
502 | drag.offset(targetPos); | |
503 | } | |
504 | } | |
505 | ); | |
506 | }; | |
507 | ||
508 | /** | |
509 | * Detect if a point is inside a given DOM node. | |
510 | * | |
511 | * @param {Number} pageX the x position. | |
512 | * @param {Number} pageY the y position. | |
513 | * @param {jQuery} drop the node to check (typically a drop). | |
514 | * @return {boolean} whether the point is inside the node. | |
515 | */ | |
516 | DragDropOntoImageQuestion.prototype.isPointInDrop = function(pageX, pageY, drop) { | |
517 | var position = drop.offset(); | |
518 | return pageX >= position.left && pageX < position.left + drop.width() | |
519 | && pageY >= position.top && pageY < position.top + drop.height(); | |
520 | }; | |
521 | ||
522 | /** | |
523 | * Set the value of the hidden input for a place, to record what is currently there. | |
524 | * | |
525 | * @param {int} place which place to set the input value for. | |
526 | * @param {int} choice the value to set. | |
527 | */ | |
528 | DragDropOntoImageQuestion.prototype.setInputValue = function(place, choice) { | |
529 | this.getRoot().find('input.placeinput.place' + place).val(choice); | |
530 | }; | |
531 | ||
532 | /** | |
533 | * Get the outer div for this question. | |
534 | * | |
535 | * @returns {jQuery} containing that div. | |
536 | */ | |
537 | DragDropOntoImageQuestion.prototype.getRoot = function() { | |
538 | return $(document.getElementById(this.containerId)); | |
539 | }; | |
540 | ||
541 | /** | |
542 | * Get the img that is the background image. | |
543 | * @returns {jQuery} containing that img. | |
544 | */ | |
545 | DragDropOntoImageQuestion.prototype.bgImage = function() { | |
546 | return this.getRoot().find('img.dropbackground'); | |
547 | }; | |
548 | ||
549 | /** | |
550 | * Get drag home for a given choice. | |
551 | * | |
552 | * @param {int} group the group. | |
553 | * @param {int} choice the choice number. | |
554 | * @returns {jQuery} containing that div. | |
555 | */ | |
556 | DragDropOntoImageQuestion.prototype.getDragHome = function(group, choice) { | |
557 | return this.getRoot().find('.ddarea .draghome.group' + group + '.choice' + choice); | |
558 | }; | |
559 | ||
560 | /** | |
561 | * Get an unplaced choice for a particular group. | |
562 | * | |
563 | * @param {int} group the group. | |
564 | * @param {int} choice the choice number. | |
565 | * @returns {jQuery} jQuery wrapping the unplaced choice. If there isn't one, the jQuery will be empty. | |
566 | */ | |
567 | DragDropOntoImageQuestion.prototype.getUnplacedChoice = function(group, choice) { | |
568 | return this.getRoot().find('.ddarea .drag.group' + group + '.choice' + choice + '.unplaced').slice(0, 1); | |
569 | }; | |
570 | ||
571 | /** | |
572 | * Get the drag that is currently in a given place. | |
573 | * | |
574 | * @param {int} place the place number. | |
575 | * @return {jQuery} the current drag (or an empty jQuery if none). | |
576 | */ | |
577 | DragDropOntoImageQuestion.prototype.getCurrentDragInPlace = function(place) { | |
578 | return this.getRoot().find('.ddarea .drag.inplace' + place); | |
579 | }; | |
580 | ||
581 | /** | |
582 | * Return the number of blanks in a given group. | |
583 | * | |
584 | * @param {int} group the group number. | |
585 | * @returns {int} the number of drops. | |
586 | */ | |
587 | DragDropOntoImageQuestion.prototype.noOfDropsInGroup = function(group) { | |
588 | return this.getRoot().find('.dropzone.group' + group).length; | |
589 | }; | |
590 | ||
591 | /** | |
592 | * Return the number of choices in a given group. | |
593 | * | |
594 | * @param {int} group the group number. | |
595 | * @returns {int} the number of choices. | |
596 | */ | |
597 | DragDropOntoImageQuestion.prototype.noOfChoicesInGroup = function(group) { | |
598 | return this.getRoot().find('.dragitemgroup' + group + ' .draghome').length; | |
599 | }; | |
600 | ||
601 | /** | |
602 | * Return the number at the end of the CSS class name with the given prefix. | |
603 | * | |
604 | * @param {jQuery} node | |
605 | * @param {String} prefix name prefix | |
606 | * @returns {Number|null} the suffix if found, else null. | |
607 | */ | |
608 | DragDropOntoImageQuestion.prototype.getClassnameNumericSuffix = function(node, prefix) { | |
609 | var classes = node.attr('class'); | |
610 | if (classes !== '') { | |
611 | var classesArr = classes.split(' '); | |
612 | for (var index = 0; index < classesArr.length; index++) { | |
613 | var patt1 = new RegExp('^' + prefix + '([0-9])+$'); | |
614 | if (patt1.test(classesArr[index])) { | |
615 | var patt2 = new RegExp('([0-9])+$'); | |
616 | var match = patt2.exec(classesArr[index]); | |
617 | return Number(match[0]); | |
618 | } | |
619 | } | |
620 | } | |
621 | return null; | |
622 | }; | |
623 | ||
624 | /** | |
625 | * Get the choice number of a drag. | |
626 | * | |
627 | * @param {jQuery} drag the drag. | |
628 | * @returns {Number} the choice number. | |
629 | */ | |
630 | DragDropOntoImageQuestion.prototype.getChoice = function(drag) { | |
631 | return this.getClassnameNumericSuffix(drag, 'choice'); | |
632 | }; | |
633 | ||
634 | /** | |
635 | * Given a DOM node that is significant to this question | |
636 | * (drag, drop, ...) get the group it belongs to. | |
637 | * | |
638 | * @param {jQuery} node a DOM node. | |
639 | * @returns {Number} the group it belongs to. | |
640 | */ | |
641 | DragDropOntoImageQuestion.prototype.getGroup = function(node) { | |
642 | return this.getClassnameNumericSuffix(node, 'group'); | |
643 | }; | |
644 | ||
645 | /** | |
646 | * Get the place number of a drop, or its corresponding hidden input. | |
647 | * | |
648 | * @param {jQuery} node the DOM node. | |
649 | * @returns {Number} the place number. | |
650 | */ | |
651 | DragDropOntoImageQuestion.prototype.getPlace = function(node) { | |
652 | return this.getClassnameNumericSuffix(node, 'place'); | |
653 | }; | |
654 | ||
655 | /** | |
656 | * Singleton object that handles all the DragDropOntoImageQuestions | |
657 | * on the page, and deals with event dispatching. | |
658 | * @type {Object} | |
659 | */ | |
660 | var questionManager = { | |
661 | ||
662 | /** | |
663 | * {boolean} ensures that the event handlers are only initialised once per page. | |
664 | */ | |
665 | eventHandlersInitialised: false, | |
666 | ||
667 | /** | |
668 | * {Object} all the questions on this page, indexed by containerId (id on the .que div). | |
669 | */ | |
670 | questions: {}, // An object containing all the information about each question on the page. | |
671 | ||
672 | /** | |
673 | * Initialise one question. | |
674 | * | |
675 | * @param {String} containerId the id of the div.que that contains this question. | |
676 | * @param {boolean} readOnly whether the question is read-only. | |
677 | * @param {Array} places data. | |
678 | */ | |
679 | init: function(containerId, readOnly, places) { | |
680 | questionManager.questions[containerId] = | |
681 | new DragDropOntoImageQuestion(containerId, readOnly, places); | |
682 | if (!questionManager.eventHandlersInitialised) { | |
683 | questionManager.setupEventHandlers(); | |
684 | questionManager.eventHandlersInitialised = true; | |
685 | } | |
686 | }, | |
687 | ||
688 | /** | |
689 | * Set up the event handlers that make this question type work. (Done once per page.) | |
690 | */ | |
691 | setupEventHandlers: function() { | |
692 | $('body') | |
693 | .on('mousedown touchstart', | |
694 | '.que.ddimageortext:not(.qtype_ddimageortext-readonly) .dragitems .drag', | |
695 | questionManager.handleDragStart) | |
696 | .on('keydown', | |
697 | '.que.ddimageortext:not(.qtype_ddimageortext-readonly) .dropzones .dropzone', | |
698 | questionManager.handleKeyPress); | |
699 | $(window).on('resize', questionManager.handleWindowResize); | |
700 | }, | |
701 | ||
702 | /** | |
703 | * Handle mouse down / touch start events on drags. | |
704 | * @param {Event} e the DOM event. | |
705 | */ | |
706 | handleDragStart: function(e) { | |
707 | e.preventDefault(); | |
708 | var question = questionManager.getQuestionForEvent(e); | |
709 | if (question) { | |
710 | question.handleDragStart(e); | |
711 | } | |
712 | }, | |
713 | ||
714 | /** | |
715 | * Handle key down / press events on drags. | |
716 | * @param {KeyboardEvent} e | |
717 | */ | |
718 | handleKeyPress: function(e) { | |
719 | var question = questionManager.getQuestionForEvent(e); | |
720 | if (question) { | |
721 | question.handleKeyPress(e); | |
722 | } | |
723 | }, | |
724 | ||
725 | /** | |
726 | * Handle when the window is resized. | |
727 | */ | |
728 | handleWindowResize: function() { | |
729 | for (var containerId in questionManager.questions) { | |
730 | if (questionManager.questions.hasOwnProperty(containerId)) { | |
731 | questionManager.questions[containerId].positionDragsAndDrops(); | |
732 | } | |
733 | } | |
734 | }, | |
735 | ||
736 | /** | |
737 | * Given an event, work out which question it effects. | |
738 | * @param {Event} e the event. | |
739 | * @returns {DragDropOntoImageQuestion|undefined} The question, or undefined. | |
740 | */ | |
741 | getQuestionForEvent: function(e) { | |
742 | var containerId = $(e.currentTarget).closest('.que.ddimageortext').attr('id'); | |
743 | return questionManager.questions[containerId]; | |
744 | } | |
745 | }; | |
746 | ||
747 | /** | |
748 | * @alias module:qtype_ddimageortext/question | |
749 | */ | |
750 | return { | |
751 | /** | |
752 | * Initialise one drag-drop onto image question. | |
753 | * | |
754 | * @param {String} containerId id of the outer div for this question. | |
755 | * @param {boolean} readOnly whether the question is being displayed read-only. | |
756 | * @param {Array} Information about the drop places. | |
757 | */ | |
758 | init: questionManager.init | |
759 | }; | |
760 | }); |