From 7d3015015fe29b166ce340aaba1de73340ee1acb Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 21 Mar 2018 17:01:14 +0100 Subject: [PATCH] MOBILE-2390 qtype: Implement ddmarker type --- src/addon/qtype/ddmarker/classes/ddmarker.ts | 891 ++++++++++++++++++ .../qtype/ddmarker/classes/graphics_api.ts | 90 ++ .../qtype/ddmarker/component/ddmarker.html | 13 + .../qtype/ddmarker/component/ddmarker.ts | 100 ++ src/addon/qtype/ddmarker/ddmarker.module.ts | 48 + src/addon/qtype/ddmarker/providers/handler.ts | 109 +++ src/addon/qtype/qtype.module.ts | 2 + 7 files changed, 1253 insertions(+) create mode 100644 src/addon/qtype/ddmarker/classes/ddmarker.ts create mode 100644 src/addon/qtype/ddmarker/classes/graphics_api.ts create mode 100644 src/addon/qtype/ddmarker/component/ddmarker.html create mode 100644 src/addon/qtype/ddmarker/component/ddmarker.ts create mode 100644 src/addon/qtype/ddmarker/ddmarker.module.ts create mode 100644 src/addon/qtype/ddmarker/providers/handler.ts diff --git a/src/addon/qtype/ddmarker/classes/ddmarker.ts b/src/addon/qtype/ddmarker/classes/ddmarker.ts new file mode 100644 index 000000000..3624bf46a --- /dev/null +++ b/src/addon/qtype/ddmarker/classes/ddmarker.ts @@ -0,0 +1,891 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { AddonQtypeDdMarkerGraphicsApi } from './graphics_api'; + +/** + * Encapsulates operations on dd area. + */ +export interface AddonQtypeDdMarkerQuestionDocStructure { + topNode?: () => HTMLElement; + bgImg?: () => HTMLImageElement; + dragItemsArea?: () => HTMLElement; + dragItems?: () => HTMLElement[]; + dragItemsForChoice?: (choiceNo: number) => HTMLElement[]; + dragItemForChoice?: (choiceNo: number, itemNo: number) => HTMLElement; + dragItemBeingDragged?: (choiceNo: number) => HTMLElement; + dragItemHome?: (choiceNo: number) => HTMLElement; + dragItemHomes?: () => HTMLElement[]; + getClassnameNumericSuffix?: (node: HTMLElement, prefix: string) => number; + inputsForChoices?: () => HTMLElement[]; + inputForChoice?: (choiceNo: number) => HTMLElement; + markerTexts?: () => HTMLElement; +} + +/** + * Class to make a question of ddmarker type work. + */ +export class AddonQtypeDdMarkerQuestion { + protected COLOURS = ['#FFFFFF', '#B0C4DE', '#DCDCDC', '#D8BFD8', '#87CEFA', '#DAA520', '#FFD700', '#F0E68C']; + + protected logger: any; + protected afterImageLoadDone = false; + protected drops; + protected topNode; + protected nextColourIndex = 0; + protected proportion = 1; + protected selected: HTMLElement; // Selected element (being "dragged"). + protected graphics: AddonQtypeDdMarkerGraphicsApi; + + doc: AddonQtypeDdMarkerQuestionDocStructure; + shapes = []; + + /** + * Create the instance. + * + * @param {CoreLoggerProvider} logger Logger provider. + * @param {CoreDomUtilsProvider} domUtils Dom Utils provider. + * @param {CoreTextUtilsProvider} textUtils Text Utils provider. + * @param {HTMLElement} container The container HTMLElement of the question. + * @param {any} question The question instance. + * @param {boolean} readOnly Whether it's read only. + * @param {any[]} dropZones The drop zones received in the init object of the question. + */ + constructor(logger: CoreLoggerProvider, protected domUtils: CoreDomUtilsProvider, protected textUtils: CoreTextUtilsProvider, + protected container: HTMLElement, protected question: any, protected readOnly: boolean, protected dropZones: any[]) { + this.logger = logger.getInstance('AddonQtypeDdMarkerQuestion'); + + this.graphics = new AddonQtypeDdMarkerGraphicsApi(this, this.domUtils); + + this.initializer(question); + } + + /** + * Calculate image proportion to make easy conversions. + */ + calculateImgProportion(): void { + const bgImg = this.doc.bgImg(); + + // Render the position related to the current image dimensions. + this.proportion = 1; + if (bgImg.width != bgImg.naturalWidth) { + this.proportion = bgImg.width / bgImg.naturalWidth; + } + } + + /** + * Create a new draggable element cloning a certain element. + * + * @param {HTMLElement} dragHome The element to clone. + * @param {number} itemNo The number of the new item. + * @return {HTMLElement} The new element. + */ + cloneNewDragItem(dragHome: HTMLElement, itemNo: number): HTMLElement { + const marker = dragHome.querySelector('span.markertext'); + marker.style.opacity = '0.6'; + + // Clone the element and add the right classes. + const drag = dragHome.cloneNode(true); + drag.classList.remove('draghome'); + drag.classList.add('dragitem'); + drag.classList.add('item' + itemNo); + + // Insert the new drag after the dragHome. + dragHome.parentElement.insertBefore(drag, dragHome.nextSibling); + if (!this.readOnly) { + this.draggable(drag); + } + + return drag; + } + + /** + * Convert the X and Y position of the BG IMG to a position relative to the window. + * + * @param {number[]} bgImgXY X and Y of the BG IMG relative position. + * @return {number[]} Position relative to the window. + */ + convertToWindowXY(bgImgXY: number[]): number[] { + const bgImg = this.doc.bgImg(), + position = this.domUtils.getElementXY(bgImg, null, 'ddarea'); + + // Render the position related to the current image dimensions. + bgImgXY[0] *= this.proportion; + bgImgXY[1] *= this.proportion; + + return [Number(bgImgXY[0]) + position[0], Number(bgImgXY[1]) + position[1]]; + } + + /** + * Check if some coordinates (X, Y) are inside the background image. + * + * @param {number[]} coords Coordinates to check. + * @return {boolean} Whether they're inside the background image. + */ + coordsInImg(coords: number[]): boolean { + const bgImg = this.doc.bgImg(); + + return (coords[0] * this.proportion <= bgImg.width && coords[1] * this.proportion <= bgImg.height); + } + + /** + * Deselect all draggable items. + */ + deselectDrags(): void { + const drags = this.doc.dragItems(); + drags.forEach((drag) => { + drag.classList.remove('beingdragged'); + }); + this.selected = null; + } + + /** + * Function to call when the instance is no longer needed. + */ + destroy(): void { + window.removeEventListener('resize', this.resizeFunction); + } + + /** + * Returns an object to encapsulate operations on dd area. + * + * @param {number} slot The question slot. + * @return {AddonQtypeDdMarkerQuestionDocStructure} The object. + */ + docStructure(slot: number): AddonQtypeDdMarkerQuestionDocStructure { + const topNode = this.container.querySelector('#core-question-' + slot + ' .addon-qtype-ddmarker-container'), + dragItemsArea = topNode.querySelector('div.dragitems'); + + return { + topNode: (): HTMLElement => { + return topNode; + }, + bgImg: (): HTMLImageElement => { + return topNode.querySelector('.dropbackground'); + }, + dragItemsArea: (): HTMLElement => { + return dragItemsArea; + }, + dragItems: (): HTMLElement[] => { + return Array.from(dragItemsArea.querySelectorAll('.dragitem')); + }, + dragItemsForChoice: (choiceNo: number): HTMLElement[] => { + return Array.from(dragItemsArea.querySelectorAll('span.dragitem.choice' + choiceNo)); + }, + dragItemForChoice: (choiceNo: number, itemNo: number): HTMLElement => { + return dragItemsArea.querySelector('span.dragitem.choice' + choiceNo + '.item' + itemNo); + }, + dragItemBeingDragged: (choiceNo: number): HTMLElement => { + return dragItemsArea.querySelector('span.dragitem.beingdragged.choice' + choiceNo); + }, + dragItemHome: (choiceNo: number): HTMLElement => { + return dragItemsArea.querySelector('span.draghome.choice' + choiceNo); + }, + dragItemHomes: (): HTMLElement[] => { + return Array.from(dragItemsArea.querySelectorAll('span.draghome')); + }, + getClassnameNumericSuffix: (node: HTMLElement, prefix: string): number => { + + if (node.classList && node.classList.length) { + const patt1 = new RegExp('^' + prefix + '([0-9])+$'), + patt2 = new RegExp('([0-9])+$'); + + for (let index = 0; index < node.classList.length; index++) { + if (patt1.test(node.classList[index])) { + const match = patt2.exec(node.classList[index]); + + return Number(match[0]); + } + } + } + + this.logger.warn('Prefix "' + prefix + '" not found in class names.'); + }, + inputsForChoices: (): HTMLElement[] => { + return Array.from(topNode.querySelectorAll('input.choices')); + }, + inputForChoice: (choiceNo: number): HTMLElement => { + return topNode.querySelector('input.choice' + choiceNo); + }, + markerTexts: (): HTMLElement => { + return topNode.querySelector('div.markertexts'); + } + }; + } + + /** + * Make an element "draggable". In the mobile app, items are "dragged" using tap and drop. + * + * @param {HTMLElement} drag Element. + */ + draggable(drag: HTMLElement): void { + drag.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const dragging = this.selected; + if (dragging && !drag.classList.contains('unplaced')) { + + const position = this.domUtils.getElementXY(drag, null, 'ddarea'), + bgImg = this.doc.bgImg(), + bgImgPos = this.domUtils.getElementXY(bgImg, null, 'ddarea'); + + position[0] = position[0] - bgImgPos[0] + e.offsetX; + position[1] = position[1] - bgImgPos[1] + e.offsetY; + + // Ensure the we click on a placed dragitem. + if (position[0] <= bgImg.width && position[1] <= bgImg.height) { + this.deselectDrags(); + this.dropDrag(dragging, position); + + return; + } + } + + if (drag.classList.contains('beingdragged')) { + this.deselectDrags(); + } else { + this.selectDrag(drag); + } + }); + } + + /** + * Get the coordinates of the drag home of a certain choice. + * + * @param {number} choiceNo Choice number. + * @return {number[]} Coordinates. + */ + dragHomeXY(choiceNo: number): number[] { + const dragItemHome = this.doc.dragItemHome(choiceNo), + position = this.domUtils.getElementXY(dragItemHome, null, 'ddarea'); + + return [position[0], position[1]]; + } + + /** + * Draw a drop zone. + * + * @param {number} dropZoneNo Number of the drop zone. + * @param {string} markerText The marker text to set. + * @param {string} shape Name of the shape of the drop zone (circle, rectangle, polygon). + * @param {string} coords Coordinates of the shape. + * @param {string} colour Colour of the shape. + * @param {boolean} link Whether the marker should have a link in it. + */ + drawDropZone(dropZoneNo: number, markerText: string, shape: string, coords: string, colour: string, link: boolean): void { + let existingMarkerText: HTMLElement; + + const markerTexts = this.doc.markerTexts(); + // Check if there is already a marker text for this drop zone. + if (link) { + existingMarkerText = markerTexts.querySelector('span.markertext' + dropZoneNo + ' a'); + } else { + existingMarkerText = markerTexts.querySelector('span.markertext' + dropZoneNo); + } + + if (existingMarkerText) { + // Marker text already exists. Update it or remove it if empty. + if (markerText !== '') { + existingMarkerText.innerHTML = markerText; + } else { + existingMarkerText.remove(); + } + } else if (markerText !== '') { + // Create and add the marker text. + const classNames = 'markertext markertext' + dropZoneNo, + span = document.createElement('span'); + + span.className = classNames; + + if (link) { + span.innerHTML = '' + markerText + ''; + } else { + span.innerHTML = markerText; + } + + markerTexts.appendChild(span); + } + + // Check that a function to draw this shape exists. + const drawFunc = 'drawShape' + this.textUtils.ucFirst(shape); + if (this[drawFunc] instanceof Function) { + + // Call the function. + const xyForText = this[drawFunc](dropZoneNo, coords, colour); + if (xyForText !== null) { + + // Search the marker for the drop zone. + const markerSpan = this.doc.topNode().querySelector( + 'div.ddarea div.markertexts span.markertext' + dropZoneNo); + if (markerSpan !== null) { + + xyForText[0] = (xyForText[0] - markerSpan.offsetWidth / 2) * this.proportion; + xyForText[1] = (xyForText[1] - markerSpan.offsetHeight / 2) * this.proportion; + + markerSpan.style.opacity = '0.6'; + markerSpan.style.left = xyForText[0] + 'px'; + markerSpan.style.top = xyForText[1] + 'px'; + + const markerSpanAnchor = markerSpan.querySelector('a'); + if (markerSpanAnchor !== null) { + + markerSpanAnchor.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + this.shapes.forEach((elem) => { + elem.css('fill-opacity', 0.5); + }); + + this.shapes[dropZoneNo].css('fill-opacity', 1); + setTimeout(() => { + this.shapes[dropZoneNo].css('fill-opacity', 0.5); + }, 2000); + }); + + markerSpanAnchor.setAttribute('tabIndex', '0'); + } + } + } + } + } + + /** + * Draw a circle in a drop zone. + * + * @param {number} dropZoneNo Number of the drop zone. + * @param {string} coords Coordinates of the circle. + * @param {string} colour Colour of the circle. + * @return {number[]} X and Y position of the center of the circle. + */ + drawShapeCircle(dropZoneNo: number, coords: string, colour: string): number[] { + // Extract the numbers in the coordinates. + const coordsParts = coords.match(/(\d+),(\d+);(\d+)/); + + if (coordsParts && coordsParts.length === 4) { + // Remove first element and convert them to number. + coordsParts.shift(); + + const coordsPartsNum = coordsParts.map((i) => { + return Number(i); + }); + + // Calculate circle limits and check it's inside the background image. + const circleLimit = [coordsPartsNum[0] - coordsPartsNum[2], coordsPartsNum[1] - coordsPartsNum[2]]; + if (this.coordsInImg(circleLimit)) { + // All good, create the shape. + this.shapes[dropZoneNo] = this.graphics.addShape({ + type: 'circle', + color: colour + }, { + cx: coordsPartsNum[0] * this.proportion, + cy: coordsPartsNum[1] * this.proportion, + r: coordsPartsNum[2] * this.proportion + }); + + // Return the center. + return [coordsPartsNum[0], coordsPartsNum[1]]; + } + } + + return null; + } + + /** + * Draw a rectangle in a drop zone. + * + * @param {number} dropZoneNo Number of the drop zone. + * @param {string} coords Coordinates of the rectangle. + * @param {string} colour Colour of the rectangle. + * @return {number[]} X and Y position of the center of the rectangle. + */ + drawShapeRectangle(dropZoneNo: number, coords: string, colour: string): number[] { + // Extract the numbers in the coordinates. + const coordsParts = coords.match(/(\d+),(\d+);(\d+),(\d+)/); + + if (coordsParts && coordsParts.length === 5) { + // Remove first element and convert them to number. + coordsParts.shift(); + + const coordsPartsNum = coordsParts.map((i) => { + return Number(i); + }); + + // Calculate rectangle limits and check it's inside the background image. + const rectLimits = [coordsPartsNum[0] + coordsPartsNum[2], coordsPartsNum[1] + coordsPartsNum[3]]; + if (this.coordsInImg(rectLimits)) { + // All good, create the shape. + this.shapes[dropZoneNo] = this.graphics.addShape({ + type: 'rect', + color: colour + }, { + x: coordsPartsNum[0] * this.proportion, + y: coordsPartsNum[1] * this.proportion, + width: coordsPartsNum[2] * this.proportion, + height: coordsPartsNum[3] * this.proportion + }); + + // Return the center. + return [coordsPartsNum[0] + coordsPartsNum[2] / 2, coordsPartsNum[1] + coordsPartsNum[3] / 2]; + } + } + + return null; + } + + /** + * Draw a polygon in a drop zone. + * + * @param {number} dropZoneNo Number of the drop zone. + * @param {string} coords Coordinates of the polygon. + * @param {string} colour Colour of the polygon. + * @return {number[]} X and Y position of the center of the polygon. + */ + drawShapePolygon(dropZoneNo: number, coords: string, colour: string): number[] { + const coordsParts = coords.split(';'), + points = [], + bgImg = this.doc.bgImg(), + maxXY = [0, 0], + minXY = [bgImg.width, bgImg.height]; + + for (const i in coordsParts) { + // Extract the X and Y of this point. + const partsString = coordsParts[i].match(/^(\d+),(\d+)$/), + parts = partsString && partsString.map((part) => { + return Number(part); + }); + + if (parts !== null && this.coordsInImg([parts[1], parts[2]])) { + parts[1] *= this.proportion; + parts[2] *= this.proportion; + + // Calculate min and max points to find center to show marker on. + minXY[0] = Math.min(parts[1], minXY[0]); + minXY[1] = Math.min(parts[2], minXY[1]); + maxXY[0] = Math.max(parts[1], maxXY[0]); + maxXY[1] = Math.max(parts[2], maxXY[1]); + + points.push(parts[1] + ',' + parts[2]); + } + } + + if (points.length > 2) { + this.shapes[dropZoneNo] = this.graphics.addShape({ + type: 'polygon', + color: colour + }, { + points: points.join(' ') + }); + + // Return the center. + return [(minXY[0] + maxXY[0]) / 2, (minXY[1] + maxXY[1]) / 2]; + } + + return null; + } + + /** + * Drop a drag element into a certain position. + * + * @param {HTMLElement} drag The element to drop. + * @param {number[]} position Position to drop to (X, Y). + */ + dropDrag(drag: HTMLElement, position: number[]): void { + const choiceNo = this.getChoiceNoForNode(drag); + + if (position) { + // Set the position related to the natural image dimensions. + if (this.proportion < 1) { + position[0] = Math.round(position[0] / this.proportion); + } + + if (this.proportion < 1) { + position[1] = Math.round(position[1] / this.proportion); + } + } + + this.saveAllXYForChoice(choiceNo, drag, position); + this.redrawDragsAndDrops(); + } + + /** + * Determine which drag items need to be shown and return coords of all drag items except any that are currently being + * dragged based on contents of hidden inputs and whether drags are 'infinite' or how many drags should be shown. + * + * @param {HTMLElement} input The input element. + * @return {number[][]} List of coordinates. + */ + getCoords(input: HTMLElement): number[][] { + const choiceNo = this.getChoiceNoForNode(input), + fv = input.getAttribute('value'), + infinite = input.classList.contains('infinite'), + noOfDrags = this.getNoOfDragsForNode(input), + dragging = (this.doc.dragItemBeingDragged(choiceNo) !== null), + coords: number[][] = []; + + if (fv !== '' && typeof fv != 'undefined') { + // Get all the coordinates in the input and add them to the coords list. + const coordsStrings = fv.split(';'); + + for (let i = 0; i < coordsStrings.length; i++) { + const coordsNumbers = coordsStrings[i].split(',').map((i) => { + return Number(i); + }); + + coords[coords.length] = this.convertToWindowXY(coordsNumbers); + } + } + + const displayedDrags = coords.length + (dragging ? 1 : 0); + if (infinite || (displayedDrags < noOfDrags)) { + coords[coords.length] = this.dragHomeXY(choiceNo); + } + + return coords; + } + + /** + * Get the choice number from an HTML element. + * + * @param {HTMLElement} node Element to check. + * @return {number} Choice number. + */ + getChoiceNoForNode(node: HTMLElement): number { + return Number(this.doc.getClassnameNumericSuffix(node, 'choice')); + } + + /** + * Get the coordinates (X, Y) of a draggable element. + * + * @param {HTMLElement} dragItem The draggable item. + * @return {number[]} Coordinates. + */ + getDragXY(dragItem: HTMLElement): number[] { + const position = this.domUtils.getElementXY(dragItem, null, 'ddarea'), + bgImg = this.doc.bgImg(), + bgImgXY = this.domUtils.getElementXY(bgImg, null, 'ddarea'); + + position[0] -= bgImgXY[0]; + position[1] -= bgImgXY[1]; + + // Set the position related to the natural image dimensions. + if (this.proportion < 1) { + position[0] = Math.round(position[0] / this.proportion); + } + + if (this.proportion < 1) { + position[1] = Math.round(position[1] / this.proportion); + } + + return position; + } + + /** + * Get the item number from an HTML element. + * + * @param {HTMLElement} node Element to check. + * @return {number} Choice number. + */ + getItemNoForNode(node: HTMLElement): number { + return Number(this.doc.getClassnameNumericSuffix(node, 'item')); + } + + /** + * Get the next colour. + * + * @return {string} Colour. + */ + getNextColour(): string { + const colour = this.COLOURS[this.nextColourIndex]; + this.nextColourIndex++; + + // If we reached the end of the list, start again. + if (this.nextColourIndex === this.COLOURS.length) { + this.nextColourIndex = 0; + } + + return colour; + } + + /** + * Get the number of drags from an HTML element. + * + * @param {HTMLElement} node Element to check. + * @return {number} Choice number. + */ + getNoOfDragsForNode(node: HTMLElement): number { + return Number(this.doc.getClassnameNumericSuffix(node, 'noofdrags')); + } + + /** + * Initialize the question. + * + * @param {any} question Question. + */ + initializer(question: any): void { + this.doc = this.docStructure(question.slot); + + // Wait the DOM to be rendered. + setTimeout(() => { + this.pollForImageLoad(); + }); + + window.addEventListener('resize', this.resizeFunction); + } + + /** + * Make background image and home zone dropable. + */ + makeImageDropable(): void { + if (this.readOnly) { + return; + } + + // Listen for click events in the background image to make it dropable. + const bgImg = this.doc.bgImg(); + bgImg.addEventListener('click', (e) => { + + const drag = this.selected; + if (!drag) { + // No draggable element selected, nothing to do. + return false; + } + + // There's an element being dragged. Deselect it and drop it in the position. + const position = [e.offsetX, e.offsetY]; + this.deselectDrags(); + this.dropDrag(drag, position); + + e.preventDefault(); + e.stopPropagation(); + }); + + const home = this.doc.dragItemsArea(); + home.addEventListener('click', (e) => { + + const drag = this.selected; + if (!drag) { + // No draggable element selected, nothing to do. + return false; + } + + // There's an element being dragged but it's not placed yet, deselect. + if (drag.classList.contains('unplaced')) { + this.deselectDrags(); + + return false; + } + + // There's an element being dragged and it's placed somewhere. Move it back to the home area. + this.deselectDrags(); + this.dropDrag(drag, null); + + e.preventDefault(); + e.stopPropagation(); + }); + } + + /** + * Wait for the background image to be loaded. + */ + pollForImageLoad(): void { + if (this.afterImageLoadDone) { + // Already treated. + return; + } + + const bgImg = this.doc.bgImg(), + imgLoaded = (): void => { + bgImg.removeEventListener('load', imgLoaded); + + this.makeImageDropable(); + + setTimeout(() => { + this.redrawDragsAndDrops(); + }); + + this.afterImageLoadDone = true; + this.question.loaded = true; + }; + + bgImg.addEventListener('load', imgLoaded); + + // Try again after a while. + setTimeout(() => { + this.pollForImageLoad(); + }, 500); + } + + redrawDragsAndDrops(): void { + // Mark all the draggable items as not placed. + const drags = this.doc.dragItems(); + drags.forEach((drag) => { + drag.classList.add('unneeded', 'unplaced'); + }); + + // Re-calculate the image proportion. + this.calculateImgProportion(); + + // Get all the inputs. + const inputs = this.doc.inputsForChoices(); + for (let x = 0; x < inputs.length; x++) { + + // Get all the drag items for the choice. + const input = inputs[x], + choiceNo = this.getChoiceNoForNode(input), + coords = this.getCoords(input), + dragItemHome = this.doc.dragItemHome(choiceNo), + homePosition = this.dragHomeXY(choiceNo); + + for (let i = 0; i < coords.length; i++) { + let dragItem = this.doc.dragItemForChoice(choiceNo, i); + + if (!dragItem || dragItem.classList.contains('beingdragged')) { + dragItem = this.cloneNewDragItem(dragItemHome, i); + } else { + dragItem.classList.remove('unneeded'); + } + + // Remove the class only if is placed on the image. + if (homePosition[0] != coords[i][0] || homePosition[1] != coords[i][1]) { + dragItem.classList.remove('unplaced'); + } + + dragItem.style.left = coords[i][0] + 'px'; + dragItem.style.top = coords[i][1] + 'px'; + } + } + + // Remove unneeded draggable items. + for (let y = 0; y < drags.length; y++) { + const item = drags[y]; + if (item.classList.contains('unneeded') && !item.classList.contains('beingdragged')) { + item.remove(); + } + } + + // Re-draw drop zones. + if (this.dropZones.length !== 0) { + this.graphics.clear(); + this.restartColours(); + + for (const dropZoneNo in this.dropZones) { + const colourForDropZone = this.getNextColour(), + dropZone = this.dropZones[dropZoneNo], + dzNo = Number(dropZoneNo); + + this.drawDropZone(dzNo, dropZone.markerText, dropZone.shape, dropZone.coords, colourForDropZone, true); + } + } + } + + /** + * Reset the coordinates stored for a choice. + * + * @param {number} choiceNo Choice number. + */ + resetDragXY(choiceNo: number): void { + this.setFormValue(choiceNo, ''); + } + + /** + * Function to call when the window is resized. + */ + resizeFunction(): void { + this.redrawDragsAndDrops(); + } + + /** + * Restart the colour index. + */ + restartColours(): void { + this.nextColourIndex = 0; + } + + /** + * Save all the coordinates of a choice into the right input. + * + * @param {number} choiceNo Number of the choice. + * @param {HTMLElement} dropped Element being dropped. + * @param {number[]} position Position where the element is dropped. + */ + saveAllXYForChoice(choiceNo: number, dropped: HTMLElement, position: number[]): void { + const coords = []; + let bgImgXY; + + // Calculate the coords for the choice. + const dragItemsChoice = this.doc.dragItemsForChoice(choiceNo); + for (let i = 0; i < dragItemsChoice.length; i++) { + + const dragItem = this.doc.dragItemForChoice(choiceNo, i); + if (dragItem) { + + dragItem.classList.remove('item' + i); + bgImgXY = this.getDragXY(dragItem); + dragItem.classList.add('item' + coords.length); + coords.push(bgImgXY); + } + } + + if (position !== null) { + // Element dropped into a certain position. Mark it as placed and save the position. + dropped.classList.remove('unplaced'); + dropped.classList.add('item' + coords.length); + coords.push(position); + } else { + // Element back at home, mark it as unplaced. + dropped.classList.add('unplaced'); + } + + if (coords.length > 0) { + // Save the coordinates in the input. + this.setFormValue(choiceNo, coords.join(';')); + } else { + // Empty the input. + this.resetDragXY(choiceNo); + } + } + + /** + * Save a certain value in the input of a choice. + * + * @param {number} choiceNo Choice number. + * @param {string} value The value to set. + */ + setFormValue(choiceNo: number, value: string): void { + this.doc.inputForChoice(choiceNo).setAttribute('value', value); + } + + /** + * Select a draggable element. + * + * @param {HTMLElement} drag Element. + */ + selectDrag(drag: HTMLElement): void { + // Deselect previous drags. + this.deselectDrags(); + + this.selected = drag; + drag.classList.add('beingdragged'); + + const itemNo = this.getItemNoForNode(drag); + if (itemNo !== null) { + drag.classList.remove('item' + itemNo); + } + } +} diff --git a/src/addon/qtype/ddmarker/classes/graphics_api.ts b/src/addon/qtype/ddmarker/classes/graphics_api.ts new file mode 100644 index 000000000..a03df9a76 --- /dev/null +++ b/src/addon/qtype/ddmarker/classes/graphics_api.ts @@ -0,0 +1,90 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { AddonQtypeDdMarkerQuestion } from './ddmarker'; + +/** + * Graphics API for drag-and-drop markers question type. + */ +export class AddonQtypeDdMarkerGraphicsApi { + + protected NS = 'http://www.w3.org/2000/svg'; + protected dropZone: SVGSVGElement; + + /** + * Create the instance. + * + * @param {AddonQtypeDdMarkerQuestion} instance Question instance. + * @param {CoreDomUtilsProvider} domUtils Dom Utils provider. + */ + constructor(protected instance: AddonQtypeDdMarkerQuestion, protected domUtils: CoreDomUtilsProvider) { } + + /** + * Add a shape. + * + * @param {{type: string, color: string}} shapeAttribs Attributes for the shape: type and color. + * @param {{[name: string]: number|string} styles Object with the styles for the shape (name -> value). + * @return {Element} The new shape. + */ + addShape(shapeAttribs: {type: string, color: string}, styles: {[name: string]: number | string}): Element { + const shape = document.createElementNS(this.NS, shapeAttribs.type); + shape.setAttribute('fill', shapeAttribs.color); + shape.setAttribute('fill-opacity', '0.5'); + shape.setAttribute('stroke', 'black'); + + for (const x in styles) { + shape.setAttribute(x, String(styles[x])); + } + + this.dropZone.appendChild(shape); + + return shape; + } + + /** + * Clear the shapes. + */ + clear(): void { + const bgImg = this.instance.doc.bgImg(), + position = this.domUtils.getElementXY(bgImg, null, 'ddarea'), + dropZones = this.instance.doc.topNode().querySelector('div.ddarea div.dropzones'); + + dropZones.style.left = position[0] + 'px'; + dropZones.style.top = position[1] + 'px'; + dropZones.style.width = bgImg.width + 'px'; + dropZones.style.height = bgImg.height + 'px'; + + const markerTexts = this.instance.doc.markerTexts(); + markerTexts.style.left = position[0] + 'px'; + markerTexts.style.top = position[1] + 'px'; + markerTexts.style.width = bgImg.width + 'px'; + markerTexts.style.height = bgImg.height + 'px'; + + if (!this.dropZone) { + this.dropZone = document.createElementNS(this.NS, 'svg'); + dropZones.appendChild(this.dropZone); + } else { + // Remove all children. + while (this.dropZone.firstChild) { + this.dropZone.removeChild(this.dropZone.firstChild); + } + } + + this.dropZone.style.width = bgImg.width + 'px'; + this.dropZone.style.height = bgImg.height + 'px'; + + this.instance.shapes = []; + } +} diff --git a/src/addon/qtype/ddmarker/component/ddmarker.html b/src/addon/qtype/ddmarker/component/ddmarker.html new file mode 100644 index 000000000..eb3a16b81 --- /dev/null +++ b/src/addon/qtype/ddmarker/component/ddmarker.html @@ -0,0 +1,13 @@ +
+ + + + +

+ + {{ 'core.question.howtodraganddrop' | translate }} +

+

+ +
+
diff --git a/src/addon/qtype/ddmarker/component/ddmarker.ts b/src/addon/qtype/ddmarker/component/ddmarker.ts new file mode 100644 index 000000000..2afbe0a1f --- /dev/null +++ b/src/addon/qtype/ddmarker/component/ddmarker.ts @@ -0,0 +1,100 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy, AfterViewInit, Injector, ElementRef } from '@angular/core'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreQuestionBaseComponent } from '@core/question/classes/base-question-component'; +import { AddonQtypeDdMarkerQuestion } from '../classes/ddmarker'; + +/** + * Component to render a drag-and-drop markers question. + */ +@Component({ + selector: 'addon-qtype-ddmarker', + templateUrl: 'ddmarker.html' +}) +export class AddonQtypeDdMarkerComponent extends CoreQuestionBaseComponent implements OnInit, AfterViewInit, OnDestroy { + + protected element: HTMLElement; + protected questionInstance: AddonQtypeDdMarkerQuestion; + protected dropZones: any[]; // The drop zones received in the init object of the question. + + constructor(logger: CoreLoggerProvider, injector: Injector, element: ElementRef) { + super(logger, 'AddonQtypeDdMarkerComponent', injector); + + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (!this.question) { + this.logger.warn('Aborting because of no question received.'); + + return this.questionHelper.showComponentError(this.onAbort); + } + + const div = document.createElement('div'); + div.innerHTML = this.question.html; + + // Get D&D area, form and question text. + const ddArea = div.querySelector('.ddarea'), + ddForm = div.querySelector('.ddform'); + + this.question.text = this.domUtils.getContentsOfElement(div, '.qtext'); + if (!ddArea || !ddForm || typeof this.question.text == 'undefined') { + this.logger.warn('Aborting because of an error parsing question.', this.question.name); + + return this.questionHelper.showComponentError(this.onAbort); + } + + // Build the D&D area HTML. + this.question.ddArea = ddArea.outerHTML; + + const wrongParts = div.querySelector('.wrongparts'); + if (wrongParts) { + this.question.ddArea += wrongParts.outerHTML; + } + this.question.ddArea += ddForm.outerHTML; + this.question.readOnly = false; + + if (this.question.initObjects) { + if (typeof this.question.initObjects.dropzones != 'undefined') { + this.dropZones = this.question.initObjects.dropzones; + } + if (typeof this.question.initObjects.readonly != 'undefined') { + this.question.readOnly = this.question.initObjects.readonly; + } + } + + this.question.loaded = false; + } + + /** + * View has been initialized. + */ + ngAfterViewInit(): void { + // Create the instance. + this.questionInstance = new AddonQtypeDdMarkerQuestion(this.logger, this.domUtils, this.textUtils, this.element, + this.question, this.question.readOnly, this.dropZones); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.questionInstance && this.questionInstance.destroy(); + } +} diff --git a/src/addon/qtype/ddmarker/ddmarker.module.ts b/src/addon/qtype/ddmarker/ddmarker.module.ts new file mode 100644 index 000000000..839fe9350 --- /dev/null +++ b/src/addon/qtype/ddmarker/ddmarker.module.ts @@ -0,0 +1,48 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreQuestionDelegate } from '@core/question/providers/delegate'; +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonQtypeDdMarkerHandler } from './providers/handler'; +import { AddonQtypeDdMarkerComponent } from './component/ddmarker'; + +@NgModule({ + declarations: [ + AddonQtypeDdMarkerComponent + ], + imports: [ + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule + ], + providers: [ + AddonQtypeDdMarkerHandler + ], + exports: [ + AddonQtypeDdMarkerComponent + ], + entryComponents: [ + AddonQtypeDdMarkerComponent + ] +}) +export class AddonQtypeDdMarkerModule { + constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeDdMarkerHandler) { + questionDelegate.registerHandler(handler); + } +} diff --git a/src/addon/qtype/ddmarker/providers/handler.ts b/src/addon/qtype/ddmarker/providers/handler.ts new file mode 100644 index 000000000..04b1f2926 --- /dev/null +++ b/src/addon/qtype/ddmarker/providers/handler.ts @@ -0,0 +1,109 @@ + +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, Injector } from '@angular/core'; +import { CoreQuestionProvider } from '@core/question/providers/question'; +import { CoreQuestionHandler } from '@core/question/providers/delegate'; +import { AddonQtypeDdMarkerComponent } from '../component/ddmarker'; + +/** + * Handler to support drag-and-drop markers question type. + */ +@Injectable() +export class AddonQtypeDdMarkerHandler implements CoreQuestionHandler { + name = 'AddonQtypeDdMarker'; + type = 'qtype_ddmarker'; + + constructor(private questionProvider: CoreQuestionProvider) { } + + /** + * Return the name of the behaviour to use for the question. + * If the question should use the default behaviour you shouldn't implement this function. + * + * @param {any} question The question. + * @param {string} behaviour The default behaviour. + * @return {string} The behaviour to use. + */ + getBehaviour(question: any, behaviour: string): string { + if (behaviour === 'interactive') { + return 'interactivecountback'; + } + + return behaviour; + } + + /** + * Return the Component to use to display the question. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param {Injector} injector Injector. + * @param {any} question The question to render. + * @return {any|Promise} The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(injector: Injector, question: any): any | Promise { + return AddonQtypeDdMarkerComponent; + } + + /** + * Check if a response is complete. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if complete, 0 if not complete, -1 if cannot determine. + */ + isCompleteResponse(question: any, answers: any): number { + // If 1 dragitem is set we assume the answer is complete (like Moodle does). + for (const name in answers) { + if (answers[name]) { + return 1; + } + } + + return 0; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} True or promise resolved with true if enabled. + */ + isEnabled(): boolean | Promise { + return true; + } + + /** + * Check if a student has provided enough of an answer for the question to be graded automatically, + * or whether it must be considered aborted. + * + * @param {any} question The question. + * @param {any} answers Object with the question answers (without prefix). + * @return {number} 1 if gradable, 0 if not gradable, -1 if cannot determine. + */ + isGradableResponse(question: any, answers: any): number { + return this.isCompleteResponse(question, answers); + } + + /** + * Check if two responses are the same. + * + * @param {any} question Question. + * @param {any} prevAnswers Object with the previous question answers. + * @param {any} newAnswers Object with the new question answers. + * @return {boolean} Whether they're the same. + */ + isSameResponse(question: any, prevAnswers: any, newAnswers: any): boolean { + return this.questionProvider.compareAllAnswers(prevAnswers, newAnswers); + } +} diff --git a/src/addon/qtype/qtype.module.ts b/src/addon/qtype/qtype.module.ts index 513587617..f630d334e 100644 --- a/src/addon/qtype/qtype.module.ts +++ b/src/addon/qtype/qtype.module.ts @@ -16,6 +16,7 @@ import { NgModule } from '@angular/core'; import { AddonQtypeCalculatedModule } from './calculated/calculated.module'; import { AddonQtypeCalculatedMultiModule } from './calculatedmulti/calculatedmulti.module'; import { AddonQtypeCalculatedSimpleModule } from './calculatedsimple/calculatedsimple.module'; +import { AddonQtypeDdMarkerModule } from './ddmarker/ddmarker.module'; import { AddonQtypeDdwtosModule } from './ddwtos/ddwtos.module'; import { AddonQtypeDescriptionModule } from './description/description.module'; import { AddonQtypeEssayModule } from './essay/essay.module'; @@ -34,6 +35,7 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; AddonQtypeCalculatedModule, AddonQtypeCalculatedMultiModule, AddonQtypeCalculatedSimpleModule, + AddonQtypeDdMarkerModule, AddonQtypeDdwtosModule, AddonQtypeDescriptionModule, AddonQtypeEssayModule,