diff --git a/src/addon/qtype/ddimageortext/classes/ddimageortext.ts b/src/addon/qtype/ddimageortext/classes/ddimageortext.ts new file mode 100644 index 000000000..64123bee7 --- /dev/null +++ b/src/addon/qtype/ddimageortext/classes/ddimageortext.ts @@ -0,0 +1,772 @@ +// (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'; + +/** + * Encapsulates operations on dd area. + */ +export interface AddonQtypeDdImageOrTextQuestionDocStructure { + topNode?: () => HTMLElement; + dragItemsArea?: () => HTMLElement; + dragItems?: () => HTMLElement[]; + dropZones?: () => HTMLElement[]; + dropZoneGroup?: (groupNo: number) => HTMLElement[]; + dragItemsClonedFrom?: (dragItemNo: number) => HTMLElement[]; + dragItem?: (dragInstanceNo: number) => HTMLElement; + dragItemsInGroup?: (groupNo: number) => HTMLElement[]; + dragItemHomes?: () => HTMLElement[]; + bgImg?: () => HTMLImageElement; + dragItemHome?: (dragItemNo: number) => HTMLElement; + getClassnameNumericSuffix?: (node: HTMLElement, prefix: string) => number; + cloneNewDragItem?: (dragInstanceNo: number, dragItemNo: number) => HTMLElement; +} + +/** + * Class to make a question of ddimageortext type work. + */ +export class AddonQtypeDdImageOrTextQuestion { + protected logger: any; + protected toLoad = 0; + protected doc: AddonQtypeDdImageOrTextQuestionDocStructure; + protected afterImageLoadDone = false; + protected topNode: HTMLElement; + protected proportion = 1; + protected selected: HTMLElement; // Selected element (being "dragged"). + + /** + * Create the this. + * + * @param {CoreLoggerProvider} logger Logger provider. + * @param {CoreDomUtilsProvider} domUtils Dom Utils provider. + * @param {HTMLElement} container The container HTMLElement of the question. + * @param {any} question The question this. + * @param {boolean} readOnly Whether it's read only. + * @param {any[]} drops The drop zones received in the init object of the question. + */ + constructor(logger: CoreLoggerProvider, protected domUtils: CoreDomUtilsProvider, protected container: HTMLElement, + protected question: any, protected readOnly: boolean, protected drops: any[]) { + this.logger = logger.getInstance('AddonQtypeDdImageOrTextQuestion'); + + 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; + } + } + + /** + * 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] + 1, Number(bgImgXY[1]) + position[1] + 1]; + } + + /** + * Create and initialize all draggable elements and drop zones. + */ + createAllDragAndDrops(): void { + // Initialize drop zones. + this.initDrops(); + + // Initialize drag items area. + const dragItemsArea = this.doc.dragItemsArea(); + dragItemsArea.classList.add('clearfix'); + this.makeDragAreaClickable(); + + const dragItemHomes = this.doc.dragItemHomes(); + let i = 0; + + // Create the draggable items. + for (let x = 0; x < dragItemHomes.length; x++) { + + const dragItemHome = dragItemHomes[x], + dragItemNo = this.doc.getClassnameNumericSuffix(dragItemHome, 'dragitemhomes'), + choice = this.doc.getClassnameNumericSuffix(dragItemHome, 'choice'), + group = this.doc.getClassnameNumericSuffix(dragItemHome, 'group'); + + // Images need to be inside a div element to admit padding with width and height. + if (dragItemHome.tagName == 'IMG') { + const wrap = document.createElement('div'); + wrap.className = dragItemHome.className; + dragItemHome.className = ''; + + // Insert wrapper before the image in the DOM tree. + dragItemHome.parentNode.insertBefore(wrap, dragItemHome); + // Move the image into wrapper. + wrap.appendChild(dragItemHome); + } + + // Create a new drag item for this home. + const dragNode = this.doc.cloneNewDragItem(i, dragItemNo); + i++; + + if (!this.readOnly) { + // Make the item draggable. + this.draggableForQuestion(dragNode, group, choice); + } + + // If the draggable item needs to be created more than once, create the rest of copies. + if (dragNode.classList.contains('infinite')) { + const groupSize = this.doc.dropZoneGroup(group).length; + let dragsToCreate = groupSize - 1; + + while (dragsToCreate > 0) { + const newDragNode = this.doc.cloneNewDragItem(i, dragItemNo); + i++; + if (!this.readOnly) { + this.draggableForQuestion(newDragNode, group, choice); + } + dragsToCreate--; + } + } + } + + // All drag items have been created, position them. + this.repositionDragsForQuestion(); + + if (!this.readOnly) { + const dropZones = this.doc.dropZones(); + dropZones.forEach((dropZone) => { + dropZone.setAttribute('tabIndex', '0'); + }); + } + } + + /** + * Deselect all drags. + */ + 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 { + this.stopPolling(); + window.removeEventListener('resize', this.resizeFunction); + } + + /** + * Returns an object to encapsulate operations on dd area. + * + * @param {number} slot The question slot. + * @return {AddonQtypeDdImageOrTextQuestionDocStructure} The object. + */ + docStructure(slot: number): AddonQtypeDdImageOrTextQuestionDocStructure { + const topNode = this.container.querySelector(`#core-question-${slot} .addon-qtype-ddimageortext-container`), + dragItemsArea = topNode.querySelector('div.dragitems'), + doc: AddonQtypeDdImageOrTextQuestionDocStructure = {}; + + doc.topNode = (): HTMLElement => { + return topNode; + }; + doc.dragItemsArea = (): HTMLElement => { + return dragItemsArea; + }; + doc.dragItems = (): HTMLElement[] => { + return Array.from(dragItemsArea.querySelectorAll('.drag')); + }; + doc.dropZones = (): HTMLElement[] => { + return Array.from(topNode.querySelectorAll('div.dropzones div.dropzone')); + }; + doc.dropZoneGroup = (groupNo: number): HTMLElement[] => { + return Array.from(topNode.querySelectorAll('div.dropzones div.group' + groupNo)); + }; + doc.dragItemsClonedFrom = (dragItemNo: number): HTMLElement[] => { + return Array.from(dragItemsArea.querySelectorAll('.dragitems' + dragItemNo)); + }; + doc.dragItem = (dragInstanceNo: number): HTMLElement => { + return dragItemsArea.querySelector('.draginstance' + dragInstanceNo); + }; + doc.dragItemsInGroup = (groupNo: number): HTMLElement[] => { + return Array.from(dragItemsArea.querySelectorAll('.drag.group' + groupNo)); + }; + doc.dragItemHomes = (): HTMLElement[] => { + return Array.from(dragItemsArea.querySelectorAll('.draghome')); + }; + doc.bgImg = (): HTMLImageElement => { + return topNode.querySelector('.dropbackground'); + }; + doc.dragItemHome = (dragItemNo: number): HTMLElement => { + return dragItemsArea.querySelector('.dragitemhomes' + dragItemNo); + }; + doc.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.'); + }; + doc.cloneNewDragItem = (dragInstanceNo: number, dragItemNo: number): HTMLElement => { + const dragHome = doc.dragItemHome(dragItemNo); + if (dragHome === null) { + return null; + } + + const dragHomeImg = dragHome.querySelector('img'); + let divDrag: HTMLElement; + + // Images need to be inside a div element to admit padding with width and height. + if (dragHomeImg) { + // Clone the image. + const drag = dragHomeImg.cloneNode(true); + + // Create a div and put the image in it. + divDrag = document.createElement('div'); + divDrag.appendChild(drag); + divDrag.className = dragHome.className; + drag.className = ''; + } else { + // The drag item doesn't have an image, just clone it. + divDrag = dragHome.cloneNode(true); + } + + // Set the right classes and styles. + divDrag.classList.remove('dragitemhomes' + dragItemNo); + divDrag.classList.remove('draghome'); + divDrag.classList.add('dragitems' + dragItemNo); + divDrag.classList.add('draginstance' + dragInstanceNo); + divDrag.classList.add('drag'); + + divDrag.style.visibility = 'inherit'; + divDrag.style.position = 'absolute'; + divDrag.setAttribute('draginstanceno', String(dragInstanceNo)); + divDrag.setAttribute('dragitemno', String(dragItemNo)); + + // Insert the new drag after the dragHome. + dragHome.parentElement.insertBefore(divDrag, dragHome.nextSibling); + + return divDrag; + }; + + return doc; + } + + /** + * Make an element draggable. + * + * @param {HTMLElement} drag Element to make draggable. + * @param {number} group Group the element belongs to. + * @param {number} choice Choice the element belongs to. + */ + draggableForQuestion(drag: HTMLElement, group: number, choice: number): void { + // Set attributes. + drag.setAttribute('group', String(group)); + drag.setAttribute('choice', String(choice)); + + // Listen to click events. + drag.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (drag.classList.contains('beingdragged')) { + this.deselectDrags(); + } else { + this.selectDrag(drag); + } + }); + } + + /** + * Function called when a drop zone is clicked. + * + * @param {HTMLElement} dropNode Drop element. + */ + dropClick(dropNode: HTMLElement): void { + const drag = this.selected; + if (!drag) { + // No selected item, nothing to do. + return; + } + + // Deselect the drag and place it in the position of this drop zone if it belongs to the same group. + this.deselectDrags(); + + if (Number(dropNode.getAttribute('group')) === Number(drag.getAttribute('group'))) { + this.placeDragInDrop(drag, dropNode); + } + } + + /** + * Get all the draggable elements for a choice and a drop zone. + * + * @param {number} choice Choice number. + * @param {HTMLElement} drop Drop zone. + * @return {HTMLElement[]} Draggable elements. + */ + getChoicesForDrop(choice: number, drop: HTMLElement): HTMLElement[] { + return Array.from(this.doc.topNode().querySelectorAll( + 'div.dragitemgroup' + drop.getAttribute('group') + ' .choice' + choice + '.drag')); + } + + /** + * Get an unplaced draggable element that belongs to a certain choice and drop zone. + * + * @param {number} choice Choice number. + * @param {HTMLElement} drop Drop zone. + * @return {HTMLElement} Unplaced draggable element. + */ + getUnplacedChoiceForDrop(choice: number, drop: HTMLElement): HTMLElement { + const dragItems = this.getChoicesForDrop(choice, drop); + + for (let x = 0; x < dragItems.length; x++) { + const dragItem = dragItems[x]; + if (this.readOnly || (!dragItem.classList.contains('placed') && !dragItem.classList.contains('beingdragged'))) { + return dragItem; + } + } + + return null; + } + + /** + * Initialize drop zones. + */ + initDrops(): void { + const dropAreas = this.doc.topNode().querySelector('div.dropzones'), + groupNodes = {}; + + // Create all group nodes and add them to the drop area. + for (let groupNo = 1; groupNo <= 8; groupNo++) { + const groupNode = document.createElement('div'); + groupNode.className = 'dropzonegroup' + groupNo; + + dropAreas.appendChild(groupNode); + groupNodes[groupNo] = groupNode; + } + + // Create the drops specified by the init object. + for (const dropNo in this.drops) { + const drop = this.drops[dropNo], + nodeClass = 'dropzone group' + drop.group + ' place' + dropNo, + title = drop.text.replace('"', '\"'), + dropNode = document.createElement('div'); + + dropNode.setAttribute('title', title); + dropNode.className = nodeClass; + + groupNodes[drop.group].appendChild(dropNode); + dropNode.style.opacity = '0.5'; + dropNode.setAttribute('xy', drop.xy); + dropNode.setAttribute('aria-label', drop.text); + dropNode.setAttribute('place', dropNo); + dropNode.setAttribute('inputid', drop.fieldname.replace(':', '_')); + dropNode.setAttribute('group', drop.group); + + dropNode.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + this.dropClick(dropNode); + }); + } + } + + /** + * Initialize the question. + * + * @param {any} question Question. + */ + initializer(question: any): void { + this.doc = this.docStructure(question.slot); + + if (this.readOnly) { + const container = this.doc.topNode(); + container.classList.add('readonly'); + } + + // Wait the DOM to be rendered. + setTimeout(() => { + const bgImg = this.doc.bgImg(); + + // Wait for background image to be loaded. + // On iOS, complete is mistakenly true, check also naturalWidth for compatibility. + if (!bgImg.complete || !bgImg.naturalWidth) { + this.toLoad++; + bgImg.addEventListener('load', () => { + this.toLoad--; + }); + } + + const itemHomes = this.doc.dragItemHomes(); + itemHomes.forEach((item) => { + if (item.tagName == 'IMG') { + // Wait for drag images to be loaded. + // On iOS, complete is mistakenly true, check also naturalWidth for compatibility. + const itemImg = item; + + if (!itemImg.complete || !itemImg.naturalWidth) { + this.toLoad++; + itemImg.addEventListener('load', () => { + this.toLoad--; + }); + } + } + }); + + this.pollForImageLoad(); + }); + + window.addEventListener('resize', this.resizeFunction); + } + + /** + * Make the drag items area clickable. + */ + makeDragAreaClickable(): void { + if (this.readOnly) { + return; + } + + const home = this.doc.dragItemsArea(); + home.addEventListener('click', (e) => { + const drag = this.selected; + if (!drag) { + // No element selected, nothing to do. + return false; + } + + // An element was selected. Deselect it and move it back to the area if needed. + this.deselectDrags(); + this.removeDragFromDrop(drag); + + e.preventDefault(); + e.stopPropagation(); + }); + } + + /** + * Place a draggable element into a certain drop zone. + * + * @param {HTMLElement} drag Draggable element. + * @param {HTMLElement} drop Drop zone element. + */ + placeDragInDrop(drag: HTMLElement, drop: HTMLElement): void { + // Search the input related to the drop zone. + const targetInputId = drop.getAttribute('inputid'), + inputNode = this.doc.topNode().querySelector('input#' + targetInputId); + + // Check if the draggable item is already assigned to an input and if it's the same as the one of the drop zone. + const originInputId = drag.getAttribute('inputid'); + if (originInputId && originInputId != targetInputId) { + // Remove it from the previous place. + const originInputNode = this.doc.topNode().querySelector('input#' + originInputId); + originInputNode.setAttribute('value', ''); + } + + // Now position the draggable and set it to the input. + const position = this.domUtils.getElementXY(drop, null, 'ddarea'); + drag.style.left = position[0] - 1 + 'px'; + drag.style.top = position[1] - 1 + 'px'; + drag.classList.add('placed'); + + inputNode.setAttribute('value', drag.getAttribute('choice')); + drag.setAttribute('inputid', targetInputId); + } + + /** + * Wait for images to be loaded. + */ + pollForImageLoad(): void { + if (this.afterImageLoadDone) { + // Already done, stop. + return; + } + + if (this.toLoad <= 0) { + // All images loaded. + this.createAllDragAndDrops(); + this.afterImageLoadDone = true; + this.question.loaded = true; + } + + // Try again after a while. + setTimeout(() => { + this.pollForImageLoad(); + }, 1000); + } + + /** + * Remove a draggable element from the drop zone where it is. + * + * @param {HTMLElement} drag Draggable element to remove. + */ + removeDragFromDrop(drag: HTMLElement): void { + // Check if the draggable element is assigned to an input. If so, empty the input's value. + const inputId = drag.getAttribute('inputid'); + if (inputId) { + const inputNode = this.doc.topNode().querySelector('input#' + inputId); + inputNode.setAttribute('value', ''); + } + + // Move the element to its original position. + const dragItemHome = this.doc.dragItemHome(Number(drag.getAttribute('dragitemno'))), + position = this.domUtils.getElementXY(dragItemHome, null, 'ddarea'); + + drag.style.left = position[0] + 'px'; + drag.style.top = position[1] + 'px'; + drag.classList.remove('placed'); + + drag.setAttribute('inputid', ''); + } + + /** + * Reposition all the draggable elements and drop zones. + */ + repositionDragsForQuestion(): void { + const dragItems = this.doc.dragItems(); + + // Mark all draggable items as "unplaced", they will be placed again later. + dragItems.forEach((dragItem) => { + dragItem.classList.remove('placed'); + dragItem.setAttribute('inputid', ''); + }); + + // Calculate the proportion to apply to images. + this.calculateImgProportion(); + + // Apply the proportion to all images in drag item homes. + const dragItemHomes = this.doc.dragItemHomes(); + for (let x = 0; x < dragItemHomes.length; x++) { + const dragItemHome = dragItemHomes[x], + dragItemHomeImg = dragItemHome.querySelector('img'); + + if (dragItemHomeImg && dragItemHomeImg.naturalWidth > 0) { + const widthHeight = [Math.round(dragItemHomeImg.naturalWidth * this.proportion), + Math.round(dragItemHomeImg.naturalHeight * this.proportion)]; + + dragItemHomeImg.style.width = widthHeight[0] + 'px'; + dragItemHomeImg.style.height = widthHeight[1] + 'px'; + + // Apply the proportion to all the images cloned from this home. + const dragItemNo = this.doc.getClassnameNumericSuffix(dragItemHome, 'dragitemhomes'), + groupNo = this.doc.getClassnameNumericSuffix(dragItemHome, 'group'), + dragsImg = Array.from(this.doc.topNode().querySelectorAll( + '.drag.group' + groupNo + '.dragitems' + dragItemNo + ' img')); + + dragsImg.forEach((dragImg) => { + dragImg.style.width = widthHeight[0] + 'px'; + dragImg.style.height = widthHeight[1] + 'px'; + }); + } + } + + // Update the padding of all draggable elements. + this.updatePaddingSizesAll(); + + const dropZones = this.doc.dropZones(); + for (let x = 0; x < dropZones.length; x++) { + // Re-position the drop zone based on the proportion. + const dropZone = dropZones[x], + dropZoneXY = dropZone.getAttribute('xy').split(',').map((i) => { + return Number(i); + }), + relativeXY = this.convertToWindowXY(dropZoneXY); + + dropZone.style.left = relativeXY[0] + 'px'; + dropZone.style.top = relativeXY[1] + 'px'; + + // Re-place items got from the inputs. + const inputCss = 'input#' + dropZone.getAttribute('inputid'), + input = this.doc.topNode().querySelector(inputCss), + choice = Number(input.value); + + if (choice > 0) { + const dragItem = this.getUnplacedChoiceForDrop(choice, dropZone); + if (dragItem !== null) { + this.placeDragInDrop(dragItem, dropZone); + } + } + } + + // Re-place draggable items not placed drop zones (they will be placed in the original position). + for (let x = 0; x < dragItems.length; x++) { + const dragItem = dragItems[x]; + if (!dragItem.classList.contains('placed') && !dragItem.classList.contains('beingdragged')) { + this.removeDragFromDrop(dragItem); + } + } + } + + /** + * Function to call when the window is resized. + */ + resizeFunction(): void { + this.repositionDragsForQuestion(); + } + + /** + * Mark a draggable element as selected. + * + * @param {HTMLElement} drag Element to select. + */ + selectDrag(drag: HTMLElement): void { + // Deselect previous ones. + this.deselectDrags(); + + this.selected = drag; + drag.classList.add('beingdragged'); + } + + /** + * Stop waiting for images to be loaded. + */ + stopPolling(): void { + this.afterImageLoadDone = true; + } + + /** + * Update the padding of all items in a group to make them all have the same width and height. + * + * @param {number} groupNo The group number. + */ + updatePaddingSizeForGroup(groupNo: number): void { + + // Get all the items for this group. + const groupItems = Array.from(this.doc.topNode().querySelectorAll('.draghome.group' + groupNo)); + if (groupItems.length !== 0) { + + // Get the max width and height of the items. + let maxWidth = 0, + maxHeight = 0; + + for (let x = 0; x < groupItems.length; x++) { + // Check if the item has an img. + const item = groupItems[x], + img = item.querySelector('img'); + + if (img) { + maxWidth = Math.max(maxWidth, Math.round(this.proportion * img.naturalWidth)); + maxHeight = Math.max(maxHeight, Math.round(this.proportion * img.naturalHeight)); + } else { + // Remove the padding to calculate the size. + const originalPadding = item.style.padding; + item.style.padding = ''; + + // Text is not affected by the proportion. + maxWidth = Math.max(maxWidth, Math.round(item.clientWidth)); + maxHeight = Math.max(maxHeight, Math.round(item.clientHeight)); + + // Restore the padding. + item.style.padding = originalPadding; + } + } + + if (maxWidth <= 0 || maxHeight <= 0) { + return; + } + + // Add a variable padding to the image or text. + maxWidth = Math.round(maxWidth + this.proportion * 8); + maxHeight = Math.round(maxHeight + this.proportion * 8); + + for (let x = 0; x < groupItems.length; x++) { + // Check if the item has an img and calculate its width and height. + const item = groupItems[x], + img = item.querySelector('img'); + let width, height; + + if (img) { + width = Math.round(img.naturalWidth * this.proportion); + height = Math.round(img.naturalHeight * this.proportion); + } else { + // Remove the padding to calculate the size. + const originalPadding = item.style.padding; + item.style.padding = ''; + + // Text is not affected by the proportion. + width = Math.round(item.clientWidth); + height = Math.round(item.clientHeight); + + // Restore the padding. + item.style.padding = originalPadding; + } + + // Now set the right padding to make this item have the max height and width. + const marginTopBottom = Math.round((maxHeight - height) / 2), + marginLeftRight = Math.round((maxWidth - width) / 2); + + // Correction for the roundings. + const widthCorrection = maxWidth - (width + marginLeftRight * 2), + heightCorrection = maxHeight - (height + marginTopBottom * 2); + + item.style.padding = marginTopBottom + 'px ' + marginLeftRight + 'px ' + + (marginTopBottom + heightCorrection) + 'px ' + (marginLeftRight + widthCorrection) + 'px'; + + const dragItemNo = this.doc.getClassnameNumericSuffix(item, 'dragitemhomes'), + drags = Array.from(this.doc.topNode().querySelectorAll( + '.drag.group' + groupNo + '.dragitems' + dragItemNo)); + + drags.forEach((drag) => { + drag.style.padding = marginTopBottom + 'px ' + marginLeftRight + 'px ' + + (marginTopBottom + heightCorrection) + 'px ' + (marginLeftRight + widthCorrection) + 'px'; + }); + } + + // It adds the border of 1px to the width. + const zoneGroups = this.doc.dropZoneGroup(groupNo); + zoneGroups.forEach((zone) => { + zone.style.width = maxWidth + 2 + 'px '; + zone.style.height = maxHeight + 2 + 'px '; + }); + } + } + + /** + * Update the padding of all items in all groups. + */ + updatePaddingSizesAll(): void { + for (let groupNo = 1; groupNo <= 8; groupNo++) { + this.updatePaddingSizeForGroup(groupNo); + } + } +} diff --git a/src/addon/qtype/ddimageortext/component/ddimageortext.html b/src/addon/qtype/ddimageortext/component/ddimageortext.html new file mode 100644 index 000000000..b6394b880 --- /dev/null +++ b/src/addon/qtype/ddimageortext/component/ddimageortext.html @@ -0,0 +1,13 @@ +
+ + + + +

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

