MOBILE-2390 qtype: Implement ddimageortext type
parent
7d3015015f
commit
6ebb42041a
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -16,6 +16,7 @@ import { NgModule } from '@angular/core';
|
|||
import { AddonQtypeCalculatedModule } from './calculated/calculated.module';
|
||||
import { AddonQtypeCalculatedMultiModule } from './calculatedmulti/calculatedmulti.module';
|
||||
import { AddonQtypeCalculatedSimpleModule } from './calculatedsimple/calculatedsimple.module';
|
||||
import { AddonQtypeDdImageOrTextModule } from './ddimageortext/ddimageortext.module';
|
||||
import { AddonQtypeDdMarkerModule } from './ddmarker/ddmarker.module';
|
||||
import { AddonQtypeDdwtosModule } from './ddwtos/ddwtos.module';
|
||||
import { AddonQtypeDescriptionModule } from './description/description.module';
|
||||
|
@ -35,6 +36,7 @@ import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module';
|
|||
AddonQtypeCalculatedModule,
|
||||
AddonQtypeCalculatedMultiModule,
|
||||
AddonQtypeCalculatedSimpleModule,
|
||||
AddonQtypeDdImageOrTextModule,
|
||||
AddonQtypeDdMarkerModule,
|
||||
AddonQtypeDdwtosModule,
|
||||
AddonQtypeDescriptionModule,
|
||||
|
|
Loading…
Reference in New Issue