1 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
17 * This class provides the enhancements to the drag-drop marker editing form.
19 * @package qtype_ddmarker
21 * @copyright 2018 The Open University
22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
25 define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes'], function($, dragDrop, Shapes) {
30 * Create the manager object that deals with keeping everything synchronised for one drop zone.
32 * @param {int} dropzoneNo the index of this drop zone in the form. 0, 1, ....
35 function DropZoneManager(dropzoneNo) {
36 this.dropzoneNo = dropzoneNo;
39 this.shape = Shapes.make(this.getShapeType(), this.getLabel());
40 this.updateCoordinatesFromForm();
44 * Update the coordinates from a particular string.
46 * @param {SVGElement} [svg] the SVG element that is the preview.
48 DropZoneManager.prototype.updateCoordinatesFromForm = function(svg) {
49 var coordinates = this.getCoordinates(),
50 currentNumPoints = this.shape.getType() === 'polygon' && this.shape.points.length;
51 if (this.shape.getCoordinates() === coordinates) {
54 // We don't need to scale the shape for editing form.
55 if (!this.shape.parse(coordinates, 1)) {
56 // Invalid coordinates. Don't update the preview.
60 if (this.shape.getType() === 'polygon' && currentNumPoints !== this.shape.points.length) {
61 // Polygon, and size has changed.
62 var currentyActive = this.isActive();
74 // Update the rounded coordinates if needed.
75 this.setCoordinatesInForm();
81 DropZoneManager.prototype.updateLabel = function() {
82 var label = this.getLabel();
83 if (this.shape.label !== label) {
84 this.shape.label = label;
90 * Handle if the type of shape has changed.
92 * @param {SVGElement} [svg] an SVG element to add this new shape to.
94 DropZoneManager.prototype.changeShape = function(svg) {
95 var newShapeType = this.getShapeType(),
96 currentyActive = this.isActive();
98 if (newShapeType === this.shape.getType()) {
102 // It has really changed.
103 this.removeFromSvg();
104 this.shape = Shapes.getSimilar(newShapeType, this.shape);
107 if (currentyActive) {
111 this.setCoordinatesInForm();
115 * Add this drop zone to an SVG graphic.
117 * @param {SVGElement} svg the SVG image to which to add this drop zone.
119 DropZoneManager.prototype.addToSvg = function(svg) {
120 if (this.svgEl !== null) {
121 throw new Error('this.svgEl already set');
123 this.svgEl = this.shape.makeSvg(svg);
127 this.svgEl.setAttribute('class', 'dropzone');
128 this.svgEl.setAttribute('data-dropzone-no', this.dropzoneNo);
131 var handles = this.shape.getHandlePositions();
132 if (handles === null) {
136 var moveHandle = Shapes.createSvgElement(this.svgEl, 'circle');
137 moveHandle.setAttribute('cx', handles.moveHandle.x);
138 moveHandle.setAttribute('cy', handles.moveHandle.y);
139 moveHandle.setAttribute('r', 7);
140 moveHandle.setAttribute('class', 'handle move');
142 for (var i = 0; i < handles.editHandles.length; ++i) {
143 this.makeEditHandle(i, handles.editHandles[i]);
148 * Add a new edit handle.
150 * @param {int} index the handle index.
151 * @param {Point} point the point at which to add the handle.
153 DropZoneManager.prototype.makeEditHandle = function(index, point) {
154 var editHandle = Shapes.createSvgElement(this.svgEl, 'rect');
155 editHandle.setAttribute('x', point.x - 6);
156 editHandle.setAttribute('y', point.y - 6);
157 editHandle.setAttribute('width', 11);
158 editHandle.setAttribute('height', 11);
159 editHandle.setAttribute('class', 'handle edit');
160 editHandle.setAttribute('data-edit-handle-no', index);
164 * Remove this drop zone from an SVG image.
166 DropZoneManager.prototype.removeFromSvg = function() {
167 if (this.svgEl !== null) {
168 this.svgEl.parentNode.removeChild(this.svgEl);
174 * Update the shape of this drop zone (but not type) in an SVG image.
176 DropZoneManager.prototype.updateSvgEl = function() {
177 if (this.svgEl === null) {
181 this.shape.updateSvg(this.svgEl);
184 var handles = this.shape.getHandlePositions();
185 if (handles === null) {
190 // The shape + its label are the first two children of svgEl.
191 // Then come the move handle followed by the edit handles.
192 this.svgEl.childNodes[2].setAttribute('cx', handles.moveHandle.x);
193 this.svgEl.childNodes[2].setAttribute('cy', handles.moveHandle.y);
196 for (var i = 0; i < handles.editHandles.length; ++i) {
197 this.svgEl.childNodes[3 + i].setAttribute('x', handles.editHandles[i].x - 6);
198 this.svgEl.childNodes[3 + i].setAttribute('y', handles.editHandles[i].y - 6);
203 * Find out of this drop zone is currently being edited.
205 * @return {boolean} true if it is.
207 DropZoneManager.prototype.isActive = function() {
208 return this.svgEl !== null && this.svgEl.getAttribute('class').match(/\bactive\b/);
212 * Set this drop zone as being edited.
214 DropZoneManager.prototype.setActive = function() {
215 // Move this one to last, so that it is always on top.
216 // (Otherwise the handles may not be able to receive events.)
217 var parent = this.svgEl.parentNode;
218 parent.removeChild(this.svgEl);
219 parent.appendChild(this.svgEl);
220 this.svgEl.setAttribute('class', this.svgEl.getAttribute('class') + ' active');
224 * Set the coordinates in the form to match the current shape.
226 DropZoneManager.prototype.setCoordinatesInForm = function() {
227 dragDropForm.form.setFormValue('drops', [this.dropzoneNo, 'coords'], this.shape.getCoordinates());
231 * Returns the coordinates for a drop zone from the text input in the form.
232 * @returns {string} the coordinates.
234 DropZoneManager.prototype.getCoordinates = function() {
235 return dragDropForm.form.getFormValue('drops', [this.dropzoneNo, 'coords']).replace(/\s*/g, '');
239 * Returns the selected marker number from the dropdown in the form.
240 * @returns {int} choice number.
242 DropZoneManager.prototype.getChoiceNo = function() {
243 return dragDropForm.form.getFormValue('drops', [this.dropzoneNo, 'choice']);
247 * Returns the selected marker number from the dropdown in the form.
248 * @returns {String} marker label text.
250 DropZoneManager.prototype.getLabel = function() {
251 return dragDropForm.form.getMarkerText(this.getChoiceNo());
256 * Returns the selected type of shape in the form.
257 * @returns {String} 'circle', 'rectangle' or 'polygon'.
259 DropZoneManager.prototype.getShapeType = function() {
260 return dragDropForm.form.getFormValue('drops', [this.dropzoneNo, 'shape']);
264 * Start responding to dragging the move handle.
265 * @param {Event} e Event object
267 DropZoneManager.prototype.handleMove = function(e) {
268 var info = dragDrop.prepare(e);
273 var movingDropZone = this,
276 dragProxy = this.makeDragProxy(info.x, info.y),
277 bgImg = $('fieldset#id_previewareaheader .dropbackground'),
278 maxX = bgImg.width(),
279 maxY = bgImg.height();
281 dragDrop.start(e, $(dragProxy), function(pageX, pageY) {
282 movingDropZone.shape.move(pageX - lastX, pageY - lastY, maxX, maxY);
285 movingDropZone.updateSvgEl();
286 movingDropZone.setCoordinatesInForm();
288 document.body.removeChild(dragProxy);
293 * Start responding to dragging the move handle.
294 * @param {Event} e Event object
295 * @param {int} handleIndex
296 * @param {SVGElement} [svg] an SVG element to add this new shape to.
298 DropZoneManager.prototype.handleEdit = function(e, handleIndex, svg) {
299 var info = dragDrop.prepare(e);
304 // For polygons, CTRL + drag adds a new point.
305 if (this.shape.getType() === 'polygon' && (e.ctrlKey || e.metaKey)) {
306 this.shape.addNewPointAfter(handleIndex);
307 this.removeFromSvg();
312 var changingDropZone = this,
315 dragProxy = this.makeDragProxy(info.x, info.y),
316 bgImg = $('fieldset#id_previewareaheader .dropbackground'),
317 maxX = bgImg.width(),
318 maxY = bgImg.height();
320 dragDrop.start(e, $(dragProxy), function(pageX, pageY) {
321 changingDropZone.shape.edit(handleIndex, pageX - lastX, pageY - lastY, maxX, maxY);
324 changingDropZone.updateSvgEl();
325 changingDropZone.setCoordinatesInForm();
327 document.body.removeChild(dragProxy);
328 changingDropZone.shape.normalizeShape();
329 changingDropZone.updateSvgEl();
330 changingDropZone.setCoordinatesInForm();
335 * Make an invisible drag proxy.
337 * @param {int} x x position .
338 * @param {int} y y position.
339 * @returns {HTMLElement} the drag proxy.
341 DropZoneManager.prototype.makeDragProxy = function(x, y) {
342 var dragProxy = document.createElement('div');
343 dragProxy.style.position = 'absolute';
344 dragProxy.style.top = y + 'px';
345 dragProxy.style.left = x + 'px';
346 dragProxy.style.width = '1px';
347 dragProxy.style.height = '1px';
348 document.body.appendChild(dragProxy);
353 * Singleton object for managing all the parts of the form.
358 * @var {object} for interacting with the file pickers.
360 fp: null, // Object containing functions associated with the file picker.
363 * @var {int} the number of drop-zones on the form.
368 * @var {DropZoneManager[]} the drop zones in the preview, indexed by drop zone number.
373 * Initialise the form.
376 dragDropForm.fp = dragDropForm.filePickers();
377 dragDropForm.noDropZones = dragDropForm.form.getFormValue('nodropzone', []);
378 dragDropForm.setupPreviewArea();
379 dragDropForm.setOptionsForDragItemSelectors();
380 dragDropForm.createShapes();
381 dragDropForm.setupEventHandlers();
382 dragDropForm.waitForFilePickerToInitialise();
386 * Add html for the preview area.
388 setupPreviewArea: function() {
389 $('fieldset#id_previewareaheader div.fcontainer').append(
390 '<div class="ddarea que ddmarker">' +
391 ' <div id="ddm-droparea" class="droparea">' +
392 ' <img class="dropbackground" />' +
393 ' <div id="ddm-dropzone" class="dropzones">' +
400 * When a new marker is added this function updates the Marker dropdown controls in Drop zones.
402 setOptionsForDragItemSelectors: function() {
403 var dragItemsOptions = {'0': ''};
404 var noItems = dragDropForm.form.getFormValue('noitems', []);
405 var selectedValues = [];
408 for (i = 1; i <= noItems; i++) {
409 label = dragDropForm.form.getMarkerText(i);
411 // HTML escape the label.
412 dragItemsOptions[i] = $('<div/>').text(label).html();
415 // Get all the currently selected drags for each drop.
416 for (i = 0; i < dragDropForm.noDropZones; i++) {
417 selector = $('#id_drops_' + i + '_choice');
418 selectedValues[i] = Number(selector.val());
420 for (i = 0; i < dragDropForm.noDropZones; i++) {
421 selector = $('#id_drops_' + i + '_choice');
422 // Remove all options for drag choice.
423 selector.find('option').remove();
424 // And recreate the options.
425 for (var value in dragItemsOptions) {
426 value = Number(value);
427 var option = '<option value="' + value + '">' + dragItemsOptions[value] + '</option>';
428 selector.append(option);
429 var optionnode = selector.find('option[value="' + value + '"]');
433 continue; // The 'no item' option is always selectable.
436 // Is this the currently selected value?
437 if (value === selectedValues[i]) {
438 optionnode.attr('selected', true);
439 continue; // If it s selected, we must leave it enabled.
442 // Count how many times it is used, and if necessary, disable.
443 var noofdrags = dragDropForm.form.getFormValue('drags', [value - 1, 'noofdrags']);
444 if (Number(noofdrags) === 0) { // 'noofdrags === 0' means infinite.
445 continue; // Nothing to check.
448 // Go through all selected values in drop downs.
449 for (var k in selectedValues) {
450 if (Number(selectedValues[k]) !== value) {
454 // Count down 'noofdrags' and if reach zero then set disabled option for this drag item.
455 if (Number(noofdrags) === 1) {
456 optionnode.attr('disabled', true);
464 if (dragDropForm.dropZones.length > 0) {
465 dragDropForm.dropZones[i].updateLabel();
471 * Create the shape representation of each dropZone.
473 createShapes: function() {
474 for (var dropzoneNo = 0; dropzoneNo < dragDropForm.noDropZones; dropzoneNo++) {
475 dragDropForm.dropZones[dropzoneNo] = new DropZoneManager(dropzoneNo);
480 * Events linked to form actions.
482 setupEventHandlers: function() {
483 // Changes to labels in the Markers section.
484 $('fieldset#id_draggableitemheader').on('change input', 'input, select', function() {
485 dragDropForm.setOptionsForDragItemSelectors();
488 // Changes to Drop zones section: shape, coordinates and marker.
489 $('fieldset#id_dropzoneheader').on('change input', 'input, select', function(e) {
490 var ids = e.currentTarget.name.match(/^drops\[(\d+)]\[([a-z]*)]$/);
495 var dropzoneNo = ids[1],
497 dropZone = dragDropForm.dropZones[dropzoneNo];
501 dropZone.changeShape(dragDropForm.form.getSvg());
505 dropZone.updateCoordinatesFromForm(dragDropForm.form.getSvg());
509 dropZone.updateLabel();
514 // Click to toggle graphical editing.
515 var previewArea = $('fieldset#id_previewareaheader');
516 previewArea.on('click', 'g.dropzone', function(e) {
517 var dropzoneNo = $(e.currentTarget).data('dropzone-no'),
518 currentlyActive = dragDropForm.dropZones[dropzoneNo].isActive();
520 $(dragDropForm.form.getSvg()).find('.dropzone.active').removeClass('active');
522 if (!currentlyActive) {
523 dragDropForm.dropZones[dropzoneNo].setActive();
527 // Drag start on a move handle.
528 previewArea.on('mousedown touchstart', '.dropzone .handle.move', function(e) {
529 var dropzoneNo = $(e.currentTarget).closest('g').data('dropzoneNo');
531 dragDropForm.dropZones[dropzoneNo].handleMove(e);
534 // Drag start on a move handle.
535 previewArea.on('mousedown touchstart', '.dropzone .handle.edit', function(e) {
536 var dropzoneNo = $(e.currentTarget).closest('g').data('dropzoneNo'),
537 handleIndex = e.currentTarget.getAttribute('data-edit-handle-no');
539 dragDropForm.dropZones[dropzoneNo].handleEdit(e, handleIndex, dragDropForm.form.getSvg());
544 * Prevents adding drop zones until the preview background image is ready to load.
546 waitForFilePickerToInitialise: function() {
547 if (dragDropForm.fp.file('bgimage').href === null) {
548 // It would be better to use an onload or onchange event rather than this timeout.
549 // Unfortunately attempts to do this early are overwritten by filepicker during its loading.
550 setTimeout(dragDropForm.waitForFilePickerToInitialise, 1000);
554 // From now on, when a new file gets loaded into the filepicker, update the preview.
555 // This is not in the setupEventHandlers section as it needs to be delayed until
556 // after filepicker's javascript has finished.
557 $('form.mform').on('change', '#id_bgimage', dragDropForm.loadPreviewImage);
559 dragDropForm.loadPreviewImage();
563 * Loads the preview background image.
565 loadPreviewImage: function() {
566 $('fieldset#id_previewareaheader .dropbackground')
567 .one('load', dragDropForm.afterPreviewImageLoaded)
568 .attr('src', dragDropForm.fp.file('bgimage').href);
572 * Functions to run after background image loaded.
574 afterPreviewImageLoaded: function() {
575 var bgImg = $('fieldset#id_previewareaheader .dropbackground');
576 // Place the dropzone area over the background image (adding one to account for the border).
577 $('#ddm-dropzone').css('position', 'relative').css('top', (bgImg.height() + 1) * -1);
578 $('#ddm-droparea').css('height', bgImg.height() + 20);
579 dragDropForm.updateSvgDisplay();
583 * Draws or re-draws all dropzones in the preview area based on form data.
584 * Call this function when there is a change in the form data.
586 updateSvgDisplay: function() {
587 var bgImg = $('fieldset#id_previewareaheader .dropbackground'),
590 if (dragDropForm.form.getSvg()) {
591 // Already exists, just need to be updated.
592 for (dropzoneNo = 0; dropzoneNo < dragDropForm.noDropZones; dropzoneNo++) {
593 dragDropForm.dropZones[dropzoneNo].updateSvgEl();
598 $('#ddm-dropzone').html('<svg xmlns="http://www.w3.org/2000/svg" class="dropzones" ' +
599 'width="' + bgImg.outerWidth() + '" ' +
600 'height="' + bgImg.outerHeight() + '"></svg>');
601 for (dropzoneNo = 0; dropzoneNo < dragDropForm.noDropZones; dropzoneNo++) {
602 dragDropForm.dropZones[dropzoneNo].addToSvg(dragDropForm.form.getSvg());
608 * Helper to make it easy to work with form elements with names like "drops[0][shape]".
612 * Returns the label text for a marker.
613 * @param {int} markerNo
614 * @returns {string} Marker text
616 getMarkerText: function(markerNo) {
617 if (Number(markerNo) !== 0) {
618 var label = dragDropForm.form.getFormValue('drags', [markerNo - 1, 'label']);
619 return label.replace(new RegExp("^\\s*(.*)\\s*$"), "$1");
626 * Get the SVG element, if there is one, otherwise return null.
628 * @returns {SVGElement|null} the SVG element or null.
631 var svg = $('fieldset#id_previewareaheader svg');
632 if (svg.length === 0) {
639 toNameWithIndex: function(name, indexes) {
640 var indexString = name;
641 for (var i = 0; i < indexes.length; i++) {
642 indexString = indexString + '[' + indexes[i] + ']';
647 getEl: function(name, indexes) {
648 var form = $('form.mform')[0];
649 return form.elements[this.toNameWithIndex(name, indexes)];
653 * Helper to get the value of a form elements with name like "drops[0][shape]".
655 * @param {String} name the base name, e.g. 'drops'.
656 * @param {String[]} indexes the indexes, e.g. ['0', 'shape'].
657 * @return {String} the value of that field.
659 getFormValue: function(name, indexes) {
660 var el = this.getEl(name, indexes);
661 if (el.type === 'checkbox') {
669 * Helper to get the value of a form elements with name like "drops[0][shape]".
671 * @param {String} name the base name, e.g. 'drops'.
672 * @param {String[]} indexes the indexes, e.g. ['0', 'shape'].
673 * @param {String} value the value to set.
675 setFormValue: function(name, indexes, value) {
676 var el = this.getEl(name, indexes);
677 if (el.type === 'checkbox') {
686 * Utility to get the file name and url from the filepicker.
687 * @returns {Object} object containing functions {file, name}
689 filePickers: function() {
690 var draftItemIdsToName;
691 var nameToParentNode;
692 if (draftItemIdsToName === undefined) {
693 draftItemIdsToName = {};
694 nameToParentNode = {};
695 $('form.mform input.filepickerhidden').each(function(key, filepicker) {
696 draftItemIdsToName[filepicker.value] = filepicker.name;
697 nameToParentNode[filepicker.name] = filepicker.parentNode;
701 file: function(name) {
702 var fileAnchor = $(nameToParentNode[name]).find('div.filepicker-filelist a');
703 if (fileAnchor.length) {
704 return {href: fileAnchor.get(0).href, name: fileAnchor.get(0).innerHTML};
706 return {href: null, name: null};
709 name: function(draftitemid) {
710 return draftItemIdsToName[draftitemid];
717 * @alias module:qtype_ddmarker/form
721 * Initialise the form javascript features.
722 * @param {Object} maxBgimageSize object with two properties: width and height.
724 init: dragDropForm.init