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