MDL-68454 mod_feedback: Update get_context to match parent
[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 {String} bgImgUrl the URL of the background image.
34      * @param {boolean} readOnly whether the question is being displayed read-only.
35      * @param {Object[]} visibleDropZones the geometry of any drop-zones to show.
36      *      Objects have fields shape, coords and markertext.
37      * @constructor
38      */
39     function DragDropMarkersQuestion(containerId, bgImgUrl, readOnly, visibleDropZones) {
40         this.containerId = containerId;
41         this.visibleDropZones = visibleDropZones;
42         if (readOnly) {
43             this.getRoot().addClass('qtype_ddmarker-readonly');
44         }
45         this.loadImage(bgImgUrl);
46     }
48     /**
49      * Load the background image is loaded, then do the rest of the display.
50      *
51      * @param {String} bgImgUrl the URL of the background image.
52      */
53     DragDropMarkersQuestion.prototype.loadImage = function(bgImgUrl) {
54         var thisQ = this;
55         this.getRoot().find('.dropbackground')
56             .one('load', function() {
57                 if (thisQ.visibleDropZones.length > 0) {
58                     thisQ.drawDropzones();
59                 }
60                 thisQ.repositionDrags();
61             })
62             .attr('src', bgImgUrl)
63             .css({'border': '1px solid #000', 'max-width': 'none'});
64     };
66     /**
67      * Draws the svg shapes of any drop zones that should be visible for feedback purposes.
68      */
69     DragDropMarkersQuestion.prototype.drawDropzones = function() {
70         var bgImage = this.getRoot().find('img.dropbackground');
72         this.getRoot().find('div.dropzones').html('<svg xmlns="http://www.w3.org/2000/svg" class="dropzones" ' +
73             'width="' + bgImage.outerWidth() + '" ' +
74             'height="' + bgImage.outerHeight() + '"></svg>');
75         var svg = this.getRoot().find('svg.dropzones');
76         svg.css('position', 'absolute');
78         var nextColourIndex = 0;
79         for (var dropZoneNo = 0; dropZoneNo < this.visibleDropZones.length; dropZoneNo++) {
80             var colourClass = 'color' + nextColourIndex;
81             nextColourIndex = (nextColourIndex + 1) % 8;
82             this.addDropzone(svg, dropZoneNo, colourClass);
83         }
84     };
86     /**
87      * Adds a dropzone shape with colour, coords and link provided to the array of shapes.
88      *
89      * @param {jQuery} svg the SVG image to which to add this drop zone.
90      * @param {int} dropZoneNo which drop-zone to add.
91      * @param {string} colourClass class name
92      */
93     DragDropMarkersQuestion.prototype.addDropzone = function(svg, dropZoneNo, colourClass) {
94         var dropZone = this.visibleDropZones[dropZoneNo],
95             shape = Shapes.make(dropZone.shape, ''),
96             existingmarkertext;
97         if (!shape.parse(dropZone.coords)) {
98             return;
99         }
101         existingmarkertext = this.getRoot().find('div.markertexts span.markertext' + dropZoneNo);
102         if (existingmarkertext.length) {
103             if (dropZone.markertext !== '') {
104                 existingmarkertext.html(dropZone.markertext);
105             } else {
106                 existingmarkertext.remove();
107             }
108         } else if (dropZone.markertext !== '') {
109             var classnames = 'markertext markertext' + dropZoneNo;
110             this.getRoot().find('div.markertexts').append('<span class="' + classnames + '">' +
111                 dropZone.markertext + '</span>');
112         }
114         var shapeSVG = shape.makeSvg(svg[0]);
115         shapeSVG.setAttribute('class', 'dropzone ' + colourClass);
116     };
118     /**
119      * Draws the drag items on the page (and drop zones if required).
120      * The idea is to re-draw all the drags and drops whenever there is a change
121      * like a widow resize or an item dropped in place.
122      */
123     DragDropMarkersQuestion.prototype.repositionDropZones = function() {
124         var svg = this.getRoot().find('svg.dropzones');
125         if (svg.length === 0) {
126             return;
127         }
128         var bgPosition = this.convertToWindowXY(new Shapes.Point(-1, 0));
129         svg.offset({'left': bgPosition.x, 'top': bgPosition.y});
131         for (var dropZoneNo = 0; dropZoneNo < this.visibleDropZones.length; dropZoneNo++) {
132             var markerspan = this.getRoot().find('div.ddarea div.markertexts span.markertext' + dropZoneNo);
133             if (markerspan.length === 0) {
134                 continue;
135             }
136             var dropZone = this.visibleDropZones[dropZoneNo],
137                 shape = Shapes.make(dropZone.shape, '');
138             if (!shape.parse(dropZone.coords)) {
139                 continue;
140             }
141             var handles = shape.getHandlePositions(),
142                 textPos = this.convertToWindowXY(handles.moveHandle.offset(
143                     -markerspan.outerWidth() / 2, -markerspan.outerHeight() / 2));
144             markerspan.offset({'left': textPos.x - 4, 'top': textPos.y});
145         }
146     };
148     /**
149      * Draws the drag items on the page (and drop zones if required).
150      * The idea is to re-draw all the drags and drops whenever there is a change
151      * like a widow resize or an item dropped in place.
152      */
153     DragDropMarkersQuestion.prototype.repositionDrags = function() {
154         var root = this.getRoot(),
155             thisQ = this;
157         root.find('div.dragitems .dragitem').each(function(key, item) {
158             $(item).addClass('unneeded');
159         });
161         root.find('input.choices').each(function(key, input) {
162             var choiceNo = thisQ.getChoiceNoFromElement(input),
163                 coords = thisQ.getCoords(input),
164                 dragHome = thisQ.dragHome(choiceNo);
165             for (var i = 0; i < coords.length; i++) {
166                 var drag = thisQ.dragItem(choiceNo, i);
167                 if (!drag.length || drag.hasClass('beingdragged')) {
168                     drag = thisQ.cloneNewDragItem(dragHome, i);
169                 } else {
170                     drag.removeClass('unneeded');
171                 }
172                 drag.offset({'left': coords[i].x, 'top': coords[i].y});
173             }
174         });
176         root.find('div.dragitems .dragitem').each(function(key, itm) {
177             var item = $(itm);
178             if (item.hasClass('unneeded') && !item.hasClass('beingdragged')) {
179                 item.remove();
180             }
181         });
183         this.repositionDropZones();
185         var bgImage = this.bgImage(),
186             bgPosition = bgImage.offset();
187         bgImage.data('prev-top', bgPosition.top).data('prev-left', bgPosition.left);
188     };
190     /**
191      * Determine what drag items need to be shown and
192      * return coords of all drag items except any that are currently being dragged
193      * based on contents of hidden inputs and whether drags are 'infinite' or how many
194      * drags should be shown.
195      *
196      * @param {jQuery} inputNode
197      * @returns {Point[]} coordinates of however many copies of the drag item should be shown.
198      */
199     DragDropMarkersQuestion.prototype.getCoords = function(inputNode) {
200         var root = this.getRoot(),
201             choiceNo = this.getChoiceNoFromElement(inputNode),
202             noOfDrags = Number(this.getClassnameNumericSuffix(inputNode, 'noofdrags')),
203             dragging = root.find('span.dragitem.beingdragged.choice' + choiceNo).length > 0,
204             coords = [],
205             val = $(inputNode).val();
206         if (val !== '') {
207             var coordsStrings = val.split(';');
208             for (var i = 0; i < coordsStrings.length; i++) {
209                 coords[i] = this.convertToWindowXY(Shapes.Point.parse(coordsStrings[i]));
210             }
211         }
212         var displayeddrags = coords.length + (dragging ? 1 : 0);
213         if ($(inputNode).hasClass('infinite') || (displayeddrags < noOfDrags)) {
214             coords[coords.length] = this.dragHomeXY(choiceNo);
215         }
216         return coords;
217     };
219     /**
220      * Converts the relative x and y position coordinates into
221      * absolute x and y position coordinates.
222      *
223      * @param {Point} point relative to the background image.
224      * @returns {Point} point relative to the page.
225      */
226     DragDropMarkersQuestion.prototype.convertToWindowXY = function(point) {
227         var bgImage = this.bgImage();
228         // The +1 seems rather odd, but seems to give the best results in
229         // the three main browsers at a range of zoom levels.
230         // (Its due to the 1px border around the image, that shifts the
231         // image pixels by 1 down and to the left.)
232         return point.offset(bgImage.offset().left + 1, bgImage.offset().top + 1);
233     };
235     /**
236      * Utility function converting window coordinates to relative to the
237      * background image coordinates.
238      *
239      * @param {Point} point relative to the page.
240      * @returns {Point} point relative to the background image.
241      */
242     DragDropMarkersQuestion.prototype.convertToBgImgXY = function(point) {
243         var bgImage = this.bgImage();
244         return point.offset(-bgImage.offset().left - 1, -bgImage.offset().top - 1);
245     };
247     /**
248      * Is the point within the background image?
249      *
250      * @param {Point} point relative to the BG image.
251      * @return {boolean} true it they are.
252      */
253     DragDropMarkersQuestion.prototype.coordsInBgImg = function(point) {
254         var bgImage = this.bgImage();
255         return point.x > 0 && point.x <= bgImage.width() &&
256                 point.y > 0 && point.y <= bgImage.height();
257     };
259     /**
260      * Returns coordinates for the home position of a choice.
261      *
262      * @param {Number} choiceNo
263      * @returns {Point} coordinates
264      */
265     DragDropMarkersQuestion.prototype.dragHomeXY = function(choiceNo) {
266         var dragItemHome = this.dragHome(choiceNo);
267         return new Shapes.Point(dragItemHome.offset().left, dragItemHome.offset().top);
268     };
270     /**
271      * Get the outer div for this question.
272      * @returns {jQuery} containing that div.
273      */
274     DragDropMarkersQuestion.prototype.getRoot = function() {
275         return $(document.getElementById(this.containerId));
276     };
278     /**
279      * Get the img that is the background image.
280      * @returns {jQuery} containing that img.
281      */
282     DragDropMarkersQuestion.prototype.bgImage = function() {
283         return this.getRoot().find('img.dropbackground');
284     };
286     /**
287      * Return the DOM node for this choice's home position.
288      * @param {Number} choiceNo
289      * @returns {jQuery} containing the home.
290      */
291     DragDropMarkersQuestion.prototype.dragHome = function(choiceNo) {
292         return this.getRoot().find('div.dragitems span.draghome.choice' + choiceNo);
293     };
295     /**
296      * Return the DOM node for a particular instance of a particular choice.
297      * @param {Number} choiceNo
298      * @param {Number} itemNo
299      * @returns {jQuery} containing the item.
300      */
301     DragDropMarkersQuestion.prototype.dragItem = function(choiceNo, itemNo) {
302         return this.getRoot().find('div.dragitems span.dragitem.choice' + choiceNo + '.item' + itemNo);
303     };
305     /**
306      * Create a draggable copy of the drag item.
307      *
308      * @param {jQuery} dragHome to clone
309      * @param {Number} itemNo new item number
310      * @return {jQuery} drag
311      */
312     DragDropMarkersQuestion.prototype.cloneNewDragItem = function(dragHome, itemNo) {
313         var drag = dragHome.clone(true);
314         drag.removeClass('draghome').addClass('dragitem').addClass('item' + itemNo);
315         dragHome.after(drag);
316         drag.attr('tabIndex', 0);
317         return drag;
318     };
320     DragDropMarkersQuestion.prototype.handleDragStart = function(e) {
321         var thisQ = this,
322             dragged = $(e.target).closest('.dragitem');
324         var info = dragDrop.prepare(e);
325         if (!info.start) {
326             return;
327         }
329         dragged.addClass('beingdragged');
330         dragDrop.start(e, dragged, function() {
331             void (1); // Nothing to do, but we need a function.
332         }, function(x, y, dragged) {
333             thisQ.dragEnd(dragged);
334         });
335     };
337     /**
338      * Functionality at the end of a drag drop.
339      * @param {jQuery} dragged the marker that was dragged.
340      */
341     DragDropMarkersQuestion.prototype.dragEnd = function(dragged) {
342         dragged.removeClass('beingdragged');
343         var choiceNo = this.getChoiceNoFromElement(dragged);
344         this.saveCoordsForChoice(choiceNo, dragged);
345         this.repositionDrags();
346     };
348     /**
349      * Save the coordinates for a dropped item in the form field.
350      * @param {Number} choiceNo which copy of the choice this was.
351      * @param {jQuery} dropped the choice that was dropped here.
352      */
353     DragDropMarkersQuestion.prototype.saveCoordsForChoice = function(choiceNo, dropped) {
354         var coords = [],
355             numItems = this.getRoot().find('span.dragitem.choice' + choiceNo).length,
356             bgImgXY,
357             addme = true;
359         // Re-build the coords array based on data in the ddform inputs.
360         // While long winded and unnecessary if there is only one drop item
361         // for a choice, it does account for moving any one of several drop items
362         // within a choice that have already been placed.
363         for (var i = 0; i <= numItems; i++) {
364             var drag = this.dragItem(choiceNo, i);
365             if (drag.length === 0) {
366                 continue;
367             }
369             if (!drag.hasClass('beingdragged')) {
370                 bgImgXY = this.convertToBgImgXY(new Shapes.Point(drag.offset().left, drag.offset().top));
371                 if (this.coordsInBgImg(bgImgXY)) {
372                     coords[coords.length] = bgImgXY;
373                 }
374             }
376             if (dropped && dropped.length !== 0 && (dropped[0].innerText === drag[0].innerText)) {
377                 addme = false;
378             }
379         }
381         // If dropped has been passed it is because a new item has been dropped onto the background image
382         // so add its coordinates to the array.
383         if (addme) {
384             bgImgXY = this.convertToBgImgXY(new Shapes.Point(dropped.offset().left, dropped.offset().top));
385             if (this.coordsInBgImg(bgImgXY)) {
386                 coords[coords.length] = bgImgXY;
387             }
388         }
390         this.getRoot().find('input.choice' + choiceNo).val(coords.join(';'));
391     };
393     /**
394      * Handle key down / press events on markers.
395      * @param {KeyboardEvent} e
396      */
397     DragDropMarkersQuestion.prototype.handleKeyPress = function(e) {
398         var drag = $(e.target).closest('.dragitem'),
399             point = new Shapes.Point(drag.offset().left, drag.offset().top),
400             choiceNo = this.getChoiceNoFromElement(drag);
402         switch (e.keyCode) {
403             case keys.arrowLeft:
404             case 65: // A.
405                 point.x -= 1;
406                 break;
407             case keys.arrowRight:
408             case 68: // D.
409                 point.x += 1;
410                 break;
411             case keys.arrowDown:
412             case 83: // S.
413                 point.y += 1;
414                 break;
415             case keys.arrowUp:
416             case 87: // W.
417                 point.y -= 1;
418                 break;
419             case keys.space:
420             case keys.escape:
421                 point = null;
422                 break;
423             default:
424                 return; // Ingore other keys.
425         }
426         e.preventDefault();
428         if (point !== null) {
429             point = this.constrainToBgImg(point);
430         } else {
431             point = this.dragHomeXY(choiceNo);
432         }
433         drag.offset({'left': point.x, 'top': point.y});
434         this.saveCoordsForChoice(choiceNo, drag);
435         this.repositionDrags();
436     };
438     /**
439      * Makes sure the dragged item always exists within the background image area.
440      *
441      * @param {Point} windowxy
442      * @returns {Point} coordinates
443      */
444     DragDropMarkersQuestion.prototype.constrainToBgImg = function(windowxy) {
445         var bgImg = this.bgImage(),
446             bgImgXY = this.convertToBgImgXY(windowxy);
447         bgImgXY.x = Math.max(0, bgImgXY.x);
448         bgImgXY.y = Math.max(0, bgImgXY.y);
449         bgImgXY.x = Math.min(bgImg.width(), bgImgXY.x);
450         bgImgXY.y = Math.min(bgImg.height(), bgImgXY.y);
451         return this.convertToWindowXY(bgImgXY);
452     };
454     /**
455      * Returns the choice number for a node.
456      *
457      * @param {Element|jQuery} node
458      * @returns {Number}
459      */
460     DragDropMarkersQuestion.prototype.getChoiceNoFromElement = function(node) {
461         return Number(this.getClassnameNumericSuffix(node, 'choice'));
462     };
464     /**
465      * Returns the numeric part of a class with the given prefix.
466      *
467      * @param {Element|jQuery} node
468      * @param {String} prefix
469      * @returns {Number|null}
470      */
471     DragDropMarkersQuestion.prototype.getClassnameNumericSuffix = function(node, prefix) {
472         var classes = $(node).attr('class');
473         if (classes !== undefined && classes !== '') {
474             var classesarr = classes.split(' ');
475             for (var index = 0; index < classesarr.length; index++) {
476                 var patt1 = new RegExp('^' + prefix + '([0-9])+$');
477                 if (patt1.test(classesarr[index])) {
478                     var patt2 = new RegExp('([0-9])+$');
479                     var match = patt2.exec(classesarr[index]);
480                     return Number(match[0]);
481                 }
482             }
483         }
484         return null;
485     };
487     /**
488      * Handle when the window is resized.
489      */
490     DragDropMarkersQuestion.prototype.handleResize = function() {
491         this.repositionDrags();
492     };
494     /**
495      * Check to see if the background image has moved. If so, refresh the layout.
496      */
497     DragDropMarkersQuestion.prototype.fixLayoutIfBackgroundMoved = function() {
498         var bgImage = this.bgImage(),
499             bgPosition = bgImage.offset(),
500             prevTop = bgImage.data('prev-top'),
501             prevLeft = bgImage.data('prev-left');
502         if (prevLeft === undefined || prevTop === undefined) {
503             // Question is not set up yet. Nothing to do.
504             return;
505         }
506         if (prevTop === bgPosition.top && prevLeft === bgPosition.left) {
507             // Things have not moved.
508             return;
509         }
510         // We need to reposition things.
511         this.repositionDrags();
512     };
514     /**
515      * Singleton that tracks all the DragDropToTextQuestions on this page, and deals
516      * with event dispatching.
517      *
518      * @type {Object}
519      */
520     var questionManager = {
522         /**
523          * {boolean} ensures that the event handlers are only initialised once per page.
524          */
525         eventHandlersInitialised: false,
527         /**
528          * {Object} all the questions on this page, indexed by containerId (id on the .que div).
529          */
530         questions: {}, // An object containing all the information about each question on the page.
532         /**
533          * Initialise one question.
534          *
535          * @param {String} containerId the id of the div.que that contains this question.
536          * @param {String} bgImgUrl URL fo the background image.
537          * @param {boolean} readOnly whether the question is read-only.
538          * @param {Object[]} visibleDropZones data on any drop zones to draw as part of the feedback.
539          */
540         init: function(containerId, bgImgUrl, readOnly, visibleDropZones) {
541             questionManager.questions[containerId] =
542                 new DragDropMarkersQuestion(containerId, bgImgUrl, readOnly, visibleDropZones);
543             if (!questionManager.eventHandlersInitialised) {
544                 questionManager.setupEventHandlers();
545                 questionManager.eventHandlersInitialised = true;
546             }
547         },
549         /**
550          * Set up the event handlers that make this question type work. (Done once per page.)
551          */
552         setupEventHandlers: function() {
553             $('body').on('mousedown touchstart',
554                 '.que.ddmarker:not(.qtype_ddmarker-readonly) div.dragitems .dragitem',
555                 questionManager.handleDragStart)
556                 .on('keydown keypress',
557                     '.que.ddmarker:not(.qtype_ddmarker-readonly) div.dragitems .dragitem',
558                     questionManager.handleKeyPress);
559             $(window).on('resize', questionManager.handleWindowResize);
560             setTimeout(questionManager.fixLayoutIfThingsMoved, 100);
561         },
563         /**
564          * Handle mouse down / touch start events on markers.
565          * @param {Event} e the DOM event.
566          */
567         handleDragStart: function(e) {
568             e.preventDefault();
569             var question = questionManager.getQuestionForEvent(e);
570             if (question) {
571                 question.handleDragStart(e);
572             }
573         },
575         /**
576          * Handle key down / press events on markers.
577          * @param {Event} e
578          */
579         handleKeyPress: function(e) {
580             var question = questionManager.getQuestionForEvent(e);
581             if (question) {
582                 question.handleKeyPress(e);
583             }
584         },
586         /**
587          * Handle when the window is resized.
588          */
589         handleWindowResize: function() {
590             for (var containerId in questionManager.questions) {
591                 if (questionManager.questions.hasOwnProperty(containerId)) {
592                     questionManager.questions[containerId].handleResize();
593                 }
594             }
595         },
597         /**
598          * Sometimes, despite our best efforts, things change in a way that cannot
599          * be specifically caught (e.g. dock expanding or collapsing in Boost).
600          * Therefore, we need to periodically check everything is in the right position.
601          */
602         fixLayoutIfThingsMoved: function() {
603             for (var containerId in questionManager.questions) {
604                 if (questionManager.questions.hasOwnProperty(containerId)) {
605                     questionManager.questions[containerId].fixLayoutIfBackgroundMoved();
606                 }
607             }
609             // We use setTimeout after finishing work, rather than setInterval,
610             // in case positioning things is slow. We want 100 ms gap
611             // between executions, not what setInterval does.
612             setTimeout(questionManager.fixLayoutIfThingsMoved, 100);
613         },
615         /**
616          * Given an event, work out which question it effects.
617          * @param {Event} e the event.
618          * @returns {DragDropMarkersQuestion|undefined} The question, or undefined.
619          */
620         getQuestionForEvent: function(e) {
621             var containerId = $(e.currentTarget).closest('.que.ddmarker').attr('id');
622             return questionManager.questions[containerId];
623         }
624     };
626     /**
627      * @alias module:qtype_ddmarker/question
628      */
629     return {
630         /**
631          * Initialise one drag-drop markers question.
632          *
633          * @param {String} containerId id of the outer div for this question.
634          * @param {String} bgImgUrl the URL of the background image.
635          * @param {boolean} readOnly whether the question is being displayed read-only.
636          * @param {String[]} visibleDropZones the geometry of any drop-zones to show.
637          */
638         init: questionManager.init
639     };
640 });