+

+ +
+
diff --git a/src/addon/qtype/ddimageortext/component/ddimageortext.ts b/src/addon/qtype/ddimageortext/component/ddimageortext.ts new file mode 100644 index 000000000..cb96133bd --- /dev/null +++ b/src/addon/qtype/ddimageortext/component/ddimageortext.ts @@ -0,0 +1,93 @@ +// (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 { AddonQtypeDdImageOrTextQuestion } from '../classes/ddimageortext'; + +/** + * Component to render a drag-and-drop onto image question. + */ +@Component({ + selector: 'addon-qtype-ddimageortext', + templateUrl: 'ddimageortext.html' +}) +export class AddonQtypeDdImageOrTextComponent extends CoreQuestionBaseComponent implements OnInit, AfterViewInit, OnDestroy { + + protected element: HTMLElement; + protected questionInstance: AddonQtypeDdImageOrTextQuestion; + protected drops: any[]; // The drop zones received in the init object of the question. + + constructor(logger: CoreLoggerProvider, injector: Injector, element: ElementRef) { + super(logger, 'AddonQtypeDdImageOrTextComponent', 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 and question text. + const ddArea = div.querySelector('.ddarea'); + + this.question.text = this.domUtils.getContentsOfElement(div, '.qtext'); + if (!ddArea || typeof this.question.text == 'undefined') { + this.logger.warn('Aborting because of an error parsing question.', this.question.name); + + return this.questionHelper.showComponentError(this.onAbort); + } + + // Set the D&D area HTML. + this.question.ddArea = ddArea.outerHTML; + this.question.readOnly = false; + + if (this.question.initObjects) { + if (typeof this.question.initObjects.drops != 'undefined') { + this.drops = this.question.initObjects.drops; + } + 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 AddonQtypeDdImageOrTextQuestion(this.logger, this.domUtils, this.element, + this.question, this.question.readOnly, this.drops); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.questionInstance && this.questionInstance.destroy(); + } +} diff --git a/src/addon/qtype/ddimageortext/ddimageortext.module.ts b/src/addon/qtype/ddimageortext/ddimageortext.module.ts new file mode 100644 index 000000000..82ec397bb --- /dev/null +++ b/src/addon/qtype/ddimageortext/ddimageortext.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 { AddonQtypeDdImageOrTextHandler } from './providers/handler'; +import { AddonQtypeDdImageOrTextComponent } from './component/ddimageortext'; + +@NgModule({ + declarations: [ + AddonQtypeDdImageOrTextComponent + ], + imports: [ + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule + ], + providers: [ + AddonQtypeDdImageOrTextHandler + ], + exports: [ + AddonQtypeDdImageOrTextComponent + ], + entryComponents: [ + AddonQtypeDdImageOrTextComponent + ] +}) +export class AddonQtypeDdImageOrTextModule { + constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeDdImageOrTextHandler) { + questionDelegate.registerHandler(handler); + } +} diff --git a/src/addon/qtype/ddimageortext/providers/handler.ts b/src/addon/qtype/ddimageortext/providers/handler.ts new file mode 100644 index 000000000..6cdf63057 --- /dev/null +++ b/src/addon/qtype/ddimageortext/providers/handler.ts @@ -0,0 +1,118 @@ + +// (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 { AddonQtypeDdImageOrTextComponent } from '../component/ddimageortext'; + +/** + * Handler to support drag-and-drop onto image question type. + */ +@Injectable() +export class AddonQtypeDdImageOrTextHandler implements CoreQuestionHandler { + name = 'AddonQtypeDdImageOrText'; + type = 'qtype_ddimageortext'; + + 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 AddonQtypeDdImageOrTextComponent; + } + + /** + * 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 { + // An answer is complete if all drop zones have an answer. + // We should always receive all the drop zones with their value ('' if not answered). + for (const name in answers) { + const value = answers[name]; + if (!value || value === '0') { + return 0; + } + } + + return 1; + } + + /** + * 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 { + for (const name in answers) { + const value = answers[name]; + if (value && value !== '0') { + return 1; + } + } + + return 0; + } + + /** + * 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/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/ddwtos/classes/ddwtos.ts b/src/addon/qtype/ddwtos/classes/ddwtos.ts new file mode 100644 index 000000000..648c907fa --- /dev/null +++ b/src/addon/qtype/ddwtos/classes/ddwtos.ts @@ -0,0 +1,550 @@ +// (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'; + +/** + * Set of functions to get the CSS selectors. + */ +export interface AddonQtypeDdwtosQuestionCSSSelectors { + topNode?: () => string; + dragContainer?: () => string; + drags?: () => string; + drag?: (no: number) => string; + dragsInGroup?: (groupNo: number) => string; + unplacedDragsInGroup?: (groupNo: number) => string; + dragsForChoiceInGroup?: (choiceNo: number, groupNo: number) => string; + unplacedDragsForChoiceInGroup?: (choiceNo: number, groupNo: number) => string; + drops?: () => string; + dropForPlace?: (placeNo: number) => string; + dropsInGroup?: (groupNo: number) => string; + dragHomes?: () => string; + dragHomesGroup?: (groupNo: number) => string; + dragHome?: (groupNo: number, choiceNo: number) => string; + dropsGroup?: (groupNo: number) => string; +} + +/** + * Class to make a question of ddwtos type work. + */ +export class AddonQtypeDdwtosQuestion { + + protected logger: any; + protected nextDragItemNo = 1; + protected selectors: AddonQtypeDdwtosQuestionCSSSelectors; // Result of cssSelectors. + protected placed: {[no: number]: number}; // Map that relates drag elements numbers with drop zones numbers. + protected selected: HTMLElement; // Selected element (being "dragged"). + + /** + * Create the instance. + * + * @param {CoreLoggerProvider} logger Logger provider. + * @param {CoreDomUtilsProvider} domUtils Dom 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 {string[]} inputIds Ids of the inputs of the question (where the answers will be stored). + */ + constructor(logger: CoreLoggerProvider, protected domUtils: CoreDomUtilsProvider, protected container: HTMLElement, + protected question: any, protected readOnly: boolean, protected inputIds: string[]) { + this.logger = logger.getInstance('AddonQtypeDdwtosQuestion'); + + this.initializer(question); + } + + /** + * Clone a drag item and add it to the drag container. + * + * @param {HTMLElement} dragHome Item to clone + */ + cloneDragItem(dragHome: HTMLElement): void { + const drag = dragHome.cloneNode(true); + + drag.classList.remove('draghome'); + drag.classList.add('drag'); + drag.classList.add('no' + this.nextDragItemNo); + this.nextDragItemNo++; + + drag.style.visibility = 'visible'; + drag.style.position = 'absolute'; + + const container = this.container.querySelector(this.selectors.dragContainer()); + container.appendChild(drag); + + if (!this.readOnly) { + this.makeDraggable(drag); + } + } + + /** + * Clone the 'drag homes'. + * Invisible 'drag homes' are output in the question. These have the same properties as the drag items but are invisible. + * We clone these invisible elements to make the actual drag items. + */ + cloneDragItems(): void { + const dragHomes = Array.from(this.container.querySelectorAll(this.selectors.dragHomes())); + for (let x = 0; x < dragHomes.length; x++) { + this.cloneDragItemsForOneChoice(dragHomes[x]); + } + } + + /** + * Clone a certain 'drag home'. If it's an "infinite" drag, clone it several times. + * + * @param {HTMLElement} dragHome Element to clone. + */ + cloneDragItemsForOneChoice(dragHome: HTMLElement): void { + if (dragHome.classList.contains('infinite')) { + const groupNo = this.getGroup(dragHome), + noOfDrags = this.container.querySelectorAll(this.selectors.dropsInGroup(groupNo)).length; + + for (let x = 0; x < noOfDrags; x++) { + this.cloneDragItem(dragHome); + } + } else { + this.cloneDragItem(dragHome); + } + } + + /** + * Get an object with a set of functions to get the CSS selectors. + * + * @param {number} slot Question slot. + * @return {AddonQtypeDdwtosQuestionCSSSelectors} Object with the functions to get the selectors. + */ + cssSelectors(slot: number): AddonQtypeDdwtosQuestionCSSSelectors { + const topNode = '#core-question-' + slot + ' .addon-qtype-ddwtos-container', + selectors: AddonQtypeDdwtosQuestionCSSSelectors = {}; + + selectors.topNode = (): string => { + return topNode; + }; + selectors.dragContainer = (): string => { + return topNode + ' div.drags'; + }; + selectors.drags = (): string => { + return selectors.dragContainer() + ' span.drag'; + }; + selectors.drag = (no: number): string => { + return selectors.drags() + '.no' + no; + }; + selectors.dragsInGroup = (groupNo: number): string => { + return selectors.drags() + '.group' + groupNo; + }; + selectors.unplacedDragsInGroup = (groupNo: number): string => { + return selectors.dragsInGroup(groupNo) + '.unplaced'; + }; + selectors.dragsForChoiceInGroup = (choiceNo: number, groupNo: number): string => { + return selectors.dragsInGroup(groupNo) + '.choice' + choiceNo; + }; + selectors.unplacedDragsForChoiceInGroup = (choiceNo: number, groupNo: number): string => { + return selectors.unplacedDragsInGroup(groupNo) + '.choice' + choiceNo; + }; + selectors.drops = (): string => { + return topNode + ' span.drop'; + }; + selectors.dropForPlace = (placeNo: number): string => { + return selectors.drops() + '.place' + placeNo; + }; + selectors.dropsInGroup = (groupNo: number): string => { + return selectors.drops() + '.group' + groupNo; + }; + selectors.dragHomes = (): string => { + return topNode + ' span.draghome'; + }; + selectors.dragHomesGroup = (groupNo: number): string => { + return topNode + ' .draggrouphomes' + groupNo + ' span.draghome'; + }; + selectors.dragHome = (groupNo: number, choiceNo: number): string => { + return topNode + ' .draggrouphomes' + groupNo + ' span.draghome.choice' + choiceNo; + }; + selectors.dropsGroup = (groupNo: number): string => { + return topNode + ' span.drop.group' + groupNo; + }; + + return selectors; + } + + /** + * Deselect all drags. + */ + deselectDrags(): void { + // Remove the selected class from all drags. + const drags = Array.from(this.container.querySelectorAll(this.selectors.drags())); + drags.forEach((drag) => { + drag.classList.remove('selected'); + }); + this.selected = null; + } + + /** + * Function to call when the instance is no longer needed. + */ + destroy(): void { + window.removeEventListener('resize', this.resizeFunction); + } + + /** + * Get the choice number of an element. It is extracted from the classes. + * + * @param {HTMLElement} node Element to check. + * @return {number} Choice number. + */ + getChoice(node: HTMLElement): number { + return this.getClassnameNumericSuffix(node, 'choice'); + } + + /** + * Get the number in a certain class name of an element. + * + * @param {HTMLElement} node The element to check. + * @param {string} prefix Prefix of the class to check. + * @return {number} The number in the class. + */ + 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.'); + } + + /** + * Get the group number of an element. It is extracted from the classes. + * + * @param {HTMLElement} node Element to check. + * @return {number} Group number. + */ + getGroup(node: HTMLElement): number { + return this.getClassnameNumericSuffix(node, 'group'); + } + + /** + * Get the number of an element ('no'). It is extracted from the classes. + * + * @param {HTMLElement} node Element to check. + * @return {number} Number. + */ + getNo(node: HTMLElement): number { + return this.getClassnameNumericSuffix(node, 'no'); + } + + /** + * Get the place number of an element. It is extracted from the classes. + * + * @param {HTMLElement} node Element to check. + * @return {number} Place number. + */ + getPlace(node: HTMLElement): number { + return this.getClassnameNumericSuffix(node, 'place'); + } + + /** + * Initialize the question. + * + * @param {any} question Question. + */ + initializer(question: any): void { + this.selectors = this.cssSelectors(question.slot); + + const container = this.container.querySelector(this.selectors.topNode()); + if (this.readOnly) { + container.classList.add('readonly'); + } else { + container.classList.add('notreadonly'); + } + + this.setPaddingSizesAll(); + this.cloneDragItems(); + this.initialPlaceOfDragItems(); + this.makeDropZones(); + + // Wait the DOM to be rendered. + setTimeout(() => { + this.positionDragItems(); + }); + + window.addEventListener('resize', this.resizeFunction); + } + + /** + * Initialize drag items, putting them in their initial place. + */ + initialPlaceOfDragItems(): void { + const drags = Array.from(this.container.querySelectorAll(this.selectors.drags())); + + // Add the class 'unplaced' to all elements. + drags.forEach((drag) => { + drag.classList.add('unplaced'); + }); + + this.placed = {}; + for (const placeNo in this.inputIds) { + const inputId = this.inputIds[placeNo], + inputNode = this.container.querySelector('input#' + inputId), + choiceNo = Number(inputNode.getAttribute('value')); + + if (choiceNo !== 0) { + const drop = this.container.querySelector(this.selectors.dropForPlace(parseInt(placeNo, 10) + 1)), + groupNo = this.getGroup(drop), + drag = this.container.querySelector( + this.selectors.unplacedDragsForChoiceInGroup(choiceNo, groupNo)); + + this.placeDragInDrop(drag, drop); + this.positionDragItem(drag); + } + } + } + + /** + * Make an element "draggable". In the mobile app, items are "dragged" using tap and drop. + * + * @param {HTMLElement} drag Element. + */ + makeDraggable(drag: HTMLElement): void { + drag.addEventListener('click', () => { + if (drag.classList.contains('selected')) { + this.deselectDrags(); + } else { + this.selectDrag(drag); + } + }); + } + + /** + * Convert an element into a drop zone. + * + * @param {HTMLElement} drop Element. + */ + makeDropZone(drop: HTMLElement): void { + drop.addEventListener('click', () => { + const drag = this.selected; + if (!drag) { + // No element selected, nothing to do. + return false; + } + + // Place it only if the same group is selected. + if (this.getGroup(drag) === this.getGroup(drop)) { + this.placeDragInDrop(drag, drop); + this.deselectDrags(); + this.positionDragItem(drag); + } + }); + } + + /** + * Create all drop zones. + */ + makeDropZones(): void { + if (this.readOnly) { + return; + } + + // Create all the drop zones. + const drops = Array.from(this.container.querySelectorAll(this.selectors.drops())); + drops.forEach((drop) => { + this.makeDropZone(drop); + }); + + // If home answer zone is clicked, return drag home. + const home = this.container.querySelector(this.selectors.topNode() + ' .answercontainer'); + + home.addEventListener('click', () => { + const drag = this.selected; + if (!drag) { + // No element selected, nothing to do. + return; + } + + // Not placed yet, deselect. + if (drag.classList.contains('unplaced')) { + this.deselectDrags(); + + return; + } + + // Remove, deselect and move back home in this order. + this.removeDragFromDrop(drag); + this.deselectDrags(); + this.positionDragItem(drag); + }); + } + + /** + * Set the width and height of an element. + * + * @param {HTMLElement} node Element. + * @param {number} width Width to set. + * @param {number} height Height to set. + */ + protected padToWidthHeight(node: HTMLElement, width: number, height: number): void { + node.style.width = width + 'px'; + node.style.height = height + 'px'; + node.style.lineHeight = height + 'px'; + } + + /** + * Place a draggable element inside a drop zone. + * + * @param {HTMLElement} drag Draggable element. + * @param {HTMLElement} drop Drop zone. + */ + placeDragInDrop(drag: HTMLElement, drop: HTMLElement): void { + + const placeNo = this.getPlace(drop), + inputId = this.inputIds[placeNo - 1], + inputNode = this.container.querySelector('input#' + inputId); + + // Set the value of the drag element in the input of the drop zone. + if (drag !== null) { + inputNode.setAttribute('value', String(this.getChoice(drag))); + } else { + inputNode.setAttribute('value', '0'); + } + + // Remove the element from the "placed" map if it's there. + for (const alreadyThereDragNo in this.placed) { + if (this.placed[alreadyThereDragNo] === placeNo) { + delete this.placed[alreadyThereDragNo]; + } + } + + if (drag !== null) { + // Add the element in the "placed" map. + this.placed[this.getNo(drag)] = placeNo; + } + } + + /** + * Position a drag element in the right drop zone or in the home zone. + * + * @param {HTMLElement} drag Drag element. + */ + positionDragItem(drag: HTMLElement): void { + let position; + + const placeNo = this.placed[this.getNo(drag)]; + if (!placeNo) { + // Not placed, put it in home zone. + const groupNo = this.getGroup(drag), + choiceNo = this.getChoice(drag); + + position = this.domUtils.getElementXY(this.container, this.selectors.dragHome(groupNo, choiceNo), 'answercontainer'); + drag.classList.add('unplaced'); + } else { + // Get the drop zone position. + position = this.domUtils.getElementXY(this.container, this.selectors.dropForPlace(placeNo), + 'addon-qtype-ddwtos-container'); + drag.classList.remove('unplaced'); + } + + if (position) { + drag.style.left = position[0] + 'px'; + drag.style.top = position[1] + 'px'; + } + } + + /** + * Postition, or reposition, all the drag items. They're placed in the right drop zone or in the home zone. + */ + positionDragItems(): void { + const drags = Array.from(this.container.querySelectorAll(this.selectors.drags())); + drags.forEach((drag) => { + this.positionDragItem(drag); + }); + } + + /** + * Remove a draggable element from a drop zone. + * + * @param {HTMLElement} drag The draggable element. + */ + removeDragFromDrop(drag: HTMLElement): void { + const placeNo = this.placed[this.getNo(drag)], + drop = this.container.querySelector(this.selectors.dropForPlace(placeNo)); + + this.placeDragInDrop(null, drop); + } + + /** + * Function to call when the window is resized. + */ + resizeFunction(): void { + this.positionDragItems(); + } + + /** + * Select a certain element as being "dragged". + * + * @param {HTMLElement} drag Element. + */ + selectDrag(drag: HTMLElement): void { + // Deselect previous drags, only 1 can be selected. + this.deselectDrags(); + + this.selected = drag; + drag.classList.add('selected'); + } + + /** + * Set the padding size for all groups. + */ + setPaddingSizesAll(): void { + for (let groupNo = 1; groupNo <= 8; groupNo++) { + this.setPaddingSizeForGroup(groupNo); + } + } + + /** + * Set the padding size for a certain group. + * + * @param {number} groupNo Group number. + */ + setPaddingSizeForGroup(groupNo: number): void { + const groupItems = Array.from(this.container.querySelectorAll(this.selectors.dragHomesGroup(groupNo))); + + if (groupItems.length !== 0) { + let maxWidth = 0, + maxHeight = 0; + + // Find max height and width. + groupItems.forEach((item) => { + maxWidth = Math.max(maxWidth, Math.ceil(item.offsetWidth)); + maxHeight = Math.max(maxHeight, Math.ceil(item.offsetHeight)); + }); + + maxWidth += 8; + maxHeight += 2; + groupItems.forEach((item) => { + this.padToWidthHeight(item, maxWidth, maxHeight); + }); + + const dropsGroup = Array.from(this.container.querySelectorAll(this.selectors.dropsGroup(groupNo))); + dropsGroup.forEach((item) => { + this.padToWidthHeight(item, maxWidth + 2, maxHeight + 2); + }); + } + } +} diff --git a/src/addon/qtype/ddwtos/component/ddwtos.html b/src/addon/qtype/ddwtos/component/ddwtos.html new file mode 100644 index 000000000..689a75406 --- /dev/null +++ b/src/addon/qtype/ddwtos/component/ddwtos.html @@ -0,0 +1,11 @@ +
+ +

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

