7dfa8ffdb57bfb02d13dc20f2c5aea15c322b303
[moodle.git] / question / type / ddmarker / amd / src / question.js
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/>.
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  */
25 define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], function($, dragDrop, Shapes, keys) {
27     "use strict";
29     /**
30      * Object to handle one drag-drop markers question.
31      *
32      * @param {String} containerId id of the outer div for this question.
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
37      */
38     function DragDropMarkersQuestion(containerId, readOnly, visibleDropZones) {
39         var thisQ = this;
40         this.containerId = containerId;
41         this.visibleDropZones = visibleDropZones;
42         this.shapes = [];
43         this.shapeSVGs = [];
44         this.isPrinting = false;
45         if (readOnly) {
46             this.getRoot().addClass('qtype_ddmarker-readonly');
47         }
48         thisQ.cloneDrags();
49         thisQ.repositionDrags();
50         thisQ.drawDropzones();
51     }
53     /**
54      * Draws the svg shapes of any drop zones that should be visible for feedback purposes.
55      */
56     DragDropMarkersQuestion.prototype.drawDropzones = function() {
57         if (this.visibleDropZones.length > 0) {
58             var bgImage = this.bgImage();
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');
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             }
71         }
72     };
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, ''),
84             existingmarkertext,
85             bgRatio = this.bgRatio();
86         if (!shape.parse(dropZone.coords, bgRatio)) {
87             return;
88         }
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();
96             }
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>');
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             }
114         }
116         var shapeSVG = shape.makeSvg(svg[0]);
117         shapeSVG.setAttribute('class', 'dropzone ' + colourClass);
119         this.shapes[this.shapes.length] = shape;
120         this.shapeSVGs[this.shapeSVGs.length] = shapeSVG;
121     };
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;
132         root.find('div.draghomes .marker').not('.dragplaceholder').each(function(key, item) {
133             $(item).addClass('unneeded');
134         });
136         root.find('input.choices').each(function(key, input) {
137             var choiceNo = thisQ.getChoiceNoFromElement(input),
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);
146                 }
147                 thisQ.getDragClone(drag).addClass('active');
148                 thisQ.cloneDragIfNeeded(drag);
149             }
150         });
151     };
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) {
163         var coords = [],
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         }
171         return coords;
172     };
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     };
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     };
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();
210         var bgPosition = bgImage.offset();
212         return point.x >= bgPosition.left && point.x < bgPosition.left + bgImage.width()
213             && point.y >= bgPosition.top && point.y < bgPosition.top + bgImage.height();
214     };
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     };
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     };
232     DragDropMarkersQuestion.prototype.handleDragStart = function(e) {
233         var thisQ = this,
234             dragged = $(e.target).closest('.marker');
236         var info = dragDrop.prepare(e);
237         if (!info.start) {
238             return;
239         }
241         dragged.addClass('beingdragged').css('transform', '');
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         }
252         dragDrop.start(e, dragged, function() {
253             void (1);
254         }, function(x, y, dragged) {
255             thisQ.dragEnd(dragged);
256         });
257     };
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) {
264         var placed = false,
265             choiceNo = this.getChoiceNoFromElement(dragged),
266             bgRatio = this.bgRatio(),
267             dragXY;
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;
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         }
282         if (!placed) {
283             this.sendDragHome(dragged);
284             this.removeDragIfNeeded(dragged);
285         } else {
286             this.cloneDragIfNeeded(dragged);
287         }
289         this.saveCoordsForChoice(choiceNo);
290     };
292     /**
293      * Save the coordinates for a dropped item in the form field.
294      * @param {Number} choiceNo which copy of the choice this was.
295      */
296     DragDropMarkersQuestion.prototype.saveCoordsForChoice = function(choiceNo) {
297         var coords = [],
298             items = this.getRoot().find('div.droparea span.marker.choice' + choiceNo),
299             thiQ = this,
300             bgRatio = this.bgRatio();
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                     }
312                 }
313             });
314         }
316         this.getRoot().find('input.choice' + choiceNo).val(coords.join(';'));
317     };
319     /**
320      * Handle key down / press events on markers.
321      * @param {KeyboardEvent} e
322      */
323     DragDropMarkersQuestion.prototype.handleKeyPress = function(e) {
324         var drag = $(e.target).closest('.marker'),
325             point = new Shapes.Point(drag.offset().left, drag.offset().top),
326             choiceNo = this.getChoiceNoFromElement(drag);
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();
354         if (point !== null) {
355             point = this.constrainToBgImg(point);
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             }
370         } else {
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);
375         }
376         drag.focus();
377         this.saveCoordsForChoice(choiceNo);
378     };
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     };
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     };
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                 }
424             }
425         }
426         return null;
427     };
429     /**
430      * Handle when the window is resized.
431      */
432     DragDropMarkersQuestion.prototype.handleResize = function() {
433         var thisQ = this,
434             bgRatio = this.bgRatio();
435         if (this.isPrinting) {
436             bgRatio = 1;
437         }
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         });
446         this.getRoot().find('div.droparea svg.dropzones')
447             .width(this.bgImage().width())
448             .height(this.bgImage().height());
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);
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         }
465     };
467     /**
468      * Clone the drag.
469      */
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     };
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     };
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     };
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     };
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     };
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);
544         }
545         dropArea.append(drag);
546         this.handleElementScale(drag, 'left top');
547     };
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;
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);
571         }
572     };
574     /**
575      * Remove the clone drag at the draghome area if needed.
576      *
577      * @param {jQuery} drag the item to place.
578      */
579     DragDropMarkersQuestion.prototype.removeDragIfNeeded = function(drag) {
580         var displayeddrags = this.getRoot().find('div.draghomes .marker.choice' +
581             this.getChoiceNoFromElement(drag) + '.dragno' + this.getDragNo(drag)).not('.dragplaceholder').length;
582         if (displayeddrags > 1) {
583             this.getRoot().find('div.draghomes .marker.choice' +
584                 this.getChoiceNoFromElement(drag) + '.dragno' + this.getDragNo(drag)).not('.dragplaceholder').first().remove();
585         }
586     };
588     /**
589      * Get the input belong to drag.
590      *
591      * @param {jQuery} drag the item to place.
592      * @returns {jQuery} input element.
593      */
594     DragDropMarkersQuestion.prototype.getInput = function(drag) {
595         var choiceNo = this.getChoiceNoFromElement(drag);
596         return this.getRoot().find('input.choices.choice' + choiceNo);
597     };
599     /**
600      * Return the background ratio.
601      *
602      * @returns {number} Background ratio.
603      */
604     DragDropMarkersQuestion.prototype.bgRatio = function() {
605         var bgImg = this.bgImage();
606         var bgImgNaturalWidth = bgImg.get(0).naturalWidth;
607         var bgImgClientWidth = bgImg.width();
609         return bgImgClientWidth / bgImgNaturalWidth;
610     };
612     /**
613      * Scale the drag if needed.
614      *
615      * @param {jQuery} element the item to place.
616      * @param {String} type scaling type
617      */
618     DragDropMarkersQuestion.prototype.handleElementScale = function(element, type) {
619         var bgRatio = parseFloat(this.bgRatio());
620         if (this.isPrinting) {
621             bgRatio = 1;
622         }
623         $(element).css({
624             '-webkit-transform': 'scale(' + bgRatio + ')',
625             '-moz-transform': 'scale(' + bgRatio + ')',
626             '-ms-transform': 'scale(' + bgRatio + ')',
627             '-o-transform': 'scale(' + bgRatio + ')',
628             'transform': 'scale(' + bgRatio + ')',
629             'transform-origin': type
630         });
631     };
633     /**
634      * Singleton that tracks all the DragDropToTextQuestions on this page, and deals
635      * with event dispatching.
636      *
637      * @type {Object}
638      */
639     var questionManager = {
641         /**
642          * {boolean} ensures that the event handlers are only initialised once per page.
643          */
644         eventHandlersInitialised: false,
646         /**
647          * {boolean} is printing or not.
648          */
649         isPrinting: false,
651         /**
652          * {boolean} is keyboard navigation.
653          */
654         isKeyboardNavigation: false,
656         /**
657          * {Object} all the questions on this page, indexed by containerId (id on the .que div).
658          */
659         questions: {}, // An object containing all the information about each question on the page.
661         /**
662          * Initialise one question.
663          *
664          * @param {String} containerId the id of the div.que that contains this question.
665          * @param {boolean} readOnly whether the question is read-only.
666          * @param {Object[]} visibleDropZones data on any drop zones to draw as part of the feedback.
667          */
668         init: function(containerId, readOnly, visibleDropZones) {
669             questionManager.questions[containerId] =
670                 new DragDropMarkersQuestion(containerId, readOnly, visibleDropZones);
671             if (!questionManager.eventHandlersInitialised) {
672                 questionManager.setupEventHandlers();
673                 questionManager.eventHandlersInitialised = true;
674             }
675         },
677         /**
678          * Set up the event handlers that make this question type work. (Done once per page.)
679          */
680         setupEventHandlers: function() {
681             $('body')
682                 .on('mousedown touchstart',
683                     '.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker', questionManager.handleDragStart)
684                 .on('mousedown touchstart',
685                     '.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker', questionManager.handleDragStart)
686                 .on('keydown keypress',
687                     '.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker', questionManager.handleKeyPress)
688                 .on('keydown keypress',
689                     '.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker', questionManager.handleKeyPress)
690                 .on('focusin',
691                     '.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker', function(e) {
692                         questionManager.handleKeyboardFocus(e, true);
693                     })
694                 .on('focusin',
695                     '.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker', function(e) {
696                         questionManager.handleKeyboardFocus(e, true);
697                     })
698                 .on('focusout',
699                     '.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker', function(e) {
700                         questionManager.handleKeyboardFocus(e, false);
701                     })
702                 .on('focusout',
703                     '.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker', function(e) {
704                         questionManager.handleKeyboardFocus(e, false);
705                     });
706             $(window).on('resize', function() {
707                 questionManager.handleWindowResize(false);
708             });
709             window.addEventListener('beforeprint', function() {
710                 questionManager.isPrinting = true;
711                 questionManager.handleWindowResize(questionManager.isPrinting);
712             });
713             window.addEventListener('afterprint', function() {
714                 questionManager.isPrinting = false;
715                 questionManager.handleWindowResize(questionManager.isPrinting);
716             });
717             setTimeout(function() {
718                 questionManager.fixLayoutIfThingsMoved();
719             }, 100);
720         },
722         /**
723          * Handle mouse down / touch start events on markers.
724          * @param {Event} e the DOM event.
725          */
726         handleDragStart: function(e) {
727             e.preventDefault();
728             var question = questionManager.getQuestionForEvent(e);
729             if (question) {
730                 question.handleDragStart(e);
731             }
732         },
734         /**
735          * Handle key down / press events on markers.
736          * @param {Event} e
737          */
738         handleKeyPress: function(e) {
739             var question = questionManager.getQuestionForEvent(e);
740             if (question) {
741                 question.handleKeyPress(e);
742             }
743         },
745         /**
746          * Handle when the window is resized.
747          * @param {boolean} isPrinting
748          */
749         handleWindowResize: function(isPrinting) {
750             for (var containerId in questionManager.questions) {
751                 if (questionManager.questions.hasOwnProperty(containerId)) {
752                     questionManager.questions[containerId].isPrinting = isPrinting;
753                     questionManager.questions[containerId].handleResize();
754                 }
755             }
756         },
758         /**
759          * Handle focus lost events on markers.
760          * @param {Event} e
761          * @param {boolean} isNavigating
762          */
763         handleKeyboardFocus: function(e, isNavigating) {
764             questionManager.isKeyboardNavigation = isNavigating;
765         },
767         /**
768          * Sometimes, despite our best efforts, things change in a way that cannot
769          * be specifically caught (e.g. dock expanding or collapsing in Boost).
770          * Therefore, we need to periodically check everything is in the right position.
771          */
772         fixLayoutIfThingsMoved: function() {
773             if (!questionManager.isKeyboardNavigation) {
774                 this.handleWindowResize(questionManager.isPrinting);
775             }
776             // We use setTimeout after finishing work, rather than setInterval,
777             // in case positioning things is slow. We want 100 ms gap
778             // between executions, not what setInterval does.
779             setTimeout(function() {
780                 questionManager.fixLayoutIfThingsMoved(questionManager.isPrinting);
781             }, 100);
782         },
784         /**
785          * Given an event, work out which question it effects.
786          * @param {Event} e the event.
787          * @returns {DragDropMarkersQuestion|undefined} The question, or undefined.
788          */
789         getQuestionForEvent: function(e) {
790             var containerId = $(e.currentTarget).closest('.que.ddmarker').attr('id');
791             return questionManager.questions[containerId];
792         }
793     };
795     /**
796      * @alias module:qtype_ddmarker/question
797      */
798     return {
799         /**
800          * Initialise one drag-drop markers question.
801          *
802          * @param {String} containerId id of the outer div for this question.
803          * @param {String} bgImgUrl the URL of the background image.
804          * @param {boolean} readOnly whether the question is being displayed read-only.
805          * @param {String[]} visibleDropZones the geometry of any drop-zones to show.
806          */
807         init: questionManager.init
808     };
809 });