MDL-71344 questions: Improve event handler for Drag n Drop family
[moodle.git] / question / type / ddmarker / amd / src / question.js
CommitLineData
5d4b3421
JB
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 * Question class for drag and drop marker question type, used to support the question and preview pages.
18 *
4f422785 19 * @module qtype_ddmarker/question
5d4b3421
JB
20 * @copyright 2018 The Open University
21 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22 */
23
ed7e30fa 24define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], function($, dragDrop, Shapes, keys) {
5d4b3421
JB
25
26 "use strict";
27
28 /**
ed7e30fa
TH
29 * Object to handle one drag-drop markers question.
30 *
31 * @param {String} containerId id of the outer div for this question.
ed7e30fa
TH
32 * @param {boolean} readOnly whether the question is being displayed read-only.
33 * @param {Object[]} visibleDropZones the geometry of any drop-zones to show.
34 * Objects have fields shape, coords and markertext.
35 * @constructor
5d4b3421 36 */
d2fa9e79
HN
37 function DragDropMarkersQuestion(containerId, readOnly, visibleDropZones) {
38 var thisQ = this;
ed7e30fa
TH
39 this.containerId = containerId;
40 this.visibleDropZones = visibleDropZones;
d2fa9e79
HN
41 this.shapes = [];
42 this.shapeSVGs = [];
43 this.isPrinting = false;
ed7e30fa
TH
44 if (readOnly) {
45 this.getRoot().addClass('qtype_ddmarker-readonly');
46 }
d2fa9e79
HN
47 thisQ.cloneDrags();
48 thisQ.repositionDrags();
49 thisQ.drawDropzones();
ed7e30fa 50 }
5d4b3421 51
ed7e30fa
TH
52 /**
53 * Draws the svg shapes of any drop zones that should be visible for feedback purposes.
54 */
55 DragDropMarkersQuestion.prototype.drawDropzones = function() {
d2fa9e79
HN
56 if (this.visibleDropZones.length > 0) {
57 var bgImage = this.bgImage();
58
59 this.getRoot().find('div.dropzones').html('<svg xmlns="http://www.w3.org/2000/svg" class="dropzones" ' +
60 'width="' + bgImage.outerWidth() + '" ' +
61 'height="' + bgImage.outerHeight() + '"></svg>');
62 var svg = this.getRoot().find('svg.dropzones');
63
64 var nextColourIndex = 0;
65 for (var dropZoneNo = 0; dropZoneNo < this.visibleDropZones.length; dropZoneNo++) {
66 var colourClass = 'color' + nextColourIndex;
67 nextColourIndex = (nextColourIndex + 1) % 8;
68 this.addDropzone(svg, dropZoneNo, colourClass);
69 }
ed7e30fa
TH
70 }
71 };
5d4b3421 72
ed7e30fa
TH
73 /**
74 * Adds a dropzone shape with colour, coords and link provided to the array of shapes.
75 *
76 * @param {jQuery} svg the SVG image to which to add this drop zone.
77 * @param {int} dropZoneNo which drop-zone to add.
78 * @param {string} colourClass class name
79 */
80 DragDropMarkersQuestion.prototype.addDropzone = function(svg, dropZoneNo, colourClass) {
81 var dropZone = this.visibleDropZones[dropZoneNo],
82 shape = Shapes.make(dropZone.shape, ''),
d2fa9e79
HN
83 existingmarkertext,
84 bgRatio = this.bgRatio();
85 if (!shape.parse(dropZone.coords, bgRatio)) {
ed7e30fa
TH
86 return;
87 }
5d4b3421 88
ed7e30fa
TH
89 existingmarkertext = this.getRoot().find('div.markertexts span.markertext' + dropZoneNo);
90 if (existingmarkertext.length) {
91 if (dropZone.markertext !== '') {
92 existingmarkertext.html(dropZone.markertext);
93 } else {
94 existingmarkertext.remove();
5d4b3421 95 }
ed7e30fa
TH
96 } else if (dropZone.markertext !== '') {
97 var classnames = 'markertext markertext' + dropZoneNo;
98 this.getRoot().find('div.markertexts').append('<span class="' + classnames + '">' +
99 dropZone.markertext + '</span>');
d2fa9e79
HN
100 var markerspan = this.getRoot().find('div.ddarea div.markertexts span.markertext' + dropZoneNo);
101 if (markerspan.length) {
102 var handles = shape.getHandlePositions();
103 var positionLeft = handles.moveHandle.x - (markerspan.outerWidth() / 2) - 4;
104 var positionTop = handles.moveHandle.y - (markerspan.outerHeight() / 2);
105 markerspan
106 .css('left', positionLeft)
107 .css('top', positionTop);
108 markerspan
109 .data('originX', markerspan.position().left / bgRatio)
110 .data('originY', markerspan.position().top / bgRatio);
111 this.handleElementScale(markerspan, 'center');
112 }
ed7e30fa 113 }
5d4b3421 114
ed7e30fa
TH
115 var shapeSVG = shape.makeSvg(svg[0]);
116 shapeSVG.setAttribute('class', 'dropzone ' + colourClass);
ed7e30fa 117
d2fa9e79
HN
118 this.shapes[this.shapes.length] = shape;
119 this.shapeSVGs[this.shapeSVGs.length] = shapeSVG;
ed7e30fa
TH
120 };
121
122 /**
123 * Draws the drag items on the page (and drop zones if required).
124 * The idea is to re-draw all the drags and drops whenever there is a change
125 * like a widow resize or an item dropped in place.
126 */
127 DragDropMarkersQuestion.prototype.repositionDrags = function() {
128 var root = this.getRoot(),
129 thisQ = this;
130
d2fa9e79 131 root.find('div.draghomes .marker').not('.dragplaceholder').each(function(key, item) {
ed7e30fa
TH
132 $(item).addClass('unneeded');
133 });
134
135 root.find('input.choices').each(function(key, input) {
136 var choiceNo = thisQ.getChoiceNoFromElement(input),
d2fa9e79
HN
137 coords = thisQ.getCoords(input);
138 if (coords.length) {
139 var drag = thisQ.getRoot().find('.draghomes' + ' span.marker' + '.choice' + choiceNo).not('.dragplaceholder');
140 drag.remove();
141 for (var i = 0; i < coords.length; i++) {
142 var dragInDrop = drag.clone();
143 dragInDrop.data('pagex', coords[i].x).data('pagey', coords[i].y);
144 thisQ.sendDragToDrop(dragInDrop, false);
5d4b3421 145 }
d2fa9e79
HN
146 thisQ.getDragClone(drag).addClass('active');
147 thisQ.cloneDragIfNeeded(drag);
ed7e30fa
TH
148 }
149 });
ed7e30fa 150 };
5d4b3421 151
ed7e30fa
TH
152 /**
153 * Determine what drag items need to be shown and
154 * return coords of all drag items except any that are currently being dragged
155 * based on contents of hidden inputs and whether drags are 'infinite' or how many
156 * drags should be shown.
157 *
158 * @param {jQuery} inputNode
159 * @returns {Point[]} coordinates of however many copies of the drag item should be shown.
160 */
161 DragDropMarkersQuestion.prototype.getCoords = function(inputNode) {
d2fa9e79 162 var coords = [],
ed7e30fa
TH
163 val = $(inputNode).val();
164 if (val !== '') {
165 var coordsStrings = val.split(';');
166 for (var i = 0; i < coordsStrings.length; i++) {
167 coords[i] = this.convertToWindowXY(Shapes.Point.parse(coordsStrings[i]));
168 }
169 }
ed7e30fa
TH
170 return coords;
171 };
5d4b3421 172
ed7e30fa
TH
173 /**
174 * Converts the relative x and y position coordinates into
175 * absolute x and y position coordinates.
176 *
177 * @param {Point} point relative to the background image.
178 * @returns {Point} point relative to the page.
179 */
180 DragDropMarkersQuestion.prototype.convertToWindowXY = function(point) {
181 var bgImage = this.bgImage();
182 // The +1 seems rather odd, but seems to give the best results in
183 // the three main browsers at a range of zoom levels.
184 // (Its due to the 1px border around the image, that shifts the
185 // image pixels by 1 down and to the left.)
186 return point.offset(bgImage.offset().left + 1, bgImage.offset().top + 1);
187 };
5d4b3421 188
ed7e30fa
TH
189 /**
190 * Utility function converting window coordinates to relative to the
191 * background image coordinates.
192 *
193 * @param {Point} point relative to the page.
194 * @returns {Point} point relative to the background image.
195 */
196 DragDropMarkersQuestion.prototype.convertToBgImgXY = function(point) {
197 var bgImage = this.bgImage();
198 return point.offset(-bgImage.offset().left - 1, -bgImage.offset().top - 1);
199 };
5d4b3421 200
ed7e30fa
TH
201 /**
202 * Is the point within the background image?
203 *
204 * @param {Point} point relative to the BG image.
205 * @return {boolean} true it they are.
206 */
207 DragDropMarkersQuestion.prototype.coordsInBgImg = function(point) {
208 var bgImage = this.bgImage();
d2fa9e79 209 var bgPosition = bgImage.offset();
5d4b3421 210
d2fa9e79
HN
211 return point.x >= bgPosition.left && point.x < bgPosition.left + bgImage.width()
212 && point.y >= bgPosition.top && point.y < bgPosition.top + bgImage.height();
ed7e30fa 213 };
5d4b3421 214
ed7e30fa
TH
215 /**
216 * Get the outer div for this question.
217 * @returns {jQuery} containing that div.
218 */
219 DragDropMarkersQuestion.prototype.getRoot = function() {
220 return $(document.getElementById(this.containerId));
221 };
5d4b3421 222
ed7e30fa
TH
223 /**
224 * Get the img that is the background image.
225 * @returns {jQuery} containing that img.
226 */
227 DragDropMarkersQuestion.prototype.bgImage = function() {
228 return this.getRoot().find('img.dropbackground');
229 };
230
ed7e30fa
TH
231 DragDropMarkersQuestion.prototype.handleDragStart = function(e) {
232 var thisQ = this,
d2fa9e79 233 dragged = $(e.target).closest('.marker');
ed7e30fa
TH
234
235 var info = dragDrop.prepare(e);
236 if (!info.start) {
237 return;
238 }
239
d2fa9e79
HN
240 dragged.addClass('beingdragged').css('transform', '');
241
242 var placed = !dragged.hasClass('unneeded');
243 if (!placed) {
244 var hiddenDrag = thisQ.getDragClone(dragged);
245 if (hiddenDrag.length) {
246 hiddenDrag.addClass('active');
247 dragged.offset(hiddenDrag.offset());
248 }
249 }
250
ed7e30fa 251 dragDrop.start(e, dragged, function() {
d2fa9e79 252 void (1);
ed7e30fa
TH
253 }, function(x, y, dragged) {
254 thisQ.dragEnd(dragged);
255 });
256 };
257
258 /**
259 * Functionality at the end of a drag drop.
260 * @param {jQuery} dragged the marker that was dragged.
261 */
262 DragDropMarkersQuestion.prototype.dragEnd = function(dragged) {
d2fa9e79
HN
263 var placed = false,
264 choiceNo = this.getChoiceNoFromElement(dragged),
265 bgRatio = this.bgRatio(),
266 dragXY;
267
268 dragged.data('pagex', dragged.offset().left).data('pagey', dragged.offset().top);
269 dragXY = new Shapes.Point(dragged.data('pagex'), dragged.data('pagey'));
270 if (this.coordsInBgImg(dragXY)) {
271 this.sendDragToDrop(dragged, true);
272 placed = true;
273
274 // It seems that the dragdrop sometimes leaves the drag
275 // one pixel out of position. Put it in exactly the right place.
276 var bgImgXY = this.convertToBgImgXY(dragXY);
277 bgImgXY = new Shapes.Point(bgImgXY.x / bgRatio, bgImgXY.y / bgRatio);
278 dragged.data('originX', bgImgXY.x).data('originY', bgImgXY.y);
279 }
280
281 if (!placed) {
282 this.sendDragHome(dragged);
283 this.removeDragIfNeeded(dragged);
284 } else {
285 this.cloneDragIfNeeded(dragged);
286 }
287
288 this.saveCoordsForChoice(choiceNo);
ed7e30fa
TH
289 };
290
291 /**
292 * Save the coordinates for a dropped item in the form field.
293 * @param {Number} choiceNo which copy of the choice this was.
ed7e30fa 294 */
d2fa9e79 295 DragDropMarkersQuestion.prototype.saveCoordsForChoice = function(choiceNo) {
ed7e30fa 296 var coords = [],
d2fa9e79
HN
297 items = this.getRoot().find('div.droparea span.marker.choice' + choiceNo),
298 thiQ = this,
299 bgRatio = this.bgRatio();
300
301 if (items.length) {
302 items.each(function() {
303 var drag = $(this);
304 if (!drag.hasClass('beingdragged')) {
90798144
HN
305 if (drag.data('scaleRatio') !== bgRatio) {
306 // The scale ratio for the draggable item was changed. We need to update that.
307 drag.data('pagex', drag.offset().left).data('pagey', drag.offset().top);
308 }
d2fa9e79
HN
309 var dragXY = new Shapes.Point(drag.data('pagex'), drag.data('pagey'));
310 if (thiQ.coordsInBgImg(dragXY)) {
311 var bgImgXY = thiQ.convertToBgImgXY(dragXY);
312 bgImgXY = new Shapes.Point(bgImgXY.x / bgRatio, bgImgXY.y / bgRatio);
313 coords[coords.length] = bgImgXY;
314 }
5d4b3421 315 }
d2fa9e79 316 });
ed7e30fa
TH
317 }
318
319 this.getRoot().find('input.choice' + choiceNo).val(coords.join(';'));
320 };
321
322 /**
323 * Handle key down / press events on markers.
324 * @param {KeyboardEvent} e
325 */
326 DragDropMarkersQuestion.prototype.handleKeyPress = function(e) {
d2fa9e79 327 var drag = $(e.target).closest('.marker'),
ed7e30fa
TH
328 point = new Shapes.Point(drag.offset().left, drag.offset().top),
329 choiceNo = this.getChoiceNoFromElement(drag);
330
331 switch (e.keyCode) {
332 case keys.arrowLeft:
333 case 65: // A.
334 point.x -= 1;
335 break;
336 case keys.arrowRight:
337 case 68: // D.
338 point.x += 1;
339 break;
340 case keys.arrowDown:
341 case 83: // S.
342 point.y += 1;
343 break;
344 case keys.arrowUp:
345 case 87: // W.
346 point.y -= 1;
347 break;
348 case keys.space:
349 case keys.escape:
350 point = null;
351 break;
352 default:
353 return; // Ingore other keys.
354 }
355 e.preventDefault();
356
357 if (point !== null) {
358 point = this.constrainToBgImg(point);
d2fa9e79
HN
359 drag.offset({'left': point.x, 'top': point.y});
360 drag.data('pagex', drag.offset().left).data('pagey', drag.offset().top);
361 var dragXY = this.convertToBgImgXY(new Shapes.Point(drag.data('pagex'), drag.data('pagey')));
362 drag.data('originX', dragXY.x / this.bgRatio()).data('originY', dragXY.y / this.bgRatio());
363 if (this.coordsInBgImg(new Shapes.Point(drag.offset().left, drag.offset().top))) {
364 if (drag.hasClass('unneeded')) {
365 this.sendDragToDrop(drag, true);
366 var hiddenDrag = this.getDragClone(drag);
367 if (hiddenDrag.length) {
368 hiddenDrag.addClass('active');
369 }
370 this.cloneDragIfNeeded(drag);
371 }
372 }
ed7e30fa 373 } else {
d2fa9e79
HN
374 drag.css('left', '').css('top', '');
375 drag.data('pagex', drag.offset().left).data('pagey', drag.offset().top);
376 this.sendDragHome(drag);
377 this.removeDragIfNeeded(drag);
ed7e30fa 378 }
d2fa9e79
HN
379 drag.focus();
380 this.saveCoordsForChoice(choiceNo);
ed7e30fa
TH
381 };
382
383 /**
384 * Makes sure the dragged item always exists within the background image area.
385 *
386 * @param {Point} windowxy
387 * @returns {Point} coordinates
388 */
389 DragDropMarkersQuestion.prototype.constrainToBgImg = function(windowxy) {
390 var bgImg = this.bgImage(),
391 bgImgXY = this.convertToBgImgXY(windowxy);
392 bgImgXY.x = Math.max(0, bgImgXY.x);
393 bgImgXY.y = Math.max(0, bgImgXY.y);
394 bgImgXY.x = Math.min(bgImg.width(), bgImgXY.x);
395 bgImgXY.y = Math.min(bgImg.height(), bgImgXY.y);
396 return this.convertToWindowXY(bgImgXY);
397 };
398
399 /**
400 * Returns the choice number for a node.
401 *
402 * @param {Element|jQuery} node
403 * @returns {Number}
404 */
405 DragDropMarkersQuestion.prototype.getChoiceNoFromElement = function(node) {
406 return Number(this.getClassnameNumericSuffix(node, 'choice'));
407 };
408
409 /**
410 * Returns the numeric part of a class with the given prefix.
411 *
412 * @param {Element|jQuery} node
413 * @param {String} prefix
414 * @returns {Number|null}
415 */
416 DragDropMarkersQuestion.prototype.getClassnameNumericSuffix = function(node, prefix) {
417 var classes = $(node).attr('class');
418 if (classes !== undefined && classes !== '') {
419 var classesarr = classes.split(' ');
420 for (var index = 0; index < classesarr.length; index++) {
421 var patt1 = new RegExp('^' + prefix + '([0-9])+$');
422 if (patt1.test(classesarr[index])) {
423 var patt2 = new RegExp('([0-9])+$');
424 var match = patt2.exec(classesarr[index]);
425 return Number(match[0]);
426 }
5d4b3421 427 }
ed7e30fa
TH
428 }
429 return null;
430 };
5d4b3421 431
ed7e30fa
TH
432 /**
433 * Handle when the window is resized.
434 */
435 DragDropMarkersQuestion.prototype.handleResize = function() {
d2fa9e79
HN
436 var thisQ = this,
437 bgRatio = this.bgRatio();
438 if (this.isPrinting) {
439 bgRatio = 1;
440 }
441
442 this.getRoot().find('div.droparea .marker').not('.beingdragged').each(function(key, drag) {
443 $(drag)
444 .css('left', parseFloat($(drag).data('originX')) * parseFloat(bgRatio))
445 .css('top', parseFloat($(drag).data('originY')) * parseFloat(bgRatio));
446 thisQ.handleElementScale(drag, 'left top');
447 });
448
449 this.getRoot().find('div.droparea svg.dropzones')
450 .width(this.bgImage().width())
451 .height(this.bgImage().height());
452
453 for (var dropZoneNo = 0; dropZoneNo < this.visibleDropZones.length; dropZoneNo++) {
454 var dropZone = thisQ.visibleDropZones[dropZoneNo];
455 var originCoords = dropZone.coords;
456 var shape = thisQ.shapes[dropZoneNo];
457 var shapeSVG = thisQ.shapeSVGs[dropZoneNo];
458 shape.parse(originCoords, bgRatio);
459 shape.updateSvg(shapeSVG);
460
461 var handles = shape.getHandlePositions();
462 var markerSpan = this.getRoot().find('div.ddarea div.markertexts span.markertext' + dropZoneNo);
463 markerSpan
464 .css('left', handles.moveHandle.x - (markerSpan.outerWidth() / 2) - 4)
465 .css('top', handles.moveHandle.y - (markerSpan.outerHeight() / 2));
466 thisQ.handleElementScale(markerSpan, 'center');
467 }
ed7e30fa 468 };
5d4b3421 469
e5153d93 470 /**
d2fa9e79 471 * Clone the drag.
e5153d93 472 */
d2fa9e79
HN
473 DragDropMarkersQuestion.prototype.cloneDrags = function() {
474 var thisQ = this;
475 this.getRoot().find('div.draghomes span.marker').each(function(index, draghome) {
476 var drag = $(draghome);
477 var placeHolder = drag.clone();
478 placeHolder.removeClass();
d07c7ae3
HN
479 placeHolder.addClass('marker');
480 placeHolder.addClass('choice' + thisQ.getChoiceNoFromElement(drag));
481 placeHolder.addClass(thisQ.getDragNoClass(drag, false));
482 placeHolder.addClass('dragplaceholder');
d2fa9e79
HN
483 drag.before(placeHolder);
484 });
485 };
486
487 /**
488 * Get the drag number of a drag.
489 *
490 * @param {jQuery} drag the drag.
491 * @returns {Number} the drag number.
492 */
493 DragDropMarkersQuestion.prototype.getDragNo = function(drag) {
494 return this.getClassnameNumericSuffix(drag, 'dragno');
495 };
496
d07c7ae3
HN
497 /**
498 * Get the drag number prefix of a drag.
499 *
500 * @param {jQuery} drag the drag.
501 * @param {Boolean} includeSelector include the CSS selector prefix or not.
502 * @return {String} Class name
503 */
504 DragDropMarkersQuestion.prototype.getDragNoClass = function(drag, includeSelector) {
505 var className = 'dragno' + this.getDragNo(drag);
506 if (this.isInfiniteDrag(drag)) {
507 className = 'infinite';
508 }
509
510 if (includeSelector) {
511 return '.' + className;
512 }
513
514 return className;
515 };
516
d2fa9e79
HN
517 /**
518 * Get drag clone for a given drag.
519 *
520 * @param {jQuery} drag the drag.
521 * @returns {jQuery} the drag's clone.
522 */
523 DragDropMarkersQuestion.prototype.getDragClone = function(drag) {
524 return this.getRoot().find('.draghomes' + ' span.marker' +
d07c7ae3 525 '.choice' + this.getChoiceNoFromElement(drag) + this.getDragNoClass(drag, true) + '.dragplaceholder');
d2fa9e79
HN
526 };
527
528 /**
529 * Get the drop area element.
530 * @returns {jQuery} droparea element.
531 */
532 DragDropMarkersQuestion.prototype.dropArea = function() {
533 return this.getRoot().find('div.droparea');
534 };
535
536 /**
537 * Animate a drag back to its home.
538 *
539 * @param {jQuery} drag the item being moved.
540 */
541 DragDropMarkersQuestion.prototype.sendDragHome = function(drag) {
542 drag.removeClass('beingdragged')
543 .addClass('unneeded')
544 .css('top', '')
545 .css('left', '')
546 .css('transform', '');
547 var placeHolder = this.getDragClone(drag);
548 placeHolder.after(drag);
549 placeHolder.removeClass('active');
550 };
551
552 /**
553 * Animate a drag item into a given place.
554 *
555 * @param {jQuery} drag the item to place.
556 * @param {boolean} isScaling Scaling or not
557 */
558 DragDropMarkersQuestion.prototype.sendDragToDrop = function(drag, isScaling) {
559 var dropArea = this.dropArea(),
560 bgRatio = this.bgRatio();
561 drag.removeClass('beingdragged').removeClass('unneeded');
562 var dragXY = this.convertToBgImgXY(new Shapes.Point(drag.data('pagex'), drag.data('pagey')));
563 if (isScaling) {
564 drag.data('originX', dragXY.x / bgRatio).data('originY', dragXY.y / bgRatio);
565 drag.css('left', dragXY.x).css('top', dragXY.y);
566 } else {
567 drag.data('originX', dragXY.x).data('originY', dragXY.y);
568 drag.css('left', dragXY.x * bgRatio).css('top', dragXY.y * bgRatio);
e5153d93 569 }
90798144
HN
570 // We need to save the original scale ratio for each draggable item.
571 drag.data('scaleRatio', bgRatio);
d2fa9e79
HN
572 dropArea.append(drag);
573 this.handleElementScale(drag, 'left top');
574 };
575
576 /**
577 * Clone the drag at the draghome area if needed.
578 *
579 * @param {jQuery} drag the item to place.
580 */
581 DragDropMarkersQuestion.prototype.cloneDragIfNeeded = function(drag) {
582 var inputNode = this.getInput(drag),
583 noOfDrags = Number(this.getClassnameNumericSuffix(inputNode, 'noofdrags')),
584 displayedDragsInDropArea = this.getRoot().find('div.droparea .marker.choice' +
d07c7ae3 585 this.getChoiceNoFromElement(drag) + this.getDragNoClass(drag, true)).length,
d2fa9e79 586 displayedDragsInDragHomes = this.getRoot().find('div.draghomes .marker.choice' +
d07c7ae3 587 this.getChoiceNoFromElement(drag) + this.getDragNoClass(drag, true)).not('.dragplaceholder').length;
d2fa9e79 588
d07c7ae3
HN
589 if ((this.isInfiniteDrag(drag) ||
590 !this.isInfiniteDrag(drag) && displayedDragsInDropArea < noOfDrags) && displayedDragsInDragHomes === 0) {
591 var dragClone = drag.clone();
592 dragClone.addClass('unneeded')
d2fa9e79
HN
593 .css('top', '')
594 .css('left', '')
595 .css('transform', '');
596 this.getDragClone(drag)
597 .removeClass('active')
d07c7ae3
HN
598 .after(dragClone);
599 questionManager.addEventHandlersToMarker(dragClone);
d2fa9e79
HN
600 }
601 };
602
603 /**
604 * Remove the clone drag at the draghome area if needed.
605 *
606 * @param {jQuery} drag the item to place.
607 */
608 DragDropMarkersQuestion.prototype.removeDragIfNeeded = function(drag) {
d07c7ae3
HN
609 var dragsInHome = this.getRoot().find('div.draghomes .marker.choice' +
610 this.getChoiceNoFromElement(drag) + this.getDragNoClass(drag, true)).not('.dragplaceholder');
611 var displayedDrags = dragsInHome.length;
612 while (displayedDrags > 1) {
613 dragsInHome.first().remove();
614 displayedDrags--;
e5153d93 615 }
d2fa9e79
HN
616 };
617
618 /**
619 * Get the input belong to drag.
620 *
621 * @param {jQuery} drag the item to place.
622 * @returns {jQuery} input element.
623 */
624 DragDropMarkersQuestion.prototype.getInput = function(drag) {
625 var choiceNo = this.getChoiceNoFromElement(drag);
626 return this.getRoot().find('input.choices.choice' + choiceNo);
627 };
628
629 /**
630 * Return the background ratio.
631 *
632 * @returns {number} Background ratio.
633 */
634 DragDropMarkersQuestion.prototype.bgRatio = function() {
635 var bgImg = this.bgImage();
636 var bgImgNaturalWidth = bgImg.get(0).naturalWidth;
637 var bgImgClientWidth = bgImg.width();
638
639 return bgImgClientWidth / bgImgNaturalWidth;
640 };
641
642 /**
643 * Scale the drag if needed.
644 *
645 * @param {jQuery} element the item to place.
646 * @param {String} type scaling type
647 */
648 DragDropMarkersQuestion.prototype.handleElementScale = function(element, type) {
649 var bgRatio = parseFloat(this.bgRatio());
650 if (this.isPrinting) {
651 bgRatio = 1;
652 }
653 $(element).css({
654 '-webkit-transform': 'scale(' + bgRatio + ')',
655 '-moz-transform': 'scale(' + bgRatio + ')',
656 '-ms-transform': 'scale(' + bgRatio + ')',
657 '-o-transform': 'scale(' + bgRatio + ')',
658 'transform': 'scale(' + bgRatio + ')',
659 'transform-origin': type
660 });
e5153d93
TH
661 };
662
d07c7ae3
HN
663 /**
664 * Check if the given drag is in infinite mode or not.
665 *
666 * @param {jQuery} drag The drag item need to check.
667 */
668 DragDropMarkersQuestion.prototype.isInfiniteDrag = function(drag) {
669 return drag.hasClass('infinite');
670 };
671
ed7e30fa
TH
672 /**
673 * Singleton that tracks all the DragDropToTextQuestions on this page, and deals
674 * with event dispatching.
675 *
676 * @type {Object}
677 */
678 var questionManager = {
5d4b3421
JB
679
680 /**
ed7e30fa 681 * {boolean} ensures that the event handlers are only initialised once per page.
5d4b3421 682 */
ed7e30fa 683 eventHandlersInitialised: false,
5d4b3421 684
b39f9824
HN
685 /**
686 * {Object} ensures that the marker event handlers are only initialised once per question,
687 * indexed by containerId (id on the .que div).
688 */
689 markerEventHandlersInitialised: {},
690
d2fa9e79
HN
691 /**
692 * {boolean} is printing or not.
693 */
694 isPrinting: false,
695
696 /**
697 * {boolean} is keyboard navigation.
698 */
699 isKeyboardNavigation: false,
700
5d4b3421 701 /**
ed7e30fa 702 * {Object} all the questions on this page, indexed by containerId (id on the .que div).
5d4b3421 703 */
ed7e30fa 704 questions: {}, // An object containing all the information about each question on the page.
5d4b3421
JB
705
706 /**
ed7e30fa
TH
707 * Initialise one question.
708 *
709 * @param {String} containerId the id of the div.que that contains this question.
ed7e30fa
TH
710 * @param {boolean} readOnly whether the question is read-only.
711 * @param {Object[]} visibleDropZones data on any drop zones to draw as part of the feedback.
5d4b3421 712 */
d2fa9e79 713 init: function(containerId, readOnly, visibleDropZones) {
ed7e30fa 714 questionManager.questions[containerId] =
d2fa9e79 715 new DragDropMarkersQuestion(containerId, readOnly, visibleDropZones);
ed7e30fa
TH
716 if (!questionManager.eventHandlersInitialised) {
717 questionManager.setupEventHandlers();
718 questionManager.eventHandlersInitialised = true;
5d4b3421 719 }
b39f9824
HN
720 if (!questionManager.markerEventHandlersInitialised.hasOwnProperty(containerId)) {
721 questionManager.markerEventHandlersInitialised[containerId] = true;
722 // We do not use the body event here to prevent the other event on Mobile device, such as scroll event.
723 var questionContainer = document.getElementById(containerId);
724 if (questionContainer.classList.contains('ddmarker') &&
725 !questionContainer.classList.contains('qtype_ddmarker-readonly')) {
726 // TODO: Convert all the jQuery selectors and events to native Javascript.
727 questionManager.addEventHandlersToMarker($(questionContainer).find('div.draghomes .marker'));
728 questionManager.addEventHandlersToMarker($(questionContainer).find('div.droparea .marker'));
729 }
730 }
5d4b3421
JB
731 },
732
733 /**
ed7e30fa 734 * Set up the event handlers that make this question type work. (Done once per page.)
5d4b3421 735 */
ed7e30fa 736 setupEventHandlers: function() {
d2fa9e79
HN
737 $(window).on('resize', function() {
738 questionManager.handleWindowResize(false);
739 });
740 window.addEventListener('beforeprint', function() {
741 questionManager.isPrinting = true;
742 questionManager.handleWindowResize(questionManager.isPrinting);
743 });
744 window.addEventListener('afterprint', function() {
745 questionManager.isPrinting = false;
746 questionManager.handleWindowResize(questionManager.isPrinting);
747 });
748 setTimeout(function() {
749 questionManager.fixLayoutIfThingsMoved();
750 }, 100);
5d4b3421
JB
751 },
752
5fa8f1c8
HN
753 /**
754 * Binding the event again for newly created element.
755 *
756 * @param {jQuery} element Element to bind the event
757 */
758 addEventHandlersToMarker: function(element) {
759 element
760 .on('mousedown touchstart', questionManager.handleDragStart)
761 .on('keydown keypress', questionManager.handleKeyPress)
762 .focusin(function(e) {
763 questionManager.handleKeyboardFocus(e, true);
764 })
765 .focusout(function(e) {
766 questionManager.handleKeyboardFocus(e, false);
767 });
768 },
769
5d4b3421 770 /**
ed7e30fa
TH
771 * Handle mouse down / touch start events on markers.
772 * @param {Event} e the DOM event.
5d4b3421 773 */
ed7e30fa
TH
774 handleDragStart: function(e) {
775 e.preventDefault();
776 var question = questionManager.getQuestionForEvent(e);
777 if (question) {
778 question.handleDragStart(e);
5d4b3421 779 }
5d4b3421
JB
780 },
781
782 /**
ed7e30fa
TH
783 * Handle key down / press events on markers.
784 * @param {Event} e
5d4b3421 785 */
ed7e30fa
TH
786 handleKeyPress: function(e) {
787 var question = questionManager.getQuestionForEvent(e);
788 if (question) {
789 question.handleKeyPress(e);
5d4b3421 790 }
5d4b3421
JB
791 },
792
793 /**
ed7e30fa 794 * Handle when the window is resized.
d2fa9e79 795 * @param {boolean} isPrinting
5d4b3421 796 */
d2fa9e79 797 handleWindowResize: function(isPrinting) {
ed7e30fa
TH
798 for (var containerId in questionManager.questions) {
799 if (questionManager.questions.hasOwnProperty(containerId)) {
d2fa9e79 800 questionManager.questions[containerId].isPrinting = isPrinting;
ed7e30fa 801 questionManager.questions[containerId].handleResize();
5d4b3421
JB
802 }
803 }
5d4b3421
JB
804 },
805
d2fa9e79
HN
806 /**
807 * Handle focus lost events on markers.
808 * @param {Event} e
809 * @param {boolean} isNavigating
810 */
811 handleKeyboardFocus: function(e, isNavigating) {
812 questionManager.isKeyboardNavigation = isNavigating;
813 },
814
e5153d93
TH
815 /**
816 * Sometimes, despite our best efforts, things change in a way that cannot
817 * be specifically caught (e.g. dock expanding or collapsing in Boost).
818 * Therefore, we need to periodically check everything is in the right position.
819 */
820 fixLayoutIfThingsMoved: function() {
d2fa9e79
HN
821 if (!questionManager.isKeyboardNavigation) {
822 this.handleWindowResize(questionManager.isPrinting);
e5153d93 823 }
e5153d93
TH
824 // We use setTimeout after finishing work, rather than setInterval,
825 // in case positioning things is slow. We want 100 ms gap
826 // between executions, not what setInterval does.
d2fa9e79
HN
827 setTimeout(function() {
828 questionManager.fixLayoutIfThingsMoved(questionManager.isPrinting);
829 }, 100);
e5153d93
TH
830 },
831
5d4b3421 832 /**
ed7e30fa
TH
833 * Given an event, work out which question it effects.
834 * @param {Event} e the event.
835 * @returns {DragDropMarkersQuestion|undefined} The question, or undefined.
5d4b3421 836 */
ed7e30fa
TH
837 getQuestionForEvent: function(e) {
838 var containerId = $(e.currentTarget).closest('.que.ddmarker').attr('id');
839 return questionManager.questions[containerId];
5d4b3421
JB
840 }
841 };
842
ed7e30fa
TH
843 /**
844 * @alias module:qtype_ddmarker/question
845 */
846 return {
847 /**
848 * Initialise one drag-drop markers question.
849 *
850 * @param {String} containerId id of the outer div for this question.
851 * @param {String} bgImgUrl the URL of the background image.
852 * @param {boolean} readOnly whether the question is being displayed read-only.
853 * @param {String[]} visibleDropZones the geometry of any drop-zones to show.
854 */
855 init: questionManager.init
856 };
5d4b3421 857});