+

+ +
+
+
diff --git a/src/addon/qtype/ddwtos/component/ddwtos.ts b/src/addon/qtype/ddwtos/component/ddwtos.ts new file mode 100644 index 000000000..18a4a121a --- /dev/null +++ b/src/addon/qtype/ddwtos/component/ddwtos.ts @@ -0,0 +1,101 @@ +// (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 { AddonQtypeDdwtosQuestion } from '../classes/ddwtos'; + +/** + * Component to render a drag-and-drop words into sentences question. + */ +@Component({ + selector: 'addon-qtype-ddwtos', + templateUrl: 'ddwtos.html' +}) +export class AddonQtypeDdwtosComponent extends CoreQuestionBaseComponent implements OnInit, AfterViewInit, OnDestroy { + + protected element: HTMLElement; + protected questionInstance: AddonQtypeDdwtosQuestion; + protected inputIds: string[]; // Ids of the inputs of the question (where the answers will be stored). + + constructor(logger: CoreLoggerProvider, injector: Injector, element: ElementRef) { + super(logger, 'AddonQtypeDdwtosComponent', 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; + + // Replace Moodle's correct/incorrect and feedback classes with our own. + this.questionHelper.replaceCorrectnessClasses(div); + this.questionHelper.replaceFeedbackClasses(div); + + // Treat the correct/incorrect icons. + this.questionHelper.treatCorrectnessIcons(div, this.component, this.componentId); + + const answerContainer = div.querySelector('.answercontainer'); + if (!answerContainer) { + this.logger.warn('Aborting because of an error parsing question.', this.question.name); + + return this.questionHelper.showComponentError(this.onAbort); + } + + this.question.readOnly = answerContainer.classList.contains('readonly'); + this.question.answers = answerContainer.outerHTML; + + this.question.text = this.domUtils.getContentsOfElement(div, '.qtext'); + if (typeof this.question.text == 'undefined') { + this.logger.warn('Aborting because of an error parsing question.', this.question.name); + + return this.questionHelper.showComponentError(this.onAbort); + } + + // Get the inputs where the answers will be stored and add them to the question text. + const inputEls = Array.from(div.querySelectorAll('input[type="hidden"]:not([name*=sequencecheck])')), + inputIds = []; + + inputEls.forEach((inputEl) => { + this.question.text += inputEl.outerHTML; + inputIds.push(inputEl.getAttribute('id')); + }); + } + + /** + * View has been initialized. + */ + ngAfterViewInit(): void { + // Create the instance. + this.questionInstance = new AddonQtypeDdwtosQuestion(this.logger, this.domUtils, this.element, this.question, + this.question.readOnly, this.inputIds); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.questionInstance && this.questionInstance.destroy(); + } +} diff --git a/src/addon/qtype/ddwtos/ddwtos.module.ts b/src/addon/qtype/ddwtos/ddwtos.module.ts new file mode 100644 index 000000000..00e0c1dbf --- /dev/null +++ b/src/addon/qtype/ddwtos/ddwtos.module.ts @@ -0,0 +1,46 @@ +// (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 { CoreDirectivesModule } from '@directives/directives.module'; +import { AddonQtypeDdwtosHandler } from './providers/handler'; +import { AddonQtypeDdwtosComponent } from './component/ddwtos'; + +@NgModule({ + declarations: [ + AddonQtypeDdwtosComponent + ], + imports: [ + IonicModule, + TranslateModule.forChild(), + CoreDirectivesModule + ], + providers: [ + AddonQtypeDdwtosHandler + ], + exports: [ + AddonQtypeDdwtosComponent + ], + entryComponents: [ + AddonQtypeDdwtosComponent + ] +}) +export class AddonQtypeDdwtosModule { + constructor(questionDelegate: CoreQuestionDelegate, handler: AddonQtypeDdwtosHandler) { + questionDelegate.registerHandler(handler); + } +} diff --git a/src/addon/qtype/ddwtos/providers/handler.ts b/src/addon/qtype/ddwtos/providers/handler.ts new file mode 100644 index 000000000..6a8a6eae4 --- /dev/null +++ b/src/addon/qtype/ddwtos/providers/handler.ts @@ -0,0 +1,116 @@ + +// (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 { AddonQtypeDdwtosComponent } from '../component/ddwtos'; + +/** + * Handler to support drag-and-drop words into sentences question type. + */ +@Injectable() +export class AddonQtypeDdwtosHandler implements CoreQuestionHandler { + name = 'AddonQtypeDdwtos'; + type = 'qtype_ddwtos'; + + 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 AddonQtypeDdwtosComponent; + } + + /** + * 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 { + for (const name in answers) { + const value = answers[name]; + if (!value || value === '0') { + return 0; + } + } + + return 1; + } + + /** + * 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 { + for (const name in answers) { + const value = answers[name]; + if (value && value !== '0') { + return 1; + } + } + + return 0; + } + + /** + * 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 a8563022c..9959ae0ed 100644 --- a/src/addon/qtype/qtype.module.ts +++ b/src/addon/qtype/qtype.module.ts @@ -16,6 +16,9 @@ import { NgModule } from '@angular/core'; import { AddonQtypeCalculatedModule } from './calculated/calculated.module'; import { AddonQtypeCalculatedMultiModule } from './calculatedmulti/calculatedmulti.module'; import { AddonQtypeCalculatedSimpleModule } from './calculatedsimple/calculatedsimple.module'; +import { AddonQtypeDdImageOrTextModule } from './ddimageortext/ddimageortext.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'; import { AddonQtypeGapSelectModule } from './gapselect/gapselect.module'; @@ -33,6 +36,9 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module'; AddonQtypeCalculatedModule, AddonQtypeCalculatedMultiModule, AddonQtypeCalculatedSimpleModule, + AddonQtypeDdImageOrTextModule, + AddonQtypeDdMarkerModule, + AddonQtypeDdwtosModule, AddonQtypeDescriptionModule, AddonQtypeEssayModule, AddonQtypeGapSelectModule,