Commit | Line | Data |
---|---|---|
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 | 24 | define(['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 | }); |