Merge pull request #1287 from dpalou/MOBILE-2390

Mobile 2390
main
Juan Leyva 2018-04-09 12:39:59 +01:00 committed by GitHub
commit 439d8fe60d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 3125 additions and 0 deletions

View File

@ -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 = <HTMLElement> this.container.querySelector(`#core-question-${slot} .addon-qtype-ddimageortext-container`),
dragItemsArea = <HTMLElement> topNode.querySelector('div.dragitems'),
doc: AddonQtypeDdImageOrTextQuestionDocStructure = {};
doc.topNode = (): HTMLElement => {
return topNode;
};
doc.dragItemsArea = (): HTMLElement => {
return dragItemsArea;
};
doc.dragItems = (): HTMLElement[] => {
return <HTMLElement[]> Array.from(dragItemsArea.querySelectorAll('.drag'));
};
doc.dropZones = (): HTMLElement[] => {
return <HTMLElement[]> Array.from(topNode.querySelectorAll('div.dropzones div.dropzone'));
};
doc.dropZoneGroup = (groupNo: number): HTMLElement[] => {
return <HTMLElement[]> Array.from(topNode.querySelectorAll('div.dropzones div.group' + groupNo));
};
doc.dragItemsClonedFrom = (dragItemNo: number): HTMLElement[] => {
return <HTMLElement[]> Array.from(dragItemsArea.querySelectorAll('.dragitems' + dragItemNo));
};
doc.dragItem = (dragInstanceNo: number): HTMLElement => {
return <HTMLElement> dragItemsArea.querySelector('.draginstance' + dragInstanceNo);
};
doc.dragItemsInGroup = (groupNo: number): HTMLElement[] => {
return <HTMLElement[]> Array.from(dragItemsArea.querySelectorAll('.drag.group' + groupNo));
};
doc.dragItemHomes = (): HTMLElement[] => {
return <HTMLElement[]> Array.from(dragItemsArea.querySelectorAll('.draghome'));
};
doc.bgImg = (): HTMLImageElement => {
return <HTMLImageElement> topNode.querySelector('.dropbackground');
};
doc.dragItemHome = (dragItemNo: number): HTMLElement => {
return <HTMLElement> 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 = <HTMLElement> 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 = <HTMLElement> 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 <HTMLElement[]> 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 = <HTMLImageElement> 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 = <HTMLInputElement> 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 = <HTMLInputElement> 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 = <HTMLInputElement> 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 = <HTMLElement[]> 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 = <HTMLInputElement> 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 = <HTMLElement[]> 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 = <HTMLElement[]> 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);
}
}
}

View File

@ -0,0 +1,13 @@
<section ion-list *ngIf="question.text || question.text === ''" class="addon-qtype-ddimageortext-container">
<!-- Content is outside the core-loading to let the script calculate drag items position -->
<core-loading [hideUntil]="question.loaded"></core-loading>
<ion-item text-wrap [ngClass]="{invisible: !question.loaded}">
<p *ngIf="!question.readOnly" class="core-info-card-icon">
<ion-icon name="information" item-start></ion-icon>
{{ 'core.question.howtodraganddrop' | translate }}
</p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>
<core-format-text *ngIf="question.ddArea" [adaptImg]="false" [component]="component" [componentId]="componentId" [text]="question.ddArea"></core-format-text>
</ion-item>
</section>

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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<any>} The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(injector: Injector, question: any): any | Promise<any> {
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<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
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);
}
}

View File

@ -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 = <HTMLElement> dragHome.querySelector('span.markertext');
marker.style.opacity = '0.6';
// Clone the element and add the right classes.
const drag = <HTMLElement> 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 = <HTMLElement> this.container.querySelector('#core-question-' + slot + ' .addon-qtype-ddmarker-container'),
dragItemsArea = <HTMLElement> topNode.querySelector('div.dragitems');
return {
topNode: (): HTMLElement => {
return topNode;
},
bgImg: (): HTMLImageElement => {
return <HTMLImageElement> topNode.querySelector('.dropbackground');
},
dragItemsArea: (): HTMLElement => {
return dragItemsArea;
},
dragItems: (): HTMLElement[] => {
return <HTMLElement[]> Array.from(dragItemsArea.querySelectorAll('.dragitem'));
},
dragItemsForChoice: (choiceNo: number): HTMLElement[] => {
return <HTMLElement[]> Array.from(dragItemsArea.querySelectorAll('span.dragitem.choice' + choiceNo));
},
dragItemForChoice: (choiceNo: number, itemNo: number): HTMLElement => {
return <HTMLElement> dragItemsArea.querySelector('span.dragitem.choice' + choiceNo + '.item' + itemNo);
},
dragItemBeingDragged: (choiceNo: number): HTMLElement => {
return <HTMLElement> dragItemsArea.querySelector('span.dragitem.beingdragged.choice' + choiceNo);
},
dragItemHome: (choiceNo: number): HTMLElement => {
return <HTMLElement> dragItemsArea.querySelector('span.draghome.choice' + choiceNo);
},
dragItemHomes: (): HTMLElement[] => {
return <HTMLElement[]> 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 <HTMLElement[]> Array.from(topNode.querySelectorAll('input.choices'));
},
inputForChoice: (choiceNo: number): HTMLElement => {
return <HTMLElement> topNode.querySelector('input.choice' + choiceNo);
},
markerTexts: (): HTMLElement => {
return <HTMLElement> 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 = '<a href="#">' + markerText + '</a>';
} 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 = <HTMLElement> 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);
}
}
}

View File

@ -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 = <HTMLElement> 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 = <SVGSVGElement> 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 = [];
}
}

View File

@ -0,0 +1,13 @@
<section ion-list *ngIf="question.text || question.text === ''" class="addon-qtype-ddmarker-container">
<!-- Content is outside the core-loading to let the script calculate drag items position -->
<core-loading [hideUntil]="question.loaded"></core-loading>
<ion-item text-wrap [ngClass]="{invisible: !question.loaded}">
<p *ngIf="!question.readOnly" class="core-info-card-icon">
<ion-icon name="information" item-start></ion-icon>
{{ 'core.question.howtodraganddrop' | translate }}
</p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>
<core-format-text *ngIf="question.ddArea" [adaptImg]="false" [component]="component" [componentId]="componentId" [text]="question.ddArea"></core-format-text>
</ion-item>
</section>

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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<any>} The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(injector: Injector, question: any): any | Promise<any> {
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<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
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);
}
}

View File

@ -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 = <HTMLElement> 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 = <HTMLElement[]> 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 = <HTMLElement[]> 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 = <HTMLElement> 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 = <HTMLElement[]> 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 = <HTMLElement> this.container.querySelector(this.selectors.dropForPlace(parseInt(placeNo, 10) + 1)),
groupNo = this.getGroup(drop),
drag = <HTMLElement> 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 = <HTMLElement[]> 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 = <HTMLElement> 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 = <HTMLElement[]> 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 = <HTMLElement> 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 = <HTMLElement[]> 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 = <HTMLElement[]> Array.from(this.container.querySelectorAll(this.selectors.dropsGroup(groupNo)));
dropsGroup.forEach((item) => {
this.padToWidthHeight(item, maxWidth + 2, maxHeight + 2);
});
}
}
}

View File

@ -0,0 +1,11 @@
<section ion-list *ngIf="question.text || question.text === ''" class="addon-qtype-ddwtos-container">
<ion-item text-wrap>
<p *ngIf="!question.readOnly" class="core-info-card-icon">
<ion-icon name="information" item-start></ion-icon>
{{ 'core.question.howtodraganddrop' | translate }}
</p>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="question.text"></core-format-text></p>
<core-format-text *ngIf="question.answers" [component]="component" [componentId]="componentId" [text]="question.answers"></core-format-text>
<div class="drags"></div>
</ion-item>
</section>

View File

@ -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 = <HTMLElement[]> 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();
}
}

View File

@ -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);
}
}

View File

@ -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<any>} The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(injector: Injector, question: any): any | Promise<any> {
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<boolean>} True or promise resolved with true if enabled.
*/
isEnabled(): boolean | Promise<boolean> {
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);
}
}

View File

@ -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,