MOBILE-3651 qtype: Implement all question types

This commit is contained in:
Dani Palou 2021-02-15 11:35:41 +01:00
parent 4917067f8d
commit 916dc14401
73 changed files with 8392 additions and 15 deletions
src
addons
addons.module.ts
qtype
calculated
calculatedmulti
calculatedsimple
ddimageortext
ddmarker
ddwtos
description
essay
gapselect
match
multianswer
multichoice
numerical
qtype.module.ts
randomsamatch
shortanswer
truefalse
core
features
fileuploader/services
question/classes
services

@ -25,6 +25,7 @@ import { AddonMessageOutputModule } from './messageoutput/messageoutput.module';
import { AddonMessagesModule } from './messages/messages.module'; import { AddonMessagesModule } from './messages/messages.module';
import { AddonModModule } from './mod/mod.module'; import { AddonModModule } from './mod/mod.module';
import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module'; import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module';
import { AddonQtypeModule } from './qtype/qtype.module';
@NgModule({ @NgModule({
imports: [ imports: [
@ -39,6 +40,7 @@ import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module';
AddonMessageOutputModule, AddonMessageOutputModule,
AddonModModule, AddonModModule,
AddonQbehaviourModule, AddonQbehaviourModule,
AddonQtypeModule,
], ],
}) })
export class AddonsModule {} export class AddonsModule {}

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeCalculatedComponent } from './component/calculated';
import { AddonQtypeCalculatedHandler } from './services/handlers/calculated';
@NgModule({
declarations: [
AddonQtypeCalculatedComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeCalculatedHandler.instance);
},
},
],
exports: [
AddonQtypeCalculatedComponent,
],
})
export class AddonQtypeCalculatedModule {}

@ -0,0 +1,75 @@
<ion-list class="addon-qtype-calculated-container" *ngIf="calcQuestion && (calcQuestion.text || calcQuestion.text === '')">
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="calcQuestion.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
</ion-label>
</ion-item>
<!-- Display unit options before the answer input. -->
<ng-container *ngIf="calcQuestion.options && calcQuestion.options.length && calcQuestion.optionsFirst">
<ng-container *ngTemplateOutlet="radioUnits"></ng-container>
</ng-container>
<ion-item *ngIf="calcQuestion.input" class="ion-text-wrap core-{{calcQuestion.input.correctIconColor}}-item">
<ion-label position="stacked">{{ 'addon.mod_quiz.answercolon' | translate }}</ion-label>
<!-- Display unit select before the answer input. -->
<ng-container *ngIf="calcQuestion.select && calcQuestion.selectFirst">
<ng-container *ngTemplateOutlet="selectUnits"></ng-container>
</ng-container>
<!-- Input to enter the answer. -->
<ion-input type="text" [attr.name]="calcQuestion.input.name"
[placeholder]="calcQuestion.input.readOnly ? '' : 'core.question.answer' | translate"
[value]="calcQuestion.input.value" [disabled]="calcQuestion.input.readOnly" autocorrect="off"
[slot]="calcQuestion.select && calcQuestion.selectFirst ? 'end' : 'start'">
</ion-input>
<!-- Display unit select after the answer input. -->
<ng-container *ngIf="calcQuestion.select && !calcQuestion.selectFirst">
<ng-container *ngTemplateOutlet="selectUnits"></ng-container>
</ng-container>
<ion-icon *ngIf="calcQuestion.input.correctIcon" class="core-correct-icon" slot="end"
[name]="calcQuestion.input.correctIcon" [color]="[calcQuestion.input.correctIconColor]">
</ion-icon>
</ion-item>
<!-- Display unit options after the answer input. -->
<ng-container *ngIf="calcQuestion.options && calcQuestion.options.length && !calcQuestion.optionsFirst">
<ng-container *ngTemplateOutlet="radioUnits"></ng-container>
</ng-container>
</ion-list>
<!-- Template for units entered using a select. -->
<ng-template #selectUnits>
<!-- <ion-col> -->
<label *ngIf="calcQuestion!.select!.accessibilityLabel" class="accesshide" for="{{calcQuestion!.select!.id}}">
{{ calcQuestion!.select!.accessibilityLabel }}
</label>
<ion-select id="{{calcQuestion!.select!.id}}" [name]="calcQuestion!.select!.name"
[(ngModel)]="calcQuestion!.select!.selected" interface="action-sheet" [disabled]="calcQuestion!.select!.disabled"
[slot]="calcQuestion?.selectFirst ? 'start' : 'end'">
<ion-select-option *ngFor="let option of calcQuestion!.select!.options" [value]="option.value">
{{option.label}}
</ion-select-option>
</ion-select>
<!-- </ion-col> -->
</ng-template>
<!-- Template for units entered using radio buttons. -->
<ng-template #radioUnits>
<ion-radio-group [(ngModel)]="calcQuestion!.unit" [name]="calcQuestion!.optionsName">
<ion-item class="ion-text-wrap" *ngFor="let option of calcQuestion!.options">
<ion-label>{{ option.text }}</ion-label>
<ion-radio slot="end" [value]="option.value" [disabled]="option.disabled || calcQuestion!.input?.readOnly"
[color]="calcQuestion!.input?.correctIconColor">
</ion-radio>
</ion-item>
<!-- ion-radio doesn't use an input. Create a hidden input to hold the selected value. -->
<input type="hidden" [ngModel]="calcQuestion!.unit" [attr.name]="calcQuestion!.optionsName">
</ion-radio-group>
</ng-template>

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, ElementRef } from '@angular/core';
import { AddonModQuizCalculatedQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
/**
* Component to render a calculated question.
*/
@Component({
selector: 'addon-qtype-calculated',
templateUrl: 'addon-qtype-calculated.html',
})
export class AddonQtypeCalculatedComponent extends CoreQuestionBaseComponent implements OnInit {
calcQuestion?: AddonModQuizCalculatedQuestion;
constructor(elementRef: ElementRef) {
super('AddonQtypeCalculatedComponent', elementRef);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.initCalculatedComponent();
this.calcQuestion = this.question;
}
}

@ -0,0 +1,237 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import { AddonQtypeCalculatedComponent } from '../../component/calculated';
/**
* Handler to support calculated question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeCalculatedHandlerService implements CoreQuestionHandler {
static readonly UNITINPUT = '0';
static readonly UNITRADIO = '1';
static readonly UNITSELECT = '2';
static readonly UNITNONE = '3';
static readonly UNITGRADED = '1';
static readonly UNITOPTIONAL = '0';
name = 'AddonQtypeCalculated';
type = 'qtype_calculated';
/**
* 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonQtypeCalculatedComponent;
}
/**
* Check if the units are in a separate field for the question.
*
* @param question Question.
* @return Whether units are in a separate field.
*/
hasSeparateUnitField(question: CoreQuestionQuestionParsed): boolean {
if (!question.parsedSettings) {
const element = CoreDomUtils.instance.convertToElement(question.html);
return !!(element.querySelector('select[name*=unit]') || element.querySelector('input[type="radio"]'));
}
return question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITRADIO ||
question.parsedSettings.unitdisplay === AddonQtypeCalculatedHandlerService.UNITSELECT;
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string,
componentId: string | number,
): number {
if (!this.isGradableResponse(question, answers, component, componentId)) {
return 0;
}
const { answer, unit } = this.parseAnswer(question, <string> answers.answer);
if (answer === null) {
return 0;
}
if (!question.parsedSettings) {
if (this.hasSeparateUnitField(question)) {
return this.isValidValue(<string> answers.unit) ? 1 : 0;
}
// We cannot know if the answer should contain units or not.
return -1;
}
if (question.parsedSettings.unitdisplay != AddonQtypeCalculatedHandlerService.UNITINPUT && unit) {
// There should be no units or be outside of the input, not valid.
return 0;
}
if (this.hasSeparateUnitField(question) && !this.isValidValue(<string> answers.unit)) {
// Unit not supplied as a separate field and it's required.
return 0;
}
if (question.parsedSettings.unitdisplay == AddonQtypeCalculatedHandlerService.UNITINPUT &&
question.parsedSettings.unitgradingtype == AddonQtypeCalculatedHandlerService.UNITGRADED &&
!this.isValidValue(unit)) {
// Unit not supplied inside the input and it's required.
return 0;
}
return 1;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string, // eslint-disable-line @typescript-eslint/no-unused-vars
componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
): number {
return this.isValidValue(<string> answers.answer) ? 1 : 0;
}
/**
* Check if two responses are the same.
*
* @param question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
component: string, // eslint-disable-line @typescript-eslint/no-unused-vars
componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
): boolean {
return CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') &&
CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'unit');
}
/**
* Check if a value is valid (not empty).
*
* @param value Value to check.
* @return Whether the value is valid.
*/
isValidValue(value: string | number | null): boolean {
return !!value || value === '0' || value === 0;
}
/**
* Parse an answer string.
*
* @param question Question.
* @param answer Answer.
* @return Answer and unit.
*/
parseAnswer(question: CoreQuestionQuestionParsed, answer: string): { answer: number | null; unit: string | null } {
if (!answer) {
return { answer: null, unit: null };
}
let regexString = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[-+]?\\d+)?';
// Strip spaces (which may be thousands separators) and change other forms of writing e to e.
answer = answer.replace(/ /g, '');
answer = answer.replace(/(?:e|E|(?:x|\*|×)10(?:\^|\*\*))([+-]?\d+)/, 'e$1');
// If a '.' is present or there are multiple ',' (i.e. 2,456,789) assume ',' is a thousands separator and strip it.
// Else assume it is a decimal separator, and change it to '.'.
if (answer.indexOf('.') != -1 || answer.split(',').length - 1 > 1) {
answer = answer.replace(',', '');
} else {
answer = answer.replace(',', '.');
}
let unitsLeft = false;
let match: RegExpMatchArray | null = null;
if (!question.parsedSettings || question.parsedSettings.unitsleft === null) {
// We don't know if units should be before or after so we check both.
match = answer.match(new RegExp('^' + regexString));
if (!match) {
unitsLeft = true;
match = answer.match(new RegExp(regexString + '$'));
}
} else {
unitsLeft = question.parsedSettings.unitsleft == '1';
regexString = unitsLeft ? regexString + '$' : '^' + regexString;
match = answer.match(new RegExp(regexString));
}
if (!match) {
return { answer: null, unit: null };
}
const numberString = match[0];
const unit = unitsLeft ? answer.substr(0, answer.length - match[0].length) : answer.substr(match[0].length);
// No need to calculate the multiplier.
return { answer: Number(numberString), unit };
}
}
export class AddonQtypeCalculatedHandler extends makeSingleton(AddonQtypeCalculatedHandlerService) {}

@ -0,0 +1,34 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeCalculatedMultiHandler } from './services/handlers/calculatedmulti';
@NgModule({
declarations: [
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeCalculatedMultiHandler.instance);
},
},
],
})
export class AddonQtypeCalculatedMultiModule {}

@ -0,0 +1,109 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { AddonQtypeMultichoiceComponent } from '@addons/qtype/multichoice/component/multichoice';
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { makeSingleton } from '@singletons';
import { AddonQtypeMultichoiceHandler } from '@addons/qtype/multichoice/services/handlers/multichoice';
/**
* Handler to support calculated multi question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeCalculatedMultiHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeCalculatedMulti';
type = 'qtype_calculatedmulti';
/**
* 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
// Calculated multi behaves like a multichoice, use the same component.
return AddonQtypeMultichoiceComponent;
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): number {
// This question type depends on multichoice.
return AddonQtypeMultichoiceHandler.instance.isCompleteResponseSingle(answers);
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): number {
// This question type depends on multichoice.
return AddonQtypeMultichoiceHandler.instance.isGradableResponseSingle(answers);
}
/**
* Check if two responses are the same.
*
* @param question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
// This question type depends on multichoice.
return AddonQtypeMultichoiceHandler.instance.isSameResponseSingle(prevAnswers, newAnswers);
}
}
export class AddonQtypeCalculatedMultiHandler extends makeSingleton(AddonQtypeCalculatedMultiHandlerService) {}

@ -0,0 +1,34 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeCalculatedSimpleHandler } from './services/handlers/calculatedsimple';
@NgModule({
declarations: [
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeCalculatedSimpleHandler.instance);
},
},
],
})
export class AddonQtypeCalculatedSimpleModule {}

@ -0,0 +1,115 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { AddonQtypeCalculatedComponent } from '@addons/qtype/calculated/component/calculated';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { AddonQtypeCalculatedHandler } from '@addons/qtype/calculated/services/handlers/calculated';
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { makeSingleton } from '@singletons';
/**
* Handler to support calculated simple question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeCalculatedSimpleHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeCalculatedSimple';
type = 'qtype_calculatedsimple';
/**
* 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
// Calculated simple behaves like a calculated, use the same component.
return AddonQtypeCalculatedComponent;
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string,
componentId: string | number,
): number {
// This question type depends on calculated.
return AddonQtypeCalculatedHandler.instance.isCompleteResponse(question, answers, component, componentId);
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string,
componentId: string | number,
): number {
// This question type depends on calculated.
return AddonQtypeCalculatedHandler.instance.isGradableResponse(question, answers, component, componentId);
}
/**
* Check if two responses are the same.
*
* @param question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
component: string,
componentId: string | number,
): boolean {
// This question type depends on calculated.
return AddonQtypeCalculatedHandler.instance.isSameResponse(question, prevAnswers, newAnswers, component, componentId);
}
}
export class AddonQtypeCalculatedSimpleHandler extends makeSingleton(AddonQtypeCalculatedSimpleHandlerService) {}

@ -0,0 +1,848 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { CoreDomUtils } from '@services/utils/dom';
import { CoreUtils } from '@services/utils/utils';
import { CoreLogger } from '@singletons/logger';
import { AddonModQuizDdImageOrTextQuestionData } from '../component/ddimageortext';
/**
* Class to make a question of ddimageortext type work.
*/
export class AddonQtypeDdImageOrTextQuestion {
protected logger: CoreLogger;
protected toLoad = 0;
protected doc!: AddonQtypeDdImageOrTextQuestionDocStructure;
protected afterImageLoadDone = false;
protected proportion = 1;
protected selected?: HTMLElement | null; // Selected element (being "dragged").
protected resizeFunction?: (ev?: Event) => void;
/**
* Create the this.
*
* @param container The container HTMLElement of the question.
* @param question The question.
* @param readOnly Whether it's read only.
* @param drops The drop zones received in the init object of the question.
*/
constructor(
protected container: HTMLElement,
protected question: AddonModQuizDdImageOrTextQuestionData,
protected readOnly: boolean,
protected drops?: unknown[],
) {
this.logger = CoreLogger.getInstance('AddonQtypeDdImageOrTextQuestion');
this.initializer();
}
/**
* Calculate image proportion to make easy conversions.
*/
calculateImgProportion(): void {
const bgImg = this.doc.bgImg();
if (!bgImg) {
return;
}
// 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 bgImgXY X and Y of the BG IMG relative position.
* @return Position relative to the window.
*/
convertToWindowXY(bgImgXY: number[]): number[] {
const bgImg = this.doc.bgImg();
if (!bgImg) {
return bgImgXY;
}
const position = CoreDomUtils.instance.getElementXY(bgImg, undefined, '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.
*/
async createAllDragAndDrops(): Promise<void> {
// Initialize drop zones.
this.initDrops();
// Initialize drag items area.
this.doc.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];
const dragItemNo = this.doc.getClassnameNumericSuffix(dragItemHome, 'dragitemhomes') ?? -1;
const choice = this.doc.getClassnameNumericSuffix(dragItemHome, 'choice') ?? -1;
const group = this.doc.getClassnameNumericSuffix(dragItemHome, 'group') ?? -1;
// 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++;
// 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++;
this.draggableForQuestion(newDragNode, group, choice);
dragsToCreate--;
}
}
}
await CoreUtils.instance.nextTick();
// 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();
if (this.resizeFunction) {
window.removeEventListener('resize', this.resizeFunction);
}
}
/**
* Make an element draggable.
*
* @param drag Element to make draggable.
* @param group Group the element belongs to.
* @param choice Choice the element belongs to.
*/
draggableForQuestion(drag: HTMLElement | null, group: number, choice: number): void {
if (!drag) {
return;
}
// Set attributes.
drag.setAttribute('group', String(group));
drag.setAttribute('choice', String(choice));
if (!this.readOnly) {
// 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 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 choice Choice number.
* @param drop Drop zone.
* @return Draggable elements.
*/
getChoicesForDrop(choice: number, drop: HTMLElement): HTMLElement[] {
if (!this.doc.topNode) {
return [];
}
return Array.from(
this.doc.topNode.querySelectorAll('div.dragitemgroup' + drop.getAttribute('group') + ` .choice${choice}.drag`),
);
}
/**
* Get an unplaced draggable element that belongs to a certain choice and drop zone.
*
* @param choice Choice number.
* @param drop Drop zone.
* @return Unplaced draggable element.
*/
getUnplacedChoiceForDrop(choice: number, drop: HTMLElement): HTMLElement | null {
const dragItems = this.getChoicesForDrop(choice, drop);
const foundItem = dragItems.find((dragItem) =>
!dragItem.classList.contains('placed') && !dragItem.classList.contains('beingdragged'));
return foundItem || null;
}
/**
* Initialize drop zones.
*/
initDrops(): void {
const dropAreas = this.doc.topNode?.querySelector('div.dropzones');
if (!dropAreas) {
return;
}
const groupNodes: Record<number, HTMLElement> = {};
// 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];
const nodeClass = `dropzone group${drop.group} place${dropNo}`;
const title = drop.text.replace('"', '"');
const 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 question Question.
*/
initializer(): void {
this.doc = new AddonQtypeDdImageOrTextQuestionDocStructure(this.container, this.question.slot);
if (this.readOnly) {
this.doc.topNode?.classList.add('readonly');
}
// Wait the DOM to be rendered.
setTimeout(() => {
const bgImg = this.doc.bgImg();
if (!bgImg) {
this.logger.error('Background image not found');
return;
}
// 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') {
return;
}
// 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();
});
this.resizeFunction = this.repositionDragsForQuestion.bind(this);
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 drag Draggable element.
* @param drop Drop zone element.
*/
placeDragInDrop(drag: HTMLElement, drop: HTMLElement): void {
// Search the input related to the drop zone.
const targetInputId = drop.getAttribute('inputid') || '';
const inputNode = this.doc.topNode?.querySelector<HTMLInputElement>(`input#${targetInputId}`);
// Check if the draggable item is already assigned to an input and if it's the same as the one of the drop zone.
const originInputId = drag.getAttribute('inputid');
if (originInputId && originInputId != targetInputId) {
// Remove it from the previous place.
const originInputNode = this.doc.topNode?.querySelector<HTMLInputElement>(`input#${originInputId}`);
originInputNode?.setAttribute('value', '0');
}
// Now position the draggable and set it to the input.
const position = CoreDomUtils.instance.getElementXY(drop, undefined, 'ddarea');
const choice = drag.getAttribute('choice');
drag.style.left = position[0] - 1 + 'px';
drag.style.top = position[1] - 1 + 'px';
drag.classList.add('placed');
if (choice) {
inputNode?.setAttribute('value', 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 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) {
this.doc.topNode?.querySelector<HTMLInputElement>(`input#${inputId}`)?.setAttribute('value', '0');
}
// Move the element to its original position.
const dragItemHome = this.doc.dragItemHome(Number(drag.getAttribute('dragitemno')));
if (!dragItemHome) {
return;
}
const position = CoreDomUtils.instance.getElementXY(dragItemHome, undefined, '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];
const dragItemHomeImg = dragItemHome.querySelector('img');
if (!dragItemHomeImg || dragItemHomeImg.naturalWidth <= 0) {
continue;
}
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');
const groupNo = this.doc.getClassnameNumericSuffix(dragItemHome, 'group');
const dragsImg = this.doc.topNode ?
Array.from(this.doc.topNode.querySelectorAll<HTMLElement>(`.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];
const dropZoneXY = dropZone.getAttribute('xy')?.split(',').map((i) => Number(i));
const 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');
const input = this.doc.topNode?.querySelector<HTMLInputElement>(inputCss);
const choice = input ? Number(input.value) : -1;
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);
}
}
}
/**
* Mark a draggable element as selected.
*
* @param 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 groupNo The group number.
*/
updatePaddingSizeForGroup(groupNo: number): void {
// Get all the items for this group.
const groupItems = this.doc.topNode ?
Array.from(this.doc.topNode.querySelectorAll<HTMLElement>(`.draghome.group${groupNo}`)) : [];
if (groupItems.length == 0) {
return;
}
// Get the max width and height of the items.
let maxWidth = 0;
let maxHeight = 0;
for (let x = 0; x < groupItems.length; x++) {
// Check if the item has an img.
const item = groupItems[x];
const 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];
const img = item.querySelector('img');
let width: number | undefined;
let height: number | undefined;
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);
const marginLeftRight = Math.round((maxWidth - width) / 2);
// Correction for the roundings.
const widthCorrection = maxWidth - (width + marginLeftRight * 2);
const 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');
const drags = this.doc.topNode ?
Array.from(this.doc.topNode.querySelectorAll<HTMLElement>(`.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);
}
}
}
/**
* Encapsulates operations on dd area.
*/
export class AddonQtypeDdImageOrTextQuestionDocStructure {
topNode: HTMLElement | null;
dragItemsArea: HTMLElement | null;
protected logger: CoreLogger;
constructor(
protected container: HTMLElement,
protected slot: number,
) {
this.logger = CoreLogger.getInstance('AddonQtypeDdImageOrTextQuestionDocStructure');
this.topNode = this.container.querySelector<HTMLElement>('.addon-qtype-ddimageortext-container');
this.dragItemsArea = this.topNode?.querySelector<HTMLElement>('div.draghomes') || null;
if (this.dragItemsArea) {
// On 3.9+ dragitems were removed.
const dragItems = this.topNode!.querySelector('div.dragitems');
if (dragItems) {
// Remove empty div.dragitems.
dragItems.remove();
}
// 3.6+ site, transform HTML so it has the same structure as in Moodle 3.5.
const ddArea = this.topNode!.querySelector('div.ddarea');
if (ddArea) {
// Move div.dropzones to div.ddarea.
const dropZones = this.topNode!.querySelector('div.dropzones');
if (dropZones) {
ddArea.appendChild(dropZones);
}
// Move div.draghomes to div.ddarea and rename the class to .dragitems.
ddArea?.appendChild(this.dragItemsArea);
}
this.dragItemsArea.classList.remove('draghomes');
this.dragItemsArea.classList.add('dragitems');
// Add .dragitemhomesNNN class to drag items.
Array.from(this.dragItemsArea.querySelectorAll('.draghome')).forEach((draghome, index) => {
draghome.classList.add(`dragitemhomes${index}`);
});
} else {
this.dragItemsArea = this.topNode!.querySelector<HTMLElement>('div.dragitems');
}
}
querySelector<T = HTMLElement>(element: HTMLElement | null, selector: string): T | null {
if (!element) {
return null;
}
return <T | null> element.querySelector(selector);
}
querySelectorAll(element: HTMLElement | null, selector: string): HTMLElement[] {
if (!element) {
return [];
}
return Array.from(element.querySelectorAll(selector));
}
dragItems(): HTMLElement[] {
return this.querySelectorAll(this.dragItemsArea, '.drag');
}
dropZones(): HTMLElement[] {
return this.querySelectorAll(this.topNode, 'div.dropzones div.dropzone');
}
dropZoneGroup(groupNo: number): HTMLElement[] {
return this.querySelectorAll(this.topNode, `div.dropzones div.group${groupNo}`);
}
dragItemsClonedFrom(dragItemNo: number): HTMLElement[] {
return this.querySelectorAll(this.dragItemsArea, `.dragitems${dragItemNo}`);
}
dragItem(dragInstanceNo: number): HTMLElement | null {
return this.querySelector(this.dragItemsArea, `.draginstance${dragInstanceNo}`);
}
dragItemsInGroup(groupNo: number): HTMLElement[] {
return this.querySelectorAll(this.dragItemsArea, `.drag.group${groupNo}`);
}
dragItemHomes(): HTMLElement[] {
return this.querySelectorAll(this.dragItemsArea, '.draghome');
}
bgImg(): HTMLImageElement | null {
return this.querySelector(this.topNode, '.dropbackground');
}
dragItemHome(dragItemNo: number): HTMLElement | null {
return this.querySelector(this.dragItemsArea, `.dragitemhomes${dragItemNo}`);
}
getClassnameNumericSuffix(node: HTMLElement, prefix: string): number | undefined {
if (node.classList && node.classList.length) {
const patt1 = new RegExp(`^${prefix}([0-9])+$`);
const 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.`);
}
cloneNewDragItem(dragInstanceNo: number, dragItemNo: number): HTMLElement | null {
const dragHome = this.dragItemHome(dragItemNo);
if (dragHome === null) {
return null;
}
const dragHomeImg = dragHome.querySelector('img');
let divDrag: HTMLElement | undefined = undefined;
// 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));
divDrag.setAttribute('tabindex', '0');
// Insert the new drag after the dragHome.
dragHome.parentElement?.insertBefore(divDrag, dragHome.nextSibling);
return divDrag;
}
};

@ -0,0 +1,24 @@
<ion-list *ngIf="ddQuestion && (ddQuestion.text || ddQuestion.text === '')" class="addon-qtype-ddimageortext-container">
<!-- Content is outside the core-loading to let the script calculate drag items position -->
<core-loading [hideUntil]="ddQuestion.loaded"></core-loading>
<ion-item class="ion-text-wrap" [hidden]="!ddQuestion.loaded">
<ion-label>
<ion-card *ngIf="!ddQuestion.readOnly" class="core-info-card">
<ion-item>
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
<ion-label>{{ 'core.question.howtodraganddrop' | translate }}</ion-label>
</ion-item>
</ion-card>
<core-format-text [component]="component" [componentId]="componentId" [text]="ddQuestion.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"
(afterRender)="textRendered()">
</core-format-text>
<core-format-text *ngIf="ddQuestion.ddArea" [adaptImg]="false" [component]="component" [componentId]="componentId"
[text]="ddQuestion.ddArea" [filter]="false" (afterRender)="ddAreaRendered()">
</core-format-text>
</ion-label>
</ion-item>
</ion-list>

@ -0,0 +1,114 @@
@import "~core/features/question/question";
// Style ddimageortext content a bit. Almost all these styles are copied from Moodle.
:host {
.addon-qtype-ddimageortext-container {
min-height: 80px; // To display the loading.
}
core-format-text ::ng-deep {
.qtext {
margin-bottom: 0.5em;
display: block;
}
div.droparea img {
border: 1px solid var(--gray-darker);
max-width: 100%;
}
.draghome {
vertical-align: top;
margin: 5px;
visibility : hidden;
}
.draghome img {
display: block;
}
div.draghome {
border: 1px solid var(--gray-darker);
cursor: pointer;
background-color: #B0C4DE;
display: inline-block;
height: auto;
width: auto;
zoom: 1;
}
@for $i from 0 to length($core-dd-question-colors) {
.group#{$i + 1} {
background: nth($core-dd-question-colors, $i + 1);
}
}
.group2 {
border-radius: 10px 0 0 0;
}
.group3 {
border-radius: 0 10px 0 0;
}
.group4 {
border-radius: 0 0 10px 0;
}
.group5 {
border-radius: 0 0 0 10px;
}
.group6 {
border-radius: 0 10px 10px 0;
}
.group7 {
border-radius: 10px 0 0 10px;
}
.group8 {
border-radius: 10px 10px 10px 10px;
}
.drag {
border: 1px solid var(--gray-darker);
color: var(--ion-text-color);
cursor: pointer;
z-index: 2;
}
.dragitems.readonly .drag {
cursor: auto;
}
.dragitems>div {
clear: both;
}
.dragitems {
cursor: pointer;
}
.dragitems.readonly {
cursor: auto;
}
.drag img {
display: block;
}
div.ddarea {
text-align : center;
position: relative;
}
.dropbackground {
margin:0 auto;
}
.dropzone {
border: 1px solid var(--gray-darker);
position: absolute;
z-index: 1;
cursor: pointer;
}
.readonly .dropzone {
cursor: auto;
}
div.dragitems div.draghome, div.dragitems div.drag {
font:13px/1.231 arial,helvetica,clean,sans-serif;
}
.drag.beingdragged {
z-index: 3;
box-shadow: var(--core-dd-question-selected-shadow);
}
}
}

@ -0,0 +1,145 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, ElementRef } from '@angular/core';
import { AddonModQuizQuestionBasicData, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
import { CoreQuestionHelper } from '@features/question/services/question-helper';
import { CoreDomUtils } from '@services/utils/dom';
import { AddonQtypeDdImageOrTextQuestion } from '../classes/ddimageortext';
/**
* Component to render a drag-and-drop onto image question.
*/
@Component({
selector: 'addon-qtype-ddimageortext',
templateUrl: 'addon-qtype-ddimageortext.html',
styleUrls: ['ddimageortext.scss'],
})
export class AddonQtypeDdImageOrTextComponent extends CoreQuestionBaseComponent implements OnInit, OnDestroy {
ddQuestion?: AddonModQuizDdImageOrTextQuestionData;
protected questionInstance?: AddonQtypeDdImageOrTextQuestion;
protected drops?: unknown[]; // The drop zones received in the init object of the question.
protected destroyed = false;
protected textIsRendered = false;
protected ddAreaisRendered = false;
constructor(elementRef: ElementRef) {
super('AddonQtypeDdImageOrTextComponent', elementRef);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
if (!this.question) {
this.logger.warn('Aborting because of no question received.');
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
this.ddQuestion = this.question;
const element = CoreDomUtils.instance.convertToElement(this.ddQuestion.html);
// Get D&D area and question text.
const ddArea = element.querySelector('.ddarea');
this.ddQuestion.text = CoreDomUtils.instance.getContentsOfElement(element, '.qtext');
if (!ddArea || typeof this.ddQuestion.text == 'undefined') {
this.logger.warn('Aborting because of an error parsing question.', this.ddQuestion.slot);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
// Set the D&D area HTML.
this.ddQuestion.ddArea = ddArea.outerHTML;
this.ddQuestion.readOnly = false;
if (this.ddQuestion.initObjects) {
// Moodle version <= 3.5.
if (typeof this.ddQuestion.initObjects.drops != 'undefined') {
this.drops = <unknown[]> this.ddQuestion.initObjects.drops;
}
if (typeof this.ddQuestion.initObjects.readonly != 'undefined') {
this.ddQuestion.readOnly = !!this.ddQuestion.initObjects.readonly;
}
} else if (this.ddQuestion.amdArgs) {
// Moodle version >= 3.6.
if (typeof this.ddQuestion.amdArgs[1] != 'undefined') {
this.ddQuestion.readOnly = !!this.ddQuestion.amdArgs[1];
}
if (typeof this.ddQuestion.amdArgs[2] != 'undefined') {
this.drops = <unknown[]> this.ddQuestion.amdArgs[2];
}
}
this.ddQuestion.loaded = false;
}
/**
* The question ddArea has been rendered.
*/
ddAreaRendered(): void {
this.ddAreaisRendered = true;
if (this.textIsRendered) {
this.questionRendered();
}
}
/**
* The question text has been rendered.
*/
textRendered(): void {
this.textIsRendered = true;
if (this.ddAreaisRendered) {
this.questionRendered();
}
}
/**
* The question has been rendered.
*/
protected questionRendered(): void {
if (!this.destroyed && this.ddQuestion) {
// Create the instance.
this.questionInstance = new AddonQtypeDdImageOrTextQuestion(
this.hostElement,
this.ddQuestion,
!!this.ddQuestion.readOnly,
this.drops,
);
}
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.destroyed = true;
this.questionInstance?.destroy();
}
}
/**
* Data for DD Image or Text question.
*/
export type AddonModQuizDdImageOrTextQuestionData = AddonModQuizQuestionBasicData & {
loaded?: boolean;
readOnly?: boolean;
ddArea?: string;
};

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeDdImageOrTextComponent } from './component/ddimageortext';
import { AddonQtypeDdImageOrTextHandler } from './services/handlers/ddimageortext';
@NgModule({
declarations: [
AddonQtypeDdImageOrTextComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeDdImageOrTextHandler.instance);
},
},
],
exports: [
AddonQtypeDdImageOrTextComponent,
],
})
export class AddonQtypeDdImageOrTextModule {}

@ -0,0 +1,136 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { makeSingleton } from '@singletons';
import { AddonQtypeDdImageOrTextComponent } from '../../component/ddimageortext';
/**
* Handler to support drag-and-drop onto image question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeDdImageOrTextHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeDdImageOrText';
type = 'qtype_ddimageortext';
/**
* 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 question The question.
* @param behaviour The default behaviour.
* @return The behaviour to use.
*/
getBehaviour(question: CoreQuestionQuestionParsed, 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonQtypeDdImageOrTextComponent;
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): 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 True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): 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 question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers);
}
}
export class AddonQtypeDdImageOrTextHandler extends makeSingleton(AddonQtypeDdImageOrTextHandlerService) {}

@ -0,0 +1,972 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreLogger } from '@singletons/logger';
import { AddonQtypeDdMarkerQuestionData } from '../component/ddmarker';
import { AddonQtypeDdMarkerGraphicsApi } from './graphics_api';
/**
* Point type.
*/
export type AddonQtypeDdMarkerQuestionPoint = {
x: number; // X axis coordinates.
y: number; // Y axis coordinates.
};
/**
* Class to make a question of ddmarker type work.
*/
export class AddonQtypeDdMarkerQuestion {
protected readonly COLOURS = ['#FFFFFF', '#B0C4DE', '#DCDCDC', '#D8BFD8', '#87CEFA', '#DAA520', '#FFD700', '#F0E68C'];
protected logger: CoreLogger;
protected afterImageLoadDone = false;
protected drops;
protected topNode;
protected nextColourIndex = 0;
protected proportion = 1;
protected selected?: HTMLElement; // Selected element (being "dragged").
protected graphics: AddonQtypeDdMarkerGraphicsApi;
protected resizeFunction?: () => void;
doc!: AddonQtypeDdMarkerQuestionDocStructure;
shapes: SVGElement[] = [];
/**
* Create the instance.
*
* @param container The container HTMLElement of the question.
* @param question The question instance.
* @param readOnly Whether it's read only.
* @param dropZones The drop zones received in the init object of the question.
* @param imgSrc Background image source (3.6+ sites).
*/
constructor(
protected container: HTMLElement,
protected question: AddonQtypeDdMarkerQuestionData,
protected readOnly: boolean,
protected dropZones: any[], // eslint-disable-line @typescript-eslint/no-explicit-any
protected imgSrc?: string,
) {
this.logger = CoreLogger.getInstance('AddonQtypeDdMarkerQuestion');
this.graphics = new AddonQtypeDdMarkerGraphicsApi(this);
this.initializer();
}
/**
* Calculate image proportion to make easy conversions.
*/
calculateImgProportion(): void {
const bgImg = this.doc.bgImg();
if (!bgImg) {
return;
}
// 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 dragHome The element to clone.
* @param itemNo The number of the new item.
* @return The new element.
*/
cloneNewDragItem(dragHome: HTMLElement, itemNo: number): HTMLElement {
// 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);
drag.classList.remove('dragplaceholder'); // In case it has it.
dragHome.classList.add('dragplaceholder');
// 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 bgImgXY X and Y of the BG IMG relative position.
* @return Position relative to the window.
*/
convertToWindowXY(bgImgXY: string): number[] {
const bgImg = this.doc.bgImg();
if (!bgImg) {
return [];
}
const position = CoreDomUtils.instance.getElementXY(bgImg, undefined, 'ddarea');
let coordsNumbers = this.parsePoint(bgImgXY);
coordsNumbers = this.makePointProportional(coordsNumbers);
return [coordsNumbers.x + position[0], coordsNumbers.y + position[1]];
}
/**
* Check if some coordinates (X, Y) are inside the background image.
*
* @param coords Coordinates to check.
* @return Whether they're inside the background image.
*/
coordsInImg(coords: AddonQtypeDdMarkerQuestionPoint): boolean {
const bgImg = this.doc.bgImg();
if (!bgImg) {
return false;
}
return (coords.x * this.proportion <= bgImg.width + 1) && (coords.y * this.proportion <= bgImg.height + 1);
}
/**
* Deselect all draggable items.
*/
deselectDrags(): void {
const drags = this.doc.dragItems();
drags.forEach((drag) => {
drag.classList.remove('beingdragged');
});
this.selected = undefined;
}
/**
* Function to call when the instance is no longer needed.
*/
destroy(): void {
if (this.resizeFunction) {
window.removeEventListener('resize', this.resizeFunction);
}
}
/**
* Make an element "draggable". In the mobile app, items are "dragged" using tap and drop.
*
* @param 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 = CoreDomUtils.instance.getElementXY(drag, undefined, 'ddarea');
const bgImg = this.doc.bgImg();
if (!bgImg) {
return;
}
const bgImgPos = CoreDomUtils.instance.getElementXY(bgImg, undefined, '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 choiceNo Choice number.
* @return Coordinates.
*/
dragHomeXY(choiceNo: number): number[] {
const dragItemHome = this.doc.dragItemHome(choiceNo);
if (!dragItemHome) {
return [];
}
const position = CoreDomUtils.instance.getElementXY(dragItemHome, undefined, 'ddarea');
return [position[0], position[1]];
}
/**
* Draw a drop zone.
*
* @param dropZoneNo Number of the drop zone.
* @param markerText The marker text to set.
* @param shape Name of the shape of the drop zone (circle, rectangle, polygon).
* @param coords Coordinates of the shape.
* @param colour Colour of the shape.
*/
drawDropZone(dropZoneNo: number, markerText: string, shape: string, coords: string, colour: string): void {
const markerTexts = this.doc.markerTexts();
// Check if there is already a marker text for this drop zone.
const existingMarkerText = markerTexts?.querySelector<HTMLElement>('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 !== '' && markerTexts) {
// Create and add the marker text.
const classNames = 'markertext markertext' + dropZoneNo;
const span = document.createElement('span');
span.className = classNames;
span.innerHTML = markerText;
markerTexts.appendChild(span);
}
// Check that a function to draw this shape exists.
const drawFunc = 'drawShape' + CoreTextUtils.instance.ucFirst(shape);
if (!(this[drawFunc] instanceof Function)) {
return;
}
// Call the function.
const xyForText = this[drawFunc](dropZoneNo, coords, colour);
if (xyForText === null || xyForText === undefined) {
return;
}
// Search the marker for the drop zone.
const markerSpan = this.doc.topNode?.querySelector<HTMLElement>(`div.ddarea div.markertexts span.markertext${dropZoneNo}`);
if (!markerSpan) {
return;
}
const width = CoreDomUtils.instance.getElementMeasure(markerSpan, true, true, false, true);
const height = CoreDomUtils.instance.getElementMeasure(markerSpan, false, true, false, true);
markerSpan.style.opacity = '0.6';
markerSpan.style.left = (xyForText.x - (width / 2)) + 'px';
markerSpan.style.top = (xyForText.y - (height / 2)) + 'px';
const markerSpanAnchor = markerSpan.querySelector('a');
if (markerSpanAnchor !== null) {
markerSpanAnchor.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
this.shapes.forEach((elem) => {
elem.style.fillOpacity = '0.5';
});
this.shapes[dropZoneNo].style.fillOpacity = '1';
setTimeout(() => {
this.shapes[dropZoneNo].style.fillOpacity = '0.5';
}, 2000);
});
markerSpanAnchor.setAttribute('tabIndex', '0');
}
}
/**
* Draw a circle in a drop zone.
*
* @param dropZoneNo Number of the drop zone.
* @param coordinates Coordinates of the circle.
* @param colour Colour of the circle.
* @return X and Y position of the center of the circle.
*/
drawShapeCircle(dropZoneNo: number, coordinates: string, colour: string): AddonQtypeDdMarkerQuestionPoint | null {
if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?;\d+(\.\d+)?$/)) {
return null;
}
const bits = coordinates.split(';');
let centre = this.parsePoint(bits[0]);
const radius = Number(bits[1]);
// Calculate circle limits and check it's inside the background image.
const circleLimit = { x: centre.x - radius, y: centre.y - radius };
if (this.coordsInImg(circleLimit)) {
centre = this.makePointProportional(centre);
// All good, create the shape.
this.shapes[dropZoneNo] = this.graphics.addShape({
type: 'circle',
color: colour,
}, {
cx: centre.x,
cy: centre.y,
r: Math.round(radius * this.proportion),
});
// Return the centre.
return centre;
}
return null;
}
/**
* Draw a rectangle in a drop zone.
*
* @param dropZoneNo Number of the drop zone.
* @param coordinates Coordinates of the rectangle.
* @param colour Colour of the rectangle.
* @return X and Y position of the center of the rectangle.
*/
drawShapeRectangle(dropZoneNo: number, coordinates: string, colour: string): AddonQtypeDdMarkerQuestionPoint | null {
if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?;\d+(\.\d+)?,\d+(\.\d+)?$/)) {
return null;
}
const bits = coordinates.split(';');
const startPoint = this.parsePoint(bits[0]);
const size = this.parsePoint(bits[1]);
// Calculate rectangle limits and check it's inside the background image.
const rectLimits = { x: startPoint.x + size.x, y: startPoint.y + size.y };
if (this.coordsInImg(rectLimits)) {
const startPointProp = this.makePointProportional(startPoint);
const sizeProp = this.makePointProportional(size);
// All good, create the shape.
this.shapes[dropZoneNo] = this.graphics.addShape({
type: 'rect',
color: colour,
}, {
x: startPointProp.x,
y: startPointProp.y,
width: sizeProp.x,
height: sizeProp.y,
});
const centre = { x: startPoint.x + (size.x / 2) , y: startPoint.y + (size.y / 2) };
// Return the centre.
return this.makePointProportional(centre);
}
return null;
}
/**
* Draw a polygon in a drop zone.
*
* @param dropZoneNo Number of the drop zone.
* @param coordinates Coordinates of the polygon.
* @param colour Colour of the polygon.
* @return X and Y position of the center of the polygon.
*/
drawShapePolygon(dropZoneNo: number, coordinates: string, colour: string): AddonQtypeDdMarkerQuestionPoint | null {
if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?(?:;\d+(\.\d+)?,\d+(\.\d+)?)*$/)) {
return null;
}
const bits = coordinates.split(';');
const centre = { x: 0, y: 0 };
const points = bits.map((bit) => {
const point = this.parsePoint(bit);
centre.x += point.x;
centre.y += point.y;
return point;
});
if (points.length > 0) {
centre.x = Math.round(centre.x / points.length);
centre.y = Math.round(centre.y / points.length);
}
const pointsOnImg: string[] = [];
points.forEach((point) => {
if (this.coordsInImg(point)) {
point = this.makePointProportional(point);
pointsOnImg.push(point.x + ',' + point.y);
}
});
if (pointsOnImg.length > 2) {
this.shapes[dropZoneNo] = this.graphics.addShape({
type: 'polygon',
color: colour,
}, {
points: pointsOnImg.join(' '),
});
// Return the centre.
return this.makePointProportional(centre);
}
return null;
}
/**
* Make a point from the string representation.
*
* @param coordinates "x,y".
* @return Coordinates to the point.
*/
parsePoint(coordinates: string): AddonQtypeDdMarkerQuestionPoint {
const bits = coordinates.split(',');
if (bits.length !== 2) {
throw coordinates + ' is not a valid point';
}
return { x: Number(bits[0]), y: Number(bits[1]) };
}
/**
* Make proportional position of the point.
*
* @param point Point coordinates.
* @return Converted point.
*/
makePointProportional(point: AddonQtypeDdMarkerQuestionPoint): AddonQtypeDdMarkerQuestionPoint {
return {
x: Math.round(point.x * this.proportion),
y: Math.round(point.y * this.proportion),
};
}
/**
* Drop a drag element into a certain position.
*
* @param drag The element to drop.
* @param position Position to drop to (X, Y).
*/
dropDrag(drag: HTMLElement, position: number[] | null): 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);
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 input The input element.
* @return List of coordinates.
*/
getCoords(input: HTMLElement): number[][] {
const choiceNo = this.getChoiceNoForNode(input);
const fv = input.getAttribute('value');
const infinite = input.classList.contains('infinite');
const noOfDrags = this.getNoOfDragsForNode(input);
const dragging = !!this.doc.dragItemBeingDragged(choiceNo);
const coords: number[][] = [];
if (fv !== '' && typeof fv != 'undefined' && fv !== null) {
// 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++) {
coords[coords.length] = this.convertToWindowXY(coordsStrings[i]);
}
}
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 node Element to check.
* @return Choice number.
*/
getChoiceNoForNode(node: HTMLElement): number {
return Number(this.doc.getClassnameNumericSuffix(node, 'choice'));
}
/**
* Get the coordinates (X, Y) of a draggable element.
*
* @param dragItem The draggable item.
* @return Coordinates.
*/
getDragXY(dragItem: HTMLElement): number[] {
const position = CoreDomUtils.instance.getElementXY(dragItem, undefined, 'ddarea');
const bgImg = this.doc.bgImg();
if (bgImg) {
const bgImgXY = CoreDomUtils.instance.getElementXY(bgImg, undefined, '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);
position[1] = Math.round(position[1] / this.proportion);
}
return position;
}
/**
* Get the item number from an HTML element.
*
* @param node Element to check.
* @return Choice number.
*/
getItemNoForNode(node: HTMLElement): number {
return Number(this.doc.getClassnameNumericSuffix(node, 'item'));
}
/**
* Get the next colour.
*
* @return 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 node Element to check.
* @return Choice number.
*/
getNoOfDragsForNode(node: HTMLElement): number {
return Number(this.doc.getClassnameNumericSuffix(node, 'noofdrags'));
}
/**
* Initialize the question.
*
* @param question Question.
*/
initializer(): void {
this.doc = new AddonQtypeDdMarkerQuestionDocStructure(this.container);
// Wait the DOM to be rendered.
setTimeout(() => {
this.pollForImageLoad();
});
this.resizeFunction = this.redrawDragsAndDrops.bind(this);
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();
if (!bgImg) {
return;
}
if (!bgImg.src && this.imgSrc) {
bgImg.src = this.imgSrc;
}
const imgLoaded = (): void => {
bgImg.removeEventListener('load', imgLoaded);
this.makeImageDropable();
setTimeout(() => {
this.redrawDragsAndDrops();
});
this.afterImageLoadDone = true;
this.question.loaded = true;
};
if (!bgImg.src || (bgImg.complete && bgImg.naturalWidth)) {
imgLoaded();
return;
}
bgImg.addEventListener('load', imgLoaded);
// Try again after a while.
setTimeout(() => {
this.pollForImageLoad();
}, 500);
}
/**
* Redraw all draggables and drop zones.
*/
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];
const choiceNo = this.getChoiceNoForNode(input);
const coords = this.getCoords(input);
const dragItemHome = this.doc.dragItemHome(choiceNo);
const homePosition = this.dragHomeXY(choiceNo);
if (!dragItemHome) {
continue;
}
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');
}
const placeholder = this.doc.dragItemPlaceholder(choiceNo);
// 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.classList.add('placed');
const computedStyle = getComputedStyle(dragItem);
const left = coords[i][0] - CoreDomUtils.instance.getComputedStyleMeasure(computedStyle, 'marginLeft');
const top = coords[i][1] - CoreDomUtils.instance.getComputedStyleMeasure(computedStyle, 'marginTop');
dragItem.style.left = left + 'px';
dragItem.style.top = top + 'px';
placeholder?.classList.add('active');
} else {
dragItem.classList.remove('placed');
dragItem.classList.add('unplaced');
placeholder?.classList.remove('active');
}
}
}
// 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 && this.dropZones.length !== 0) {
this.graphics.clear();
this.restartColours();
for (const dropZoneNo in this.dropZones) {
const colourForDropZone = this.getNextColour();
const dropZone = this.dropZones[dropZoneNo];
const dzNo = Number(dropZoneNo);
this.drawDropZone(dzNo, dropZone.markertext, dropZone.shape, dropZone.coords, colourForDropZone);
}
}
}
/**
* Reset the coordinates stored for a choice.
*
* @param choiceNo Choice number.
*/
resetDragXY(choiceNo: number): void {
this.setFormValue(choiceNo, '');
}
/**
* Restart the colour index.
*/
restartColours(): void {
this.nextColourIndex = 0;
}
/**
* Save all the coordinates of a choice into the right input.
*
* @param choiceNo Number of the choice.
* @param dropped Element being dropped.
* @param position Position where the element is dropped.
*/
saveAllXYForChoice(choiceNo: number, dropped: HTMLElement, position: number[] | null): void {
const coords: number[][] = [];
// 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) {
const bgImgXY = this.getDragXY(dragItem);
dragItem.classList.remove('item' + i);
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 choiceNo Choice number.
* @param value The value to set.
*/
setFormValue(choiceNo: number, value: string): void {
this.doc.inputForChoice(choiceNo)?.setAttribute('value', value);
}
/**
* Select a draggable element.
*
* @param 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);
}
}
}
/**
* Encapsulates operations on dd area.
*/
export class AddonQtypeDdMarkerQuestionDocStructure {
topNode: HTMLElement | null;
dragItemsArea: HTMLElement | null;
protected logger: CoreLogger;
constructor(
protected container: HTMLElement,
) {
this.logger = CoreLogger.getInstance('AddonQtypeDdMarkerQuestionDocStructure');
this.topNode = this.container.querySelector<HTMLElement>('.addon-qtype-ddmarker-container');
this.dragItemsArea = this.topNode?.querySelector<HTMLElement>('div.dragitems, div.draghomes') || null;
}
querySelector<T = HTMLElement>(element: HTMLElement | null, selector: string): T | null {
if (!element) {
return null;
}
return <T | null> element.querySelector(selector);
}
querySelectorAll(element: HTMLElement | null, selector: string): HTMLElement[] {
if (!element) {
return [];
}
return Array.from(element.querySelectorAll(selector));
}
bgImg(): HTMLImageElement | null {
return this.querySelector(this.topNode, '.dropbackground');
}
dragItems(): HTMLElement[] {
return this.querySelectorAll(this.dragItemsArea, '.dragitem');
}
dragItemsForChoice(choiceNo: number): HTMLElement[] {
return this.querySelectorAll(this.dragItemsArea, `span.dragitem.choice${choiceNo}`);
}
dragItemForChoice(choiceNo: number, itemNo: number): HTMLElement | null {
return this.querySelector(this.dragItemsArea, `span.dragitem.choice${choiceNo}.item${itemNo}`);
}
dragItemPlaceholder(choiceNo: number): HTMLElement | null {
return this.querySelector(this.dragItemsArea, `span.dragplaceholder.choice${choiceNo}`);
}
dragItemBeingDragged(choiceNo: number): HTMLElement | null {
return this.querySelector(this.dragItemsArea, `span.dragitem.beingdragged.choice${choiceNo}`);
}
dragItemHome(choiceNo: number): HTMLElement | null {
return this.querySelector(this.dragItemsArea, `span.draghome.choice${choiceNo}, span.marker.choice${choiceNo}`);
}
dragItemHomes(): HTMLElement[] {
return this.querySelectorAll(this.dragItemsArea, 'span.draghome, span.marker');
}
getClassnameNumericSuffix(node: HTMLElement, prefix: string): number | undefined {
if (node.classList.length) {
const patt1 = new RegExp('^' + prefix + '([0-9])+$');
const 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 this.querySelectorAll(this.topNode, 'input.choices');
}
inputForChoice(choiceNo: number): HTMLElement | null {
return this.querySelector(this.topNode, `input.choice${choiceNo}`);
}
markerTexts(): HTMLElement | null {
return this.querySelector(this.topNode, 'div.markertexts');
}
}

@ -0,0 +1,96 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { CoreDomUtils } from '@services/utils/dom';
import { AddonQtypeDdMarkerQuestion } from './ddmarker';
/**
* Graphics API for drag-and-drop markers question type.
*/
export class AddonQtypeDdMarkerGraphicsApi {
protected readonly NS = 'http://www.w3.org/2000/svg';
protected dropZone?: SVGSVGElement;
/**
* Create the instance.
*
* @param instance Question instance.
* @param domUtils Dom Utils provider.
*/
constructor(protected instance: AddonQtypeDdMarkerQuestion) { }
/**
* Add a shape.
*
* @param shapeAttribs Attributes for the shape: type and color.
* @param styles Object with the styles for the shape (name -> value).
* @return The new shape.
*/
addShape(shapeAttribs: {type: string; color: string}, styles: {[name: string]: number | string}): SVGElement {
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();
const dropZones = this.instance.doc?.topNode?.querySelector<HTMLElement>('div.ddarea div.dropzones');
const markerTexts = this.instance.doc?.markerTexts();
if (!bgImg || !dropZones || !markerTexts) {
return;
}
const position = CoreDomUtils.instance.getElementXY(bgImg, undefined, 'ddarea');
dropZones.style.left = position[0] + 'px';
dropZones.style.top = position[1] + 'px';
dropZones.style.width = bgImg.width + 'px';
dropZones.style.height = bgImg.height + 'px';
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 = [];
}
}

@ -0,0 +1,22 @@
<ion-list *ngIf="ddQuestion && (ddQuestion.text || ddQuestion.text === '')" class="addon-qtype-ddmarker-container">
<!-- Content is outside the core-loading to let the script calculate drag items position -->
<core-loading [hideUntil]="ddQuestion.loaded"></core-loading>
<ion-item class="ion-text-wrap" [hidden]="!ddQuestion.loaded">
<ion-label>
<ion-card *ngIf="!ddQuestion.readOnly" class="core-info-card">
<ion-item>
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
<ion-label>{{ 'core.question.howtodraganddrop' | translate }}</ion-label>
</ion-item>
</ion-card>
<core-format-text [component]="component" [componentId]="componentId" [text]="ddQuestion.text" #questiontext
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"
(afterRender)="textRendered()">
</core-format-text>
<core-format-text *ngIf="ddQuestion.ddArea" [adaptImg]="false" [component]="component" [componentId]="componentId"
[text]="ddQuestion.ddArea" [filter]="false" (afterRender)="ddAreaRendered()">
</core-format-text>
</ion-label>
</ion-item>
</ion-list>

@ -0,0 +1,150 @@
// Style ddmarker content a bit. Almost all these styles are copied from Moodle.
:host {
.addon-qtype-ddmarker-container {
min-height: 80px; // To display the loading.
}
core-format-text ::ng-deep {
.ddarea, .ddform {
user-select: none;
}
.qtext {
margin-bottom: 0.5em;
display: block;
}
.droparea {
display: inline-block;
}
div.droparea img {
border: 1px solid var(--gray-darker);
max-width: 100%;
}
.dropzones svg {
z-index: 3;
}
.dragitem.beingdragged .markertext {
z-index: 5;
box-shadow: var(--core-dd-question-selected-shadow);
}
.dragitems, // Previous to 3.9.
.draghomes {
&.readonly {
.dragitem,
.marker {
cursor: auto;
}
}
.dragitem, // Previous to 3.9.
.draghome,
.marker {
vertical-align: top;
cursor: pointer;
position: relative;
margin: 10px;
display: inline-block;
&.dragplaceholder {
display: none;
visibility: hidden;
&.active {
display: inline-block;
}
}
&.unplaced {
position: relative;
}
&.placed {
position: absolute;
opacity: 0.6;
}
}
}
.droparea {
.dragitem,
.marker {
cursor: pointer;
position: absolute;
vertical-align: top;
z-index: 2;
}
}
div.ddarea {
text-align: center;
position: relative;
}
div.ddarea .dropzones,
div.ddarea .markertexts {
top: 0;
left: 0;
min-height: 80px;
position: absolute;
text-align: start;
}
.dropbackground {
margin: 0 auto;
}
div.dragitems div.draghome,
div.dragitems div.dragitem,
div.draghome,
div.drag,
div.draghomes div.marker,
div.marker,
div.drag {
font: 13px/1.231 arial,helvetica,clean,sans-serif;
}
div.dragitems span.markertext,
div.draghomes span.markertext,
div.markertexts span.markertext {
margin: 0 5px;
z-index: 2;
background-color: var(--white);
border: 2px solid var(--gray-darker);
padding: 5px;
display: inline-block;
zoom: 1;
border-radius: 10px;
color: var(--ion-text-color);
}
div.markertexts span.markertext {
z-index: 3;
background-color: var(--yellow-light);
border-style: solid;
border-width: 2px;
border-color: var(--yellow);
position: absolute;
}
span.wrongpart {
background-color: var(--yellow-light);
border-style: solid;
border-width: 2px;
border-color: var(--yellow);
padding: 5px;
border-radius: 10px;
opacity: 0.6;
margin: 5px;
display: inline-block;
}
div.dragitems img.target,
div.draghomes img.target {
position: absolute;
left: -7px; /* This must be half the size of the target image, minus 0.5. */
top: -7px; /* In other words, this works for a 15x15 cross-hair. */
}
div.dragitems div.draghome img.target,
div.draghomes div.marker img.target {
display: none;
}
}
}

@ -0,0 +1,188 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, ElementRef, ViewChild } from '@angular/core';
import { AddonModQuizQuestionBasicData, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
import { CoreQuestionHelper } from '@features/question/services/question-helper';
import { CoreFilepool } from '@services/filepool';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreUrlUtils } from '@services/utils/url';
import { AddonQtypeDdMarkerQuestion } from '../classes/ddmarker';
/**
* Component to render a drag-and-drop markers question.
*/
@Component({
selector: 'addon-qtype-ddmarker',
templateUrl: 'addon-qtype-ddmarker.html',
styleUrls: ['ddmarker.scss'],
})
export class AddonQtypeDdMarkerComponent extends CoreQuestionBaseComponent implements OnInit, OnDestroy {
@ViewChild('questiontext') questionTextEl?: ElementRef;
ddQuestion?: AddonQtypeDdMarkerQuestionData;
protected questionInstance?: AddonQtypeDdMarkerQuestion;
protected dropZones: unknown[] = []; // The drop zones received in the init object of the question.
protected imgSrc?: string; // Background image URL.
protected destroyed = false;
protected textIsRendered = false;
protected ddAreaisRendered = false;
constructor(elementRef: ElementRef) {
super('AddonQtypeDdMarkerComponent', elementRef);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
if (!this.question) {
this.logger.warn('Aborting because of no question received.');
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
this.ddQuestion = this.question;
const element = CoreDomUtils.instance.convertToElement(this.question.html);
// Get D&D area, form and question text.
const ddArea = element.querySelector('.ddarea');
const ddForm = element.querySelector('.ddform');
this.ddQuestion.text = CoreDomUtils.instance.getContentsOfElement(element, '.qtext');
if (!ddArea || !ddForm || typeof this.ddQuestion.text == 'undefined') {
this.logger.warn('Aborting because of an error parsing question.', this.ddQuestion.slot);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
// Build the D&D area HTML.
this.ddQuestion.ddArea = ddArea.outerHTML;
const wrongParts = element.querySelector('.wrongparts');
if (wrongParts) {
this.ddQuestion.ddArea += wrongParts.outerHTML;
}
this.ddQuestion.ddArea += ddForm.outerHTML;
this.ddQuestion.readOnly = false;
if (this.ddQuestion.initObjects) {
// Moodle version <= 3.5.
if (typeof this.ddQuestion.initObjects.dropzones != 'undefined') {
this.dropZones = <unknown[]> this.ddQuestion.initObjects.dropzones;
}
if (typeof this.ddQuestion.initObjects.readonly != 'undefined') {
this.ddQuestion.readOnly = !!this.ddQuestion.initObjects.readonly;
}
} else if (this.ddQuestion.amdArgs) {
// Moodle version >= 3.6.
let nextIndex = 1;
// Moodle version >= 3.9, imgSrc is not specified, do not advance index.
if (this.ddQuestion.amdArgs[nextIndex] !== undefined && typeof this.ddQuestion.amdArgs[nextIndex] != 'boolean') {
this.imgSrc = <string> this.ddQuestion.amdArgs[nextIndex];
nextIndex++;
}
if (typeof this.ddQuestion.amdArgs[nextIndex] != 'undefined') {
this.ddQuestion.readOnly = !!this.ddQuestion.amdArgs[nextIndex];
}
nextIndex++;
if (typeof this.ddQuestion.amdArgs[nextIndex] != 'undefined') {
this.dropZones = <unknown[]> this.ddQuestion.amdArgs[nextIndex];
}
}
this.ddQuestion.loaded = false;
}
/**
* The question ddArea has been rendered.
*/
ddAreaRendered(): void {
this.ddAreaisRendered = true;
if (this.textIsRendered) {
this.questionRendered();
}
}
/**
* The question text has been rendered.
*/
textRendered(): void {
this.textIsRendered = true;
if (this.ddAreaisRendered) {
this.questionRendered();
}
}
/**
* The question has been rendered.
*/
protected async questionRendered(): Promise<void> {
if (this.destroyed) {
return;
}
// Download background image (3.6+ sites).
let imgSrc = this.imgSrc;
const site = CoreSites.instance.getCurrentSite();
if (this.imgSrc && site?.canDownloadFiles() && CoreUrlUtils.instance.isPluginFileUrl(this.imgSrc)) {
imgSrc = await CoreFilepool.instance.getSrcByUrl(
site.id!,
this.imgSrc,
this.component,
this.componentId,
0,
true,
true,
);
}
if (this.questionTextEl) {
await CoreDomUtils.instance.waitForImages(this.questionTextEl.nativeElement);
}
// Create the instance.
this.questionInstance = new AddonQtypeDdMarkerQuestion(
this.hostElement,
this.ddQuestion!,
!!this.ddQuestion!.readOnly,
this.dropZones,
imgSrc,
);
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.destroyed = true;
this.questionInstance?.destroy();
}
}
/**
* Data for DD Marker question.
*/
export type AddonQtypeDdMarkerQuestionData = AddonModQuizQuestionBasicData & {
loaded?: boolean;
readOnly?: boolean;
ddArea?: string;
};

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeDdMarkerComponent } from './component/ddmarker';
import { AddonQtypeDdMarkerHandler } from './services/handlers/ddmarker';
@NgModule({
declarations: [
AddonQtypeDdMarkerComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeDdMarkerHandler.instance);
},
},
],
exports: [
AddonQtypeDdMarkerComponent,
],
})
export class AddonQtypeDdMarkerModule {}

@ -0,0 +1,155 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { CoreQuestionHelper, CoreQuestionQuestion } from '@features/question/services/question-helper';
import { CoreWSExternalFile } from '@services/ws';
import { makeSingleton } from '@singletons';
import { AddonQtypeDdMarkerComponent } from '../../component/ddmarker';
/**
* Handler to support drag-and-drop markers question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeDdMarkerHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeDdMarker';
type = 'qtype_ddmarker';
/**
* 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 question The question.
* @param behaviour The default behaviour.
* @return The behaviour to use.
*/
getBehaviour(question: CoreQuestionQuestionParsed, 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonQtypeDdMarkerComponent;
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string, // eslint-disable-line @typescript-eslint/no-unused-vars
componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
): 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 True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string,
componentId: string | number,
): number {
return this.isCompleteResponse(question, answers, component, componentId);
}
/**
* Check if two responses are the same.
*
* @param question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers);
}
/**
* Get the list of files that needs to be downloaded in addition to the files embedded in the HTML.
*
* @param question Question.
* @param usageId Usage ID.
* @return List of files or URLs.
*/
getAdditionalDownloadableFiles(question: CoreQuestionQuestionParsed, usageId?: number): CoreWSExternalFile[] {
const treatedQuestion: CoreQuestionQuestion = question;
CoreQuestionHelper.instance.extractQuestionScripts(treatedQuestion, usageId);
if (treatedQuestion.amdArgs && typeof treatedQuestion.amdArgs[1] == 'string') {
// Moodle 3.6+.
return [{
fileurl: treatedQuestion.amdArgs[1],
}];
}
return [];
}
}
export class AddonQtypeDdMarkerHandler extends makeSingleton(AddonQtypeDdMarkerHandlerService) {}

@ -0,0 +1,585 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { CoreLogger } from '@singletons/logger';
import { AddonModQuizDdwtosQuestionData } from '../component/ddwtos';
/**
* Class to make a question of ddwtos type work.
*/
export class AddonQtypeDdwtosQuestion {
protected logger: CoreLogger;
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").
protected resizeFunction?: () => void;
/**
* Create the instance.
*
* @param logger Logger provider.
* @param domUtils Dom Utils provider.
* @param container The container HTMLElement of the question.
* @param question The question instance.
* @param readOnly Whether it's read only.
* @param inputIds Ids of the inputs of the question (where the answers will be stored).
*/
constructor(
protected container: HTMLElement,
protected question: AddonModQuizDdwtosQuestionData,
protected readOnly: boolean,
protected inputIds: string[],
) {
this.logger = CoreLogger.getInstance('AddonQtypeDdwtosQuestion');
this.initializer();
}
/**
* Clone a drag item and add it to the drag container.
*
* @param 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.setAttribute('tabindex', '0');
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 dragHome Element to clone.
*/
cloneDragItemsForOneChoice(dragHome: HTMLElement): void {
if (dragHome.classList.contains('infinite')) {
const groupNo = this.getGroup(dragHome) ?? -1;
const noOfDrags = this.container.querySelectorAll(this.selectors.dropsInGroup(groupNo)).length;
for (let x = 0; x < noOfDrags; x++) {
this.cloneDragItem(dragHome);
}
} else {
this.cloneDragItem(dragHome);
}
}
/**
* 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 = undefined;
}
/**
* Function to call when the instance is no longer needed.
*/
destroy(): void {
if (this.resizeFunction) {
window.removeEventListener('resize', this.resizeFunction);
}
}
/**
* Get the choice number of an element. It is extracted from the classes.
*
* @param node Element to check.
* @return Choice number.
*/
getChoice(node: HTMLElement | null): number | undefined {
return this.getClassnameNumericSuffix(node, 'choice');
}
/**
* Get the number in a certain class name of an element.
*
* @param node The element to check.
* @param prefix Prefix of the class to check.
* @return The number in the class.
*/
getClassnameNumericSuffix(node: HTMLElement | null, prefix: string): number | undefined {
if (node?.classList.length) {
const patt1 = new RegExp('^' + prefix + '([0-9])+$');
const 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 node Element to check.
* @return Group number.
*/
getGroup(node: HTMLElement | null): number | undefined {
return this.getClassnameNumericSuffix(node, 'group');
}
/**
* Get the number of an element ('no'). It is extracted from the classes.
*
* @param node Element to check.
* @return Number.
*/
getNo(node: HTMLElement | null): number | undefined {
return this.getClassnameNumericSuffix(node, 'no');
}
/**
* Get the place number of an element. It is extracted from the classes.
*
* @param node Element to check.
* @return Place number.
*/
getPlace(node: HTMLElement | null): number | undefined {
return this.getClassnameNumericSuffix(node, 'place');
}
/**
* Initialize the question.
*/
async initializer(): Promise<void> {
this.selectors = new AddonQtypeDdwtosQuestionCSSSelectors();
const container = <HTMLElement> this.container.querySelector(this.selectors.topNode());
if (this.readOnly) {
container.classList.add('readonly');
} else {
container.classList.add('notreadonly');
}
// Wait for the elements to be ready.
await this.waitForReady();
this.setPaddingSizesAll();
this.cloneDragItems();
this.initialPlaceOfDragItems();
this.makeDropZones();
this.positionDragItems();
this.resizeFunction = this.positionDragItems.bind(this);
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];
const inputNode = this.container.querySelector('input#' + inputId);
const choiceNo = Number(inputNode?.getAttribute('value'));
if (choiceNo !== 0 && !isNaN(choiceNo)) {
const drop = this.container.querySelector<HTMLElement>(this.selectors.dropForPlace(parseInt(placeNo, 10) + 1));
const groupNo = this.getGroup(drop) ?? -1;
const drag = this.container.querySelector<HTMLElement>(
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 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 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 node Element.
* @param width Width to set.
* @param height Height to set.
*/
protected padToWidthHeight(node: HTMLElement, width: number, height: number): void {
node.style.width = width + 'px';
node.style.height = height + 'px';
// Originally lineHeight was set as height to center the text but it comes on too height lines on multiline elements.
}
/**
* Place a draggable element inside a drop zone.
*
* @param drag Draggable element.
* @param drop Drop zone.
*/
placeDragInDrop(drag: HTMLElement | null, drop: HTMLElement | null): void {
if (!drop) {
return;
}
const placeNo = this.getPlace(drop) ?? -1;
const inputId = this.inputIds[placeNo - 1];
const 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) ?? -1] = placeNo;
}
}
/**
* Position a drag element in the right drop zone or in the home zone.
*
* @param drag Drag element.
*/
positionDragItem(drag: HTMLElement | null): void {
if (!drag) {
return;
}
let position;
const placeNo = this.placed[this.getNo(drag) ?? -1];
if (!placeNo) {
// Not placed, put it in home zone.
const groupNo = this.getGroup(drag) ?? -1;
const choiceNo = this.getChoice(drag) ?? -1;
position = CoreDomUtils.instance.getElementXY(
this.container,
this.selectors.dragHome(groupNo, choiceNo),
'answercontainer',
);
drag.classList.add('unplaced');
} else {
// Get the drop zone position.
position = CoreDomUtils.instance.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);
});
}
/**
* Wait for the drag items to have an offsetParent. For some reason it takes a while.
*
* @param retries Number of times this has been retried.
* @return Promise resolved when ready or if it took too long to load.
*/
protected async waitForReady(retries: number = 0): Promise<void> {
const drag = <HTMLElement | null> Array.from(this.container.querySelectorAll(this.selectors.drags()))[0];
if (drag?.offsetParent || retries >= 10) {
// Ready or too many retries, stop.
return;
}
const deferred = CoreUtils.instance.promiseDefer<void>();
setTimeout(async () => {
try {
await this.waitForReady(retries + 1);
} finally {
deferred.resolve();
}
}, 20);
return deferred.promise;
}
/**
* Remove a draggable element from a drop zone.
*
* @param drag The draggable element.
*/
removeDragFromDrop(drag: HTMLElement): void {
const placeNo = this.placed[this.getNo(drag) ?? -1];
const drop = <HTMLElement> this.container.querySelector(this.selectors.dropForPlace(placeNo));
this.placeDragInDrop(null, drop);
}
/**
* Select a certain element as being "dragged".
*
* @param 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 groupNo Group number.
*/
setPaddingSizeForGroup(groupNo: number): void {
const groupItems = <HTMLElement[]> Array.from(this.container.querySelectorAll(this.selectors.dragHomesGroup(groupNo)));
if (!groupItems.length) {
return;
}
let maxWidth = 0;
let maxHeight = 0;
// Find max height and width.
groupItems.forEach((item) => {
item.innerHTML = CoreTextUtils.instance.decodeHTML(item.innerHTML);
maxWidth = Math.max(maxWidth, Math.ceil(item.offsetWidth));
maxHeight = Math.max(maxHeight, Math.ceil(item.offsetHeight));
});
maxWidth += 8;
maxHeight += 5;
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);
});
}
}
/**
* Set of functions to get the CSS selectors.
*/
export class AddonQtypeDdwtosQuestionCSSSelectors {
topNode(): string {
return '.addon-qtype-ddwtos-container';
}
dragContainer(): string {
return this.topNode() + ' div.drags';
}
drags(): string {
return this.dragContainer() + ' span.drag';
}
drag(no: number): string {
return this.drags() + `.no${no}`;
}
dragsInGroup(groupNo: number): string {
return this.drags() + `.group${groupNo}`;
}
unplacedDragsInGroup(groupNo: number): string {
return this.dragsInGroup(groupNo) + '.unplaced';
}
dragsForChoiceInGroup(choiceNo: number, groupNo: number): string {
return this.dragsInGroup(groupNo) + `.choice${choiceNo}`;
}
unplacedDragsForChoiceInGroup(choiceNo: number, groupNo: number): string {
return this.unplacedDragsInGroup(groupNo) + `.choice${choiceNo}`;
}
drops(): string {
return this.topNode() + ' span.drop';
}
dropForPlace(placeNo: number): string {
return this.drops() + `.place${placeNo}`;
}
dropsInGroup(groupNo: number): string {
return this.drops() + `.group${groupNo}`;
}
dragHomes(): string {
return this.topNode() + ' span.draghome';
}
dragHomesGroup(groupNo: number): string {
return this.topNode() + ` .draggrouphomes${groupNo} span.draghome`;
}
dragHome(groupNo: number, choiceNo: number): string {
return this.topNode() + ` .draggrouphomes${groupNo} span.draghome.choice${choiceNo}`;
}
dropsGroup(groupNo: number): string {
return this.topNode() + ` span.drop.group${groupNo}`;
}
}

@ -0,0 +1,23 @@
<ion-list *ngIf="ddQuestion && (ddQuestion.text || ddQuestion.text === '')" class="addon-qtype-ddwtos-container">
<!-- Content is outside the core-loading to let the script calculate drag items position -->
<core-loading [hideUntil]="ddQuestion.loaded"></core-loading>
<ion-item class="ion-text-wrap addon-qtype-ddwtos-container" [hidden]="!ddQuestion.loaded">
<ion-label>
<ion-card *ngIf="!ddQuestion.readOnly" class="core-info-card">
<ion-item>
<ion-icon name="fas-info-circle" slot="start"></ion-icon>
<ion-label>{{ 'core.question.howtodraganddrop' | translate }}</ion-label>
</ion-item>
</ion-card>
<core-format-text [component]="component" [componentId]="componentId" [text]="ddQuestion.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId" #questiontext
(afterRender)="textRendered()">
</core-format-text>
<core-format-text *ngIf="ddQuestion.answers" [component]="component" [componentId]="componentId"
[text]="ddQuestion.answers" [filter]="false" (afterRender)="answersRendered()">
</core-format-text>
<div class="drags"></div>
</ion-label>
</ion-item>
</ion-list>

@ -0,0 +1,132 @@
@import "~core/features/question/question";
// Style ddwtos content a bit. Almost all these styles are copied from Moodle.
:host {
.addon-qtype-ddwtos-container {
min-height: 80px; // To display the loading.
}
core-format-text ::ng-deep, .drags ::ng-deep {
.qtext {
margin-bottom: 0.5em;
display: block;
}
.draghome {
margin-bottom: 1em;
max-width: calc(100%);
}
.answertext {
margin-bottom: 0.5em;
}
.drop {
display: inline-block;
text-align: center;
border: 1px solid var(--gray-darker);
margin-bottom: 2px;
border-radius: 5px;
cursor: pointer;
}
.draghome, .drag {
display: inline-block;
text-align: center;
background: transparent;
border: 0;
white-space: normal;
overflow: visible;
word-wrap: break-word;
}
.draghome, .drag.unplaced{
border: 1px solid var(--gray-darker);
}
.draghome {
visibility: hidden;
}
.drag {
z-index: 2;
border-radius: 5px;
line-height: 25px;
cursor: pointer;
}
.drag.selected {
z-index: 3;
box-shadow: var(--core-dd-question-selected-shadow);
}
.drop.selected {
border-color: var(--yellow-light);
box-shadow: 0 0 5px 5px var(--yellow-light);
}
&.notreadonly .drag,
&.notreadonly .draghome,
&.notreadonly .drop,
&.notreadonly .answercontainer {
cursor: pointer;
border-radius: 5px;
}
&.readonly .drag,
&.readonly .draghome,
&.readonly .drop,
&.readonly .answercontainer {
cursor: default;
}
span.incorrect {
background-color: var(--red-light);
// @include darkmode() {
// background-color: $red-dark;
// }
}
span.correct {
background-color: var(--green-light);
// @include darkmode() {
// background-color: $green-dark;
// }
}
@for $i from 0 to length($core-dd-question-colors) {
.group#{$i + 1} {
background: nth($core-dd-question-colors, $i + 1);
color: var(--ion-text-color);
}
}
.group2 {
border-radius: 10px 0 0 0;
}
.group3 {
border-radius: 0 10px 0 0;
}
.group4 {
border-radius: 0 0 10px 0;
}
.group5 {
border-radius: 0 0 0 10px;
}
.group6 {
border-radius: 0 10px 10px 0;
}
.group7 {
border-radius: 10px 0 0 10px;
}
.group8 {
border-radius: 10px 10px 10px 10px;
}
sub, sup {
font-size: 80%;
position: relative;
vertical-align: baseline;
}
sup {
top: -0.4em;
}
sub {
bottom: -0.2em;
}
}
}

@ -0,0 +1,167 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, ElementRef, ViewChild } from '@angular/core';
import { AddonModQuizQuestionBasicData, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
import { CoreQuestionHelper } from '@features/question/services/question-helper';
import { CoreDomUtils } from '@services/utils/dom';
import { AddonQtypeDdwtosQuestion } from '../classes/ddwtos';
/**
* Component to render a drag-and-drop words into sentences question.
*/
@Component({
selector: 'addon-qtype-ddwtos',
templateUrl: 'addon-qtype-ddwtos.html',
styleUrls: ['ddwtos.scss'],
})
export class AddonQtypeDdwtosComponent extends CoreQuestionBaseComponent implements OnInit, OnDestroy {
@ViewChild('questiontext') questionTextEl?: ElementRef;
ddQuestion?: AddonModQuizDdwtosQuestionData;
protected questionInstance?: AddonQtypeDdwtosQuestion;
protected inputIds: string[] = []; // Ids of the inputs of the question (where the answers will be stored).
protected destroyed = false;
protected textIsRendered = false;
protected answerAreRendered = false;
constructor(elementRef: ElementRef) {
super('AddonQtypeDdwtosComponent', elementRef);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
if (!this.question) {
this.logger.warn('Aborting because of no question received.');
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
this.ddQuestion = this.question;
const element = CoreDomUtils.instance.convertToElement(this.ddQuestion.html);
// Replace Moodle's correct/incorrect and feedback classes with our own.
CoreQuestionHelper.instance.replaceCorrectnessClasses(element);
CoreQuestionHelper.instance.replaceFeedbackClasses(element);
// Treat the correct/incorrect icons.
CoreQuestionHelper.instance.treatCorrectnessIcons(element);
const answerContainer = element.querySelector('.answercontainer');
if (!answerContainer) {
this.logger.warn('Aborting because of an error parsing question.', this.ddQuestion.slot);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
this.ddQuestion.readOnly = answerContainer.classList.contains('readonly');
this.ddQuestion.answers = answerContainer.outerHTML;
this.ddQuestion.text = CoreDomUtils.instance.getContentsOfElement(element, '.qtext');
if (typeof this.ddQuestion.text == 'undefined') {
this.logger.warn('Aborting because of an error parsing question.', this.ddQuestion.slot);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
// Get the inputs where the answers will be stored and add them to the question text.
const inputEls = <HTMLElement[]> Array.from(element.querySelectorAll('input[type="hidden"]:not([name*=sequencecheck])'));
inputEls.forEach((inputEl) => {
this.ddQuestion!.text += inputEl.outerHTML;
const id = inputEl.getAttribute('id');
if (id) {
this.inputIds.push(id);
}
});
this.ddQuestion.loaded = false;
}
/**
* The question answers have been rendered.
*/
answersRendered(): void {
this.answerAreRendered = true;
if (this.textIsRendered) {
this.questionRendered();
}
}
/**
* The question text has been rendered.
*/
textRendered(): void {
this.textIsRendered = true;
if (this.answerAreRendered) {
this.questionRendered();
}
}
/**
* The question has been rendered.
*/
protected async questionRendered(): Promise<void> {
if (this.destroyed) {
return;
}
if (this.questionTextEl) {
await CoreDomUtils.instance.waitForImages(this.questionTextEl.nativeElement);
}
// Create the instance.
this.questionInstance = new AddonQtypeDdwtosQuestion(
this.hostElement,
this.ddQuestion!,
!!this.ddQuestion!.readOnly,
this.inputIds,
);
CoreQuestionHelper.instance.treatCorrectnessIconsClicks(
this.hostElement,
this.component,
this.componentId,
this.contextLevel,
this.contextInstanceId,
this.courseId,
);
this.ddQuestion!.loaded = true;
}
/**
* Component being destroyed.
*/
ngOnDestroy(): void {
this.destroyed = true;
this.questionInstance?.destroy();
}
}
/**
* Data for DD WtoS question.
*/
export type AddonModQuizDdwtosQuestionData = AddonModQuizQuestionBasicData & {
loaded?: boolean;
readOnly?: boolean;
answers?: string;
};

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeDdwtosComponent } from './component/ddwtos';
import { AddonQtypeDdwtosHandler } from './services/handlers/ddwtos';
@NgModule({
declarations: [
AddonQtypeDdwtosComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeDdwtosHandler.instance);
},
},
],
exports: [
AddonQtypeDdwtosComponent,
],
})
export class AddonQtypeDdwtosModule {}

@ -0,0 +1,134 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { makeSingleton } from '@singletons';
import { AddonQtypeDdwtosComponent } from '../../component/ddwtos';
/**
* Handler to support drag-and-drop words into sentences question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeDdwtosHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeDdwtos';
type = 'qtype_ddwtos';
/**
* 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 question The question.
* @param behaviour The default behaviour.
* @return The behaviour to use.
*/
getBehaviour(question: CoreQuestionQuestionParsed, 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonQtypeDdwtosComponent;
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): 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 True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): 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 question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers);
}
}
export class AddonQtypeDdwtosHandler extends makeSingleton(AddonQtypeDdwtosHandlerService) {}

@ -0,0 +1,12 @@
<ion-list *ngIf="question && (question.text || question.text === '')">
<!-- "Seen" hidden input -->
<input *ngIf="seenInput" type="hidden" [name]="seenInput.name" [value]="seenInput.value" >
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="question.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
</ion-label>
</ion-item>
</ion-list>

@ -0,0 +1,53 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, ElementRef } from '@angular/core';
import { CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
/**
* Component to render a description question.
*/
@Component({
selector: 'addon-qtype-description',
templateUrl: 'addon-qtype-description.html',
})
export class AddonQtypeDescriptionComponent extends CoreQuestionBaseComponent implements OnInit {
seenInput?: { name: string; value: string };
constructor(elementRef: ElementRef) {
super('AddonQtypeDescriptionComponent', elementRef);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
const questionEl = this.initComponent();
if (!questionEl) {
return;
}
// Get the "seen" hidden input.
const input = <HTMLInputElement> questionEl.querySelector('input[type="hidden"][name*=seen]');
if (input) {
this.seenInput = {
name: input.name,
value: input.value,
};
}
}
}

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeDescriptionComponent } from './component/description';
import { AddonQtypeDescriptionHandler } from './services/handlers/description';
@NgModule({
declarations: [
AddonQtypeDescriptionComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeDescriptionHandler.instance);
},
},
],
exports: [
AddonQtypeDescriptionComponent,
],
})
export class AddonQtypeDescriptionModule {}

@ -0,0 +1,77 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { makeSingleton } from '@singletons';
import { AddonQtypeDescriptionComponent } from '../../component/description';
/**
* Handler to support description question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeDescriptionHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeDescription';
type = 'qtype_description';
/**
* 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 question The question.
* @param behaviour The default behaviour.
* @return The behaviour to use.
*/
getBehaviour(): string {
return 'informationitem';
}
/**
* 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonQtypeDescriptionComponent;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): Promise<boolean> {
return true;
}
/**
* Validate if an offline sequencecheck is valid compared with the online one.
* This function only needs to be implemented if a specific compare is required.
*
* @param question The question.
* @param offlineSequenceCheck Sequence check stored in offline.
* @return Whether sequencecheck is valid.
*/
validateSequenceCheck(): boolean {
// Descriptions don't have any answer so we'll always treat them as valid.
return true;
}
}
export class AddonQtypeDescriptionHandler extends makeSingleton(AddonQtypeDescriptionHandlerService) {}

@ -0,0 +1,87 @@
<ion-list *ngIf="essayQuestion && (essayQuestion.text || essayQuestion.text === '')">
<!-- Question text. -->
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="essayQuestion.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
</ion-label>
</ion-item>
<!-- Editing the question. -->
<ng-container *ngIf="!review">
<!-- Textarea. -->
<ion-item *ngIf="essayQuestion.textarea && (!essayQuestion.hasDraftFiles || uploadFilesSupported)">
<ion-label></ion-label>
<!-- "Format" and draftid hidden inputs -->
<input *ngIf="essayQuestion.formatInput" type="hidden" [name]="essayQuestion.formatInput.name"
[value]="essayQuestion.formatInput.value" >
<input *ngIf="essayQuestion.answerDraftIdInput" type="hidden" [name]="essayQuestion.answerDraftIdInput.name"
[value]="essayQuestion.answerDraftIdInput.value" >
<!-- Plain text textarea. -->
<ion-textarea *ngIf="essayQuestion.isPlainText" class="core-question-textarea"
[ngClass]='{"core-monospaced": essayQuestion.isMonospaced}'
placeholder="{{ 'core.question.answer' | translate }}"
[attr.name]="essayQuestion.textarea.name" aria-multiline="true" [ngModel]="essayQuestion.textarea.text">
</ion-textarea>
<!-- Rich text editor. -->
<core-rich-text-editor *ngIf="!essayQuestion.isPlainText" placeholder="{{ 'core.question.answer' | translate }}"
[control]="formControl" [name]="essayQuestion.textarea.name" [component]="component" [componentId]="componentId"
[autoSave]="false">
</core-rich-text-editor>
</ion-item>
<!-- Draft files not supported. -->
<ng-container *ngIf="essayQuestion.textarea && essayQuestion.hasDraftFiles && !uploadFilesSupported">
<ion-item class="ion-text-wrap core-danger-item">
<ion-label class="core-question-warning">
{{ 'core.question.errorembeddedfilesnotsupportedinsite' | translate }}
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="essayQuestion.textarea.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
</ion-label>
</ion-item>
</ng-container>
<!-- Attachments. -->
<ng-container *ngIf="essayQuestion.allowsAttachments">
<core-attachments *ngIf="uploadFilesSupported && essayQuestion.attachmentsDraftIdInput" [files]="attachments"
[component]="component" [componentId]="componentId" [maxSize]="essayQuestion.attachmentsMaxBytes"
[maxSubmissions]="essayQuestion.attachmentsMaxFiles" [allowOffline]="offlineEnabled"
[acceptedTypes]="essayQuestion.attachmentsAcceptedTypes">
</core-attachments>
<input *ngIf="essayQuestion.attachmentsDraftIdInput" type="hidden" [name]="essayQuestion.attachmentsDraftIdInput.name"
[value]="essayQuestion.attachmentsDraftIdInput.value" >
<!-- Attachments not supported in this site. -->
<ion-item *ngIf="!uploadFilesSupported" class="ion-text-wrap core-danger-item">
<ion-label class="core-question-warning">
{{ 'core.question.errorattachmentsnotsupportedinsite' | translate }}
</ion-label>
</ion-item>
</ng-container>
</ng-container>
<!-- Reviewing the question. -->
<ng-container *ngIf="review">
<!-- Answer to the question and attachments (reviewing). -->
<ion-item class="ion-text-wrap" *ngIf="essayQuestion.answer || essayQuestion.answer == ''">
<ion-label>
<core-format-text [ngClass]='{"core-monospaced": essayQuestion.isMonospaced}' [component]="component"
[componentId]="componentId" [text]="essayQuestion.answer" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
</ion-label>
</ion-item>
<!-- List of attachments when reviewing. -->
<core-files *ngIf="essayQuestion.attachments" [files]="essayQuestion.attachments" [component]="component"
[componentId]="componentId">
</core-files>
</ng-container>
</ion-list>

@ -0,0 +1,96 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, ElementRef } from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms';
import { FileEntry } from '@ionic-native/file/ngx';
import { CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
import { AddonModQuizEssayQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
import { CoreQuestionHelper } from '@features/question/services/question-helper';
import { CoreTextUtils } from '@services/utils/text';
import { CoreWSExternalFile } from '@services/ws';
import { CoreFileSession } from '@services/file-session';
import { CoreQuestion } from '@features/question/services/question';
/**
* Component to render an essay question.
*/
@Component({
selector: 'addon-qtype-essay',
templateUrl: 'addon-qtype-essay.html',
})
export class AddonQtypeEssayComponent extends CoreQuestionBaseComponent implements OnInit {
formControl?: FormControl;
attachments?: (CoreWSExternalFile | FileEntry)[];
uploadFilesSupported = false;
essayQuestion?: AddonModQuizEssayQuestion;
constructor(elementRef: ElementRef, protected fb: FormBuilder) {
super('AddonQtypeEssayComponent', elementRef);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.uploadFilesSupported = typeof this.question?.responsefileareas != 'undefined';
this.initEssayComponent(this.review);
this.essayQuestion = this.question;
this.formControl = this.fb.control(this.essayQuestion?.textarea?.text);
if (this.essayQuestion?.allowsAttachments && this.uploadFilesSupported && !this.review) {
this.loadAttachments();
}
}
/**
* Load attachments.
*
* @return Promise resolved when done.
*/
async loadAttachments(): Promise<void> {
if (this.offlineEnabled && this.essayQuestion?.localAnswers?.attachments_offline) {
const attachmentsData: CoreFileUploaderStoreFilesResult = CoreTextUtils.instance.parseJSON(
this.essayQuestion.localAnswers.attachments_offline,
{
online: [],
offline: 0,
},
);
let offlineFiles: FileEntry[] = [];
if (attachmentsData.offline) {
offlineFiles = <FileEntry[]> await CoreQuestionHelper.instance.getStoredQuestionFiles(
this.essayQuestion,
this.component || '',
this.componentId || -1,
);
}
this.attachments = [...attachmentsData.online, ...offlineFiles];
} else {
this.attachments = Array.from(CoreQuestionHelper.instance.getResponseFileAreaFiles(this.question!, 'attachments'));
}
CoreFileSession.instance.setFiles(
this.component || '',
CoreQuestion.instance.getQuestionComponentId(this.question!, this.componentId || -1),
this.attachments,
);
}
}

@ -0,0 +1,45 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreEditorComponentsModule } from '@features/editor/components/components.module';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeEssayHandler } from './services/handlers/essay';
import { AddonQtypeEssayComponent } from './component/essay';
@NgModule({
declarations: [
AddonQtypeEssayComponent,
],
imports: [
CoreSharedModule,
CoreEditorComponentsModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeEssayHandler.instance);
},
},
],
exports: [
AddonQtypeEssayComponent,
],
})
export class AddonQtypeEssayModule {}

@ -0,0 +1,464 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { FileEntry } from '@ionic-native/file/ngx';
import { CoreFileUploader, CoreFileUploaderStoreFilesResult } from '@features/fileuploader/services/fileuploader';
import { AddonModQuizEssayQuestion } from '@features/question/classes/base-question-component';
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { CoreQuestionHelper } from '@features/question/services/question-helper';
import { CoreFileSession } from '@services/file-session';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUtils } from '@services/utils/utils';
import { CoreWSExternalFile } from '@services/ws';
import { makeSingleton } from '@singletons';
import { AddonQtypeEssayComponent } from '../../component/essay';
/**
* Handler to support essay question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeEssayHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeEssay';
type = 'qtype_essay';
/**
* Clear temporary data after the data has been saved.
*
* @param question Question.
* @param component The component the question is related to.
* @param componentId Component ID.
*/
clearTmpData(question: CoreQuestionQuestionParsed, component: string, componentId: string | number): void {
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
const files = CoreFileSession.instance.getFiles(component, questionComponentId);
// Clear the files in session for this question.
CoreFileSession.instance.clearFiles(component, questionComponentId);
// Now delete the local files from the tmp folder.
CoreFileUploader.instance.clearTmpFiles(files);
}
/**
* Delete any stored data for the question.
*
* @param question Question.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
deleteOfflineData(
question: CoreQuestionQuestionParsed,
component: string,
componentId: string | number,
siteId?: string,
): Promise<void> {
return CoreQuestionHelper.instance.deleteStoredQuestionFiles(question, component, componentId, siteId);
}
/**
* Get the list of files that needs to be downloaded in addition to the files embedded in the HTML.
*
* @param question Question.
* @param usageId Usage ID.
* @return List of files or URLs.
*/
getAdditionalDownloadableFiles(question: CoreQuestionQuestionParsed): CoreWSExternalFile[] {
if (!question.responsefileareas) {
return [];
}
return question.responsefileareas.reduce((urlsList, area) => urlsList.concat(area.files || []), <CoreWSExternalFile[]> []);
}
/**
* Check whether the question allows text and/or attachments.
*
* @param question Question to check.
* @return Allowed options.
*/
protected getAllowedOptions(question: CoreQuestionQuestionParsed): { text: boolean; attachments: boolean } {
if (question.parsedSettings) {
return {
text: question.parsedSettings.responseformat != 'noinline',
attachments: question.parsedSettings.attachments != '0',
};
}
const element = CoreDomUtils.instance.convertToElement(question.html);
return {
text: !!element.querySelector('textarea[name*=_answer]'),
attachments: !!element.querySelector('div[id*=filemanager]'),
};
}
/**
* 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 question The question.
* @param behaviour The default behaviour.
* @return The behaviour to use.
*/
getBehaviour(): string {
return 'manualgraded';
}
/**
* 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonQtypeEssayComponent;
}
/**
* Check if a question can be submitted.
* If a question cannot be submitted it should return a message explaining why (translated or not).
*
* @param question The question.
* @return Prevent submit message. Undefined or empty if can be submitted.
*/
getPreventSubmitMessage(question: CoreQuestionQuestionParsed): string | undefined {
const element = CoreDomUtils.instance.convertToElement(question.html);
const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
if (!uploadFilesSupported && element.querySelector('div[id*=filemanager]')) {
// The question allows attachments. Since the app cannot attach files yet we will prevent submitting the question.
return 'core.question.errorattachmentsnotsupportedinsite';
}
if (!uploadFilesSupported && CoreQuestionHelper.instance.hasDraftFileUrls(element.innerHTML)) {
return 'core.question.errorembeddedfilesnotsupportedinsite';
}
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string,
componentId: string | number,
): number {
const hasTextAnswer = !!answers.answer;
const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
const allowedOptions = this.getAllowedOptions(question);
if (!allowedOptions.attachments) {
return hasTextAnswer ? 1 : 0;
}
if (!uploadFilesSupported || !question.parsedSettings) {
// We can't know if the attachments are required or if the user added any in web.
return -1;
}
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
if (!allowedOptions.text) {
return attachments && attachments.length >= Number(question.parsedSettings.attachmentsrequired) ? 1 : 0;
}
return ((hasTextAnswer || question.parsedSettings.responserequired == '0') &&
(attachments && attachments.length >= Number(question.parsedSettings.attachmentsrequired))) ? 1 : 0;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string,
componentId: string | number,
): number {
if (typeof question.responsefileareas == 'undefined') {
return -1;
}
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
// Determine if the given response has online text or attachments.
return (answers.answer && answers.answer !== '') || (attachments && attachments.length > 0) ? 1 : 0;
}
/**
* Check if two responses are the same.
*
* @param question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
component: string,
componentId: string | number,
): boolean {
const uploadFilesSupported = typeof question.responsefileareas != 'undefined';
const allowedOptions = this.getAllowedOptions(question);
// First check the inline text.
const answerIsEqual = allowedOptions.text ?
CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer') : true;
if (!allowedOptions.attachments || !uploadFilesSupported || !answerIsEqual) {
// No need to check attachments.
return answerIsEqual;
}
// Check attachments now.
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
const originalAttachments = CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'attachments');
return !CoreFileUploader.instance.areFileListDifferent(attachments, originalAttachments);
}
/**
* Prepare and add to answers the data to send to server based in the input.
*
* @param question Question.
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
* @param offline Whether the data should be saved in offline.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site.
* @return Return a promise resolved when done if async, void if sync.
*/
async prepareAnswers(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
offline: boolean,
component: string,
componentId: string | number,
siteId?: string,
): Promise<void> {
const element = CoreDomUtils.instance.convertToElement(question.html);
const attachmentsInput = <HTMLInputElement> element.querySelector('.attachments input[name*=_attachments]');
// Search the textarea to get its name.
const textarea = <HTMLTextAreaElement> element.querySelector('textarea[name*=_answer]');
if (textarea && typeof answers[textarea.name] != 'undefined') {
await this.prepareTextAnswer(question, answers, textarea, siteId);
}
if (attachmentsInput) {
await this.prepareAttachments(question, answers, offline, component, componentId, attachmentsInput, siteId);
}
}
/**
* Prepare attachments.
*
* @param question Question.
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
* @param offline Whether the data should be saved in offline.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param attachmentsInput The HTML input containing the draft ID for attachments.
* @param siteId Site ID. If not defined, current site.
* @return Return a promise resolved when done if async, void if sync.
*/
async prepareAttachments(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
offline: boolean,
component: string,
componentId: string | number,
attachmentsInput: HTMLInputElement,
siteId?: string,
): Promise<void> {
// Treat attachments if any.
const questionComponentId = CoreQuestion.instance.getQuestionComponentId(question, componentId);
const attachments = CoreFileSession.instance.getFiles(component, questionComponentId);
const draftId = Number(attachmentsInput.value);
if (offline) {
// Get the folder where to store the files.
const folderPath = CoreQuestion.instance.getQuestionFolder(question.type, component, questionComponentId, siteId);
const result = await CoreFileUploader.instance.storeFilesToUpload(folderPath, attachments);
// Store the files in the answers.
answers[attachmentsInput.name + '_offline'] = JSON.stringify(result);
} else {
// Check if any attachment was deleted.
const originalAttachments = CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'attachments');
const filesToDelete = CoreFileUploader.instance.getFilesToDelete(originalAttachments, attachments);
if (filesToDelete.length > 0) {
// Delete files.
await CoreFileUploader.instance.deleteDraftFiles(draftId, filesToDelete, siteId);
}
await CoreFileUploader.instance.uploadFiles(draftId, attachments, siteId);
}
}
/**
* Prepare data to send when performing a synchronization.
*
* @param question Question.
* @param answers Answers of the question, without the prefix.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async prepareSyncData(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string,
componentId: string | number,
siteId?: string,
): Promise<void> {
const element = CoreDomUtils.instance.convertToElement(question.html);
const attachmentsInput = <HTMLInputElement> element.querySelector('.attachments input[name*=_attachments]');
if (attachmentsInput) {
// Update the draft ID, the stored one could no longer be valid.
answers.attachments = attachmentsInput.value;
}
if (!answers || !answers.attachments_offline) {
return;
}
const attachmentsData: CoreFileUploaderStoreFilesResult = CoreTextUtils.instance.parseJSON(
<string> answers.attachments_offline,
{
online: [],
offline: 0,
},
);
delete answers.attachments_offline;
// Check if any attachment was deleted.
const originalAttachments = CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'attachments');
const filesToDelete = CoreFileUploader.instance.getFilesToDelete(originalAttachments, attachmentsData.online);
if (filesToDelete.length > 0) {
// Delete files.
await CoreFileUploader.instance.deleteDraftFiles(Number(answers.attachments), filesToDelete, siteId);
}
if (!attachmentsData.offline) {
return;
}
// Upload the offline files.
const offlineFiles =
<FileEntry[]> await CoreQuestionHelper.instance.getStoredQuestionFiles(question, component, componentId, siteId);
await CoreFileUploader.instance.uploadFiles(
Number(answers.attachments),
[...attachmentsData.online, ...offlineFiles],
siteId,
);
}
/**
* Prepare the text answer.
*
* @param question Question.
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
* @param textarea The textarea HTML element of the question.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async prepareTextAnswer(
question: AddonModQuizEssayQuestion,
answers: CoreQuestionsAnswers,
textarea: HTMLTextAreaElement,
siteId?: string,
): Promise<void> {
if (CoreQuestionHelper.instance.hasDraftFileUrls(question.html) && question.responsefileareas) {
// Restore draftfile URLs.
const site = await CoreSites.instance.getSite(siteId);
answers[textarea.name] = CoreTextUtils.instance.restoreDraftfileUrls(
site.getURL(),
<string> answers[textarea.name],
question.html,
CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'answer'),
);
}
let isPlainText = false;
if (question.isPlainText !== undefined) {
isPlainText = question.isPlainText;
} else if (question.parsedSettings) {
isPlainText = question.parsedSettings.responseformat == 'monospaced' ||
question.parsedSettings.responseformat == 'plain';
} else {
const questionEl = CoreDomUtils.instance.convertToElement(question.html);
isPlainText = !!questionEl.querySelector('.qtype_essay_monospaced') || !!questionEl.querySelector('.qtype_essay_plain');
}
if (!isPlainText) {
// Add some HTML to the text if needed.
answers[textarea.name] = CoreTextUtils.instance.formatHtmlLines(<string> answers[textarea.name]);
}
}
}
export class AddonQtypeEssayHandler extends makeSingleton(AddonQtypeEssayHandlerService) {}

@ -0,0 +1,10 @@
<ion-list class="addon-qtype-gapselect-container" *ngIf="question && (question.text || question.text === '')">
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="question.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"
(afterRender)="questionRendered()">
</core-format-text>
</ion-label>
</ion-item>
</ion-list>

@ -0,0 +1,23 @@
// Style gapselect content a bit. Most of these styles are copied from Moodle.
:host ::ng-deep {
p {
margin: 0 0 .5em;
}
select {
height: 30px;
line-height: 30px;
display: inline-block;
border: 1px solid var(--gray-dark);
padding: 4px 6px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
margin-bottom: 10px;
background: var(--gray-lighter);
// @include darkmode() {
// background: $gray-dark;
// }
}
}

@ -0,0 +1,55 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, ElementRef } from '@angular/core';
import { CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
import { CoreQuestionHelper } from '@features/question/services/question-helper';
/**
* Component to render a gap select question.
*/
@Component({
selector: 'addon-qtype-gapselect',
templateUrl: 'addon-qtype-gapselect.html',
styleUrls: ['gapselect.scss'],
})
export class AddonQtypeGapSelectComponent extends CoreQuestionBaseComponent implements OnInit {
constructor(elementRef: ElementRef) {
super('AddonQtypeGapSelectComponent', elementRef);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.initOriginalTextComponent('.qtext');
}
/**
* The question has been rendered.
*/
questionRendered(): void {
CoreQuestionHelper.instance.treatCorrectnessIconsClicks(
this.hostElement,
this.component,
this.componentId,
this.contextLevel,
this.contextInstanceId,
this.courseId,
);
}
}

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeGapSelectComponent } from './component/gapselect';
import { AddonQtypeGapSelectHandler } from './services/handlers/gapselect';
@NgModule({
declarations: [
AddonQtypeGapSelectComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeGapSelectHandler.instance);
},
},
],
exports: [
AddonQtypeGapSelectComponent,
],
})
export class AddonQtypeGapSelectModule {}

@ -0,0 +1,136 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { makeSingleton } from '@singletons';
import { AddonQtypeGapSelectComponent } from '../../component/gapselect';
/**
* Handler to support gapselect question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeGapSelectHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeGapSelect';
type = 'qtype_gapselect';
/**
* 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 question The question.
* @param behaviour The default behaviour.
* @return The behaviour to use.
*/
getBehaviour(question: CoreQuestionQuestionParsed, 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonQtypeGapSelectComponent;
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): number {
// We should always get a value for each select so we can assume we receive all the possible answers.
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 True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): number {
// We should always get a value for each select so we can assume we receive all the possible answers.
for (const name in answers) {
const value = answers[name];
if (value) {
return 1;
}
}
return 0;
}
/**
* Check if two responses are the same.
*
* @param question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers);
}
}
export class AddonQtypeGapSelectHandler extends makeSingleton(AddonQtypeGapSelectHandlerService) {}

@ -0,0 +1,29 @@
<section ion-list class="addon-qtype-match-container" *ngIf="matchQuestion && matchQuestion.loaded">
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="matchQuestion.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
</ion-label>
</ion-item>
<ion-item class="ion-text-wrap" *ngFor="let row of matchQuestion.rows">
<ion-label>
<core-format-text id="addon-qtype-match-question-{{row.id}}" [component]="component"
[componentId]="componentId" [text]="row.text" [contextLevel]="contextLevel"
[contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
<label class="accesshide" for="{{row.id}}" *ngIf="row.accessibilityLabel">
{{ row.accessibilityLabel }}
</label>
</ion-label>
<ion-select id="{{row.id}}" [name]="row.name" [(ngModel)]="row.selected" interface="action-sheet"
[attr.aria-labelledby]="'addon-qtype-match-question-' + row.id" [disabled]="row.disabled"
[ngClass]="{'addon-qtype-match-correct': row.isCorrect === 1,'addon-qtype-match-incorrect': row.isCorrect === 0}">
<ion-select-option *ngFor="let option of row.options" [value]="option.value">
{{option.label}}
</ion-select-option>
</ion-select>
<ion-icon *ngIf="row.isCorrect === 1" class="core-correct-icon" name="fas-check" color="success" slot="end"></ion-icon>
<ion-icon *ngIf="row.isCorrect === 0" class="core-correct-icon" name="fas-times" color="danger" slot="end"></ion-icon>
</ion-item>
</section>

@ -0,0 +1,13 @@
:host {
.core-correct-icon {
margin-left: 0;
}
.addon-qtype-match-correct {
color: var(--success);
}
.addon-qtype-match-incorrect {
color: var(--danger);
}
}

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, ElementRef } from '@angular/core';
import { AddonModQuizMatchQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
/**
* Component to render a match question.
*/
@Component({
selector: 'addon-qtype-match',
templateUrl: 'addon-qtype-match.html',
styleUrls: ['match.scss'],
})
export class AddonQtypeMatchComponent extends CoreQuestionBaseComponent implements OnInit {
matchQuestion?: AddonModQuizMatchQuestion;
constructor(elementRef: ElementRef) {
super('AddonQtypeMatchComponent', elementRef);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.initMatchComponent();
this.matchQuestion = this.question;
}
}

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeMatchComponent } from './component/match';
import { AddonQtypeMatchHandler } from './services/handlers/match';
@NgModule({
declarations: [
AddonQtypeMatchComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeMatchHandler.instance);
},
},
],
exports: [
AddonQtypeMatchComponent,
],
})
export class AddonQtypeMatchModule {}

@ -0,0 +1,136 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { makeSingleton } from '@singletons';
import { AddonQtypeMatchComponent } from '../../component/match';
/**
* Handler to support match question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeMatchHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeMatch';
type = 'qtype_match';
/**
* 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 question The question.
* @param behaviour The default behaviour.
* @return The behaviour to use.
*/
getBehaviour(question: CoreQuestionQuestionParsed, 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonQtypeMatchComponent;
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): number {
// We should always get a value for each select so we can assume we receive all the possible answers.
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 True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): number {
// We should always get a value for each select so we can assume we receive all the possible answers.
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 question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers);
}
}
export class AddonQtypeMatchHandler extends makeSingleton(AddonQtypeMatchHandlerService) {}

@ -0,0 +1,10 @@
<ion-list class="addon-qtype-multianswer-container" *ngIf="question && (question.text || question.text === '')">
<ion-item class="ion-text-wrap">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="question.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId"
(afterRender)="questionRendered()">
</core-format-text>
</ion-label>
</ion-item>
</ion-list>

@ -0,0 +1,38 @@
// Style multianswer content a bit. Most of these styles are copied from Moodle.
:host ::ng-deep {
p {
margin: 0 0 .5em;
}
.answer div.r0, .answer div.r1, .answer td.r0, .answer td.r1 {
padding: 0.3em;
}
table {
width: 100%;
display: table;
}
tr {
display: table-row;
}
td {
display: table-cell;
}
input, select {
border-radius: 4px;
display: inline-block;
border: 1px solid var(--gray-dark);
padding: 6px 8px;
margin-left: 2px;
margin-right: 2px;
margin-bottom: 10px;
}
select {
height: 30px;
line-height: 30px;
}
}

@ -0,0 +1,54 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, ElementRef } from '@angular/core';
import { CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
import { CoreQuestionHelper } from '@features/question/services/question-helper';
/**
* Component to render a multianswer question.
*/
@Component({
selector: 'addon-qtype-multianswer',
templateUrl: 'addon-qtype-multianswer.html',
styleUrls: ['multianswer.scss'],
})
export class AddonQtypeMultiAnswerComponent extends CoreQuestionBaseComponent implements OnInit {
constructor(elementRef: ElementRef) {
super('AddonQtypeMultiAnswerComponent', elementRef);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.initOriginalTextComponent('.formulation');
}
/**
* The question has been rendered.
*/
questionRendered(): void {
CoreQuestionHelper.instance.treatCorrectnessIconsClicks(
this.hostElement,
this.component,
this.componentId,
this.contextLevel,
this.contextInstanceId,
this.courseId,
);
}
}

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeMultiAnswerComponent } from './component/multianswer';
import { AddonQtypeMultiAnswerHandler } from './services/handlers/multianswer';
@NgModule({
declarations: [
AddonQtypeMultiAnswerComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeMultiAnswerHandler.instance);
},
},
],
exports: [
AddonQtypeMultiAnswerComponent,
],
})
export class AddonQtypeMultiAnswerModule {}

@ -0,0 +1,162 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { CoreQuestion, CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { CoreQuestionHelper } from '@features/question/services/question-helper';
import { makeSingleton } from '@singletons';
import { AddonQtypeMultiAnswerComponent } from '../../component/multianswer';
/**
* Handler to support multianswer question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeMultiAnswerHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeMultiAnswer';
type = 'qtype_multianswer';
/**
* 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 question The question.
* @param behaviour The default behaviour.
* @return The behaviour to use.
*/
getBehaviour(question: CoreQuestionQuestionParsed, 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonQtypeMultiAnswerComponent;
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): number {
// Get all the inputs in the question to check if they've all been answered.
const names = CoreQuestion.instance.getBasicAnswers<boolean>(
CoreQuestionHelper.instance.getAllInputNamesFromHtml(question.html || ''),
);
for (const name in names) {
const value = answers[name];
if (!value) {
return 0;
}
}
return 1;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
): number {
// We should always get a value for each select so we can assume we receive all the possible answers.
for (const name in answers) {
const value = answers[name];
if (value || value === false) {
return 1;
}
}
return 0;
}
/**
* Check if two responses are the same.
*
* @param question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
return CoreQuestion.instance.compareAllAnswers(prevAnswers, newAnswers);
}
/**
* Validate if an offline sequencecheck is valid compared with the online one.
* This function only needs to be implemented if a specific compare is required.
*
* @param question The question.
* @param offlineSequenceCheck Sequence check stored in offline.
* @return Whether sequencecheck is valid.
*/
validateSequenceCheck(question: CoreQuestionQuestionParsed, offlineSequenceCheck: string): boolean {
if (question.sequencecheck == Number(offlineSequenceCheck)) {
return true;
}
// For some reason, viewing a multianswer for the first time without answering it creates a new step "todo".
// We'll treat this case as valid.
if (question.sequencecheck == 2 && question.state == 'todo' && offlineSequenceCheck == '1') {
return true;
}
return false;
}
}
export class AddonQtypeMultiAnswerHandler extends makeSingleton(AddonQtypeMultiAnswerHandlerService) {}

@ -0,0 +1,67 @@
<ion-list *ngIf="multiQuestion && (multiQuestion.text || multiQuestion.text === '')">
<!-- Question text first. -->
<ion-item class="ion-text-wrap">
<ion-label>
<p><core-format-text [component]="component" [componentId]="componentId" [text]="multiQuestion.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text></p>
<p *ngIf="multiQuestion.prompt">{{ multiQuestion.prompt }}</p>
</ion-label>
</ion-item>
<!-- Checkbox for multiple choice. -->
<ng-container *ngIf="multiQuestion.multi">
<ion-item class="ion-text-wrap" *ngFor="let option of multiQuestion.options">
<ion-label [color]='(option.isCorrect === 1 ? "success": "") + (option.isCorrect === 0 ? "danger": "")'>
<core-format-text [component]="component" [componentId]="componentId" [text]="option.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
<div *ngIf="option.feedback" class="specificfeedback">
<core-format-text [component]="component" [componentId]="componentId" [text]="option.feedback"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
</div>
</ion-label>
<ion-checkbox slot="end" [attr.name]="option.name" [(ngModel)]="option.checked" [disabled]="option.disabled"
[color]='(option.isCorrect === 1 ? "success": "") + (option.isCorrect === 0 ? "danger": "")'>
</ion-checkbox>
<ion-icon *ngIf="option.isCorrect === 1" class="core-correct-icon" name="fas-check" color="success"></ion-icon>
<ion-icon *ngIf="option.isCorrect === 0" class="core-correct-icon" name="fas-times" color="danger"></ion-icon>
<!-- ion-checkbox doesn't use an input. Create a hidden input to hold the value. -->
<input type="hidden" [ngModel]="option.checked" [attr.name]="option.name">
</ion-item>
</ng-container>
<!-- Radio buttons for single choice. -->
<ion-radio-group *ngIf="!multiQuestion.multi" [(ngModel)]="multiQuestion.singleChoiceModel" [name]="multiQuestion.optionsName">
<ion-item class="ion-text-wrap" *ngFor="let option of multiQuestion.options">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="option.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
<div *ngIf="option.feedback" class="specificfeedback">
<core-format-text [component]="component" [componentId]="componentId" [text]="option.feedback"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
</div>
</ion-label>
<ion-radio [value]="option.value" [disabled]="option.disabled" slot="end"
[color]='(option.isCorrect === 1 ? "success": "") + (option.isCorrect === 0 ? "danger": "")'>
</ion-radio>
<ion-icon *ngIf="option.isCorrect === 1" class="core-correct-icon" name="fas-check" color="success"></ion-icon>
<ion-icon *ngIf="option.isCorrect === 0" class="core-correct-icon" name="fas-times" color="danger"></ion-icon>
</ion-item>
<ion-button *ngIf="!multiQuestion.disabled" class="ion-text-wrap ion-margin" expand="full" color="light"
[disabled]="!multiQuestion.singleChoiceModel" (click)="clear()" type="reset">
{{ 'addon.mod_quiz.clearchoice' | translate }}
</ion-button>
<!-- ion-radio doesn't use an input. Create a hidden input to hold the selected value. -->
<input type="hidden" [ngModel]="multiQuestion.singleChoiceModel" [attr.name]="multiQuestion.optionsName">
</ion-radio-group>
</ion-list>

@ -0,0 +1,8 @@
:host {
.specificfeedback {
background-color: var(--core-question-feedback-color-bg);
color: var(--core-question-feedback-color);
display: inline;
padding: 0 .7em;
}
}

@ -0,0 +1,50 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, ElementRef } from '@angular/core';
import { AddonModQuizMultichoiceQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
/**
* Component to render a multichoice question.
*/
@Component({
selector: 'addon-qtype-multichoice',
templateUrl: 'addon-qtype-multichoice.html',
styleUrls: ['multichoice.scss'],
})
export class AddonQtypeMultichoiceComponent extends CoreQuestionBaseComponent implements OnInit {
multiQuestion?: AddonModQuizMultichoiceQuestion;
constructor(elementRef: ElementRef) {
super('AddonQtypeMultichoiceComponent', elementRef);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.initMultichoiceComponent();
this.multiQuestion = this.question;
}
/**
* Clear selected choices.
*/
clear(): void {
this.multiQuestion!.singleChoiceModel = undefined;
}
}

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { AddonQtypeMultichoiceComponent } from './component/multichoice';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeMultichoiceHandler } from './services/handlers/multichoice';
@NgModule({
declarations: [
AddonQtypeMultichoiceComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeMultichoiceHandler.instance);
},
},
],
exports: [
AddonQtypeMultichoiceComponent,
],
})
export class AddonQtypeMultichoiceModule {}

@ -0,0 +1,201 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { AddonModQuizMultichoiceQuestion } from '@features/question/classes/base-question-component';
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import { AddonQtypeMultichoiceComponent } from '../../component/multichoice';
/**
* Handler to support multichoice question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeMultichoiceHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeMultichoice';
type = 'qtype_multichoice';
/**
* 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonQtypeMultichoiceComponent;
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string, // eslint-disable-line @typescript-eslint/no-unused-vars
componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
): number {
let isSingle = true;
let isMultiComplete = false;
// To know if it's single or multi answer we need to search for answers with "choice" in the name.
for (const name in answers) {
if (name.indexOf('choice') != -1) {
isSingle = false;
if (answers[name]) {
isMultiComplete = true;
}
}
}
if (isSingle) {
// Single.
return this.isCompleteResponseSingle(answers);
} else {
// Multi.
return isMultiComplete ? 1 : 0;
}
}
/**
* Check if a response is complete. Only for single answer.
*
* @param question The question.uestion answers (without prefix).
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponseSingle(answers: CoreQuestionsAnswers): number {
return (answers.answer && answers.answer !== '') ? 1 : 0;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string,
componentId: string | number,
): number {
return this.isCompleteResponse(question, answers, component, componentId);
}
/**
* Check if a student has provided enough of an answer for the question to be graded automatically,
* or whether it must be considered aborted. Only for single answer.
*
* @param answers Object with the question answers (without prefix).
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponseSingle(answers: CoreQuestionsAnswers): number {
return this.isCompleteResponseSingle(answers);
}
/**
* Check if two responses are the same.
*
* @param question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
let isSingle = true;
let isMultiSame = true;
// To know if it's single or multi answer we need to search for answers with "choice" in the name.
for (const name in newAnswers) {
if (name.indexOf('choice') != -1) {
isSingle = false;
if (!CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, name)) {
isMultiSame = false;
break;
}
}
}
if (isSingle) {
return this.isSameResponseSingle(prevAnswers, newAnswers);
} else {
return isMultiSame;
}
}
/**
* Check if two responses are the same. Only for single answer.
*
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @return Whether they're the same.
*/
isSameResponseSingle(prevAnswers: CoreQuestionsAnswers, newAnswers: CoreQuestionsAnswers): boolean {
return CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
}
/**
* Prepare and add to answers the data to send to server based in the input. Return promise if async.
*
* @param question Question.
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
* @param offline Whether the data should be saved in offline.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site.
* @return Return a promise resolved when done if async, void if sync.
*/
prepareAnswers(
question: AddonModQuizMultichoiceQuestion,
answers: CoreQuestionsAnswers,
): void {
if (question && !question.multi && answers[question.optionsName!] !== undefined && !answers[question.optionsName!]) {
/* It's a single choice and the user hasn't answered. Delete the answer because
sending an empty string (default value) will mark the first option as selected. */
delete answers[question.optionsName!];
}
}
}
export class AddonQtypeMultichoiceHandler extends makeSingleton(AddonQtypeMultichoiceHandlerService) {}

@ -0,0 +1,35 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeNumericalHandler } from './services/handlers/numerical';
@NgModule({
declarations: [
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeNumericalHandler.instance);
},
},
],
})
export class AddonQtypeNumericalModule {}

@ -0,0 +1,32 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 } from '@angular/core';
import { AddonQtypeCalculatedHandlerService } from '@addons/qtype/calculated/services/handlers/calculated';
import { makeSingleton } from '@singletons';
/**
* Handler to support numerical question type.
* This question type depends on calculated question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeNumericalHandlerService extends AddonQtypeCalculatedHandlerService {
name = 'AddonQtypeNumerical';
type = 'qtype_numerical';
}
export class AddonQtypeNumericalHandler extends makeSingleton(AddonQtypeNumericalHandlerService) {}

@ -0,0 +1,57 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { 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';
import { AddonQtypeMatchModule } from './match/match.module';
import { AddonQtypeMultiAnswerModule } from './multianswer/multianswer.module';
import { AddonQtypeMultichoiceModule } from './multichoice/multichoice.module';
import { AddonQtypeNumericalModule } from './numerical/numerical.module';
import { AddonQtypeRandomSaMatchModule } from './randomsamatch/randomsamatch.module';
import { AddonQtypeShortAnswerModule } from './shortanswer/shortanswer.module';
import { AddonQtypeTrueFalseModule } from './truefalse/truefalse.module';
@NgModule({
declarations: [],
imports: [
AddonQtypeCalculatedModule,
AddonQtypeCalculatedMultiModule,
AddonQtypeCalculatedSimpleModule,
AddonQtypeDdImageOrTextModule,
AddonQtypeDdMarkerModule,
AddonQtypeDdwtosModule,
AddonQtypeDescriptionModule,
AddonQtypeEssayModule,
AddonQtypeGapSelectModule,
AddonQtypeMatchModule,
AddonQtypeMultiAnswerModule,
AddonQtypeMultichoiceModule,
AddonQtypeNumericalModule,
AddonQtypeRandomSaMatchModule,
AddonQtypeShortAnswerModule,
AddonQtypeTrueFalseModule,
],
providers: [
],
exports: [],
})
export class AddonQtypeModule { }

@ -0,0 +1,34 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeRandomSaMatchHandler } from './services/handlers/randomsamatch';
@NgModule({
declarations: [
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeRandomSaMatchHandler.instance);
},
},
],
})
export class AddonQtypeRandomSaMatchModule {}

@ -0,0 +1,31 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 } from '@angular/core';
import { AddonQtypeMatchHandlerService } from '@addons/qtype/match/services/handlers/match';
import { makeSingleton } from '@singletons';
/**
* Handler to support random short-answer matching question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeRandomSaMatchHandlerService extends AddonQtypeMatchHandlerService {
name = 'AddonQtypeRandomSaMatch';
type = 'qtype_randomsamatch';
}
export class AddonQtypeRandomSaMatchHandler extends makeSingleton(AddonQtypeRandomSaMatchHandlerService) {}

@ -0,0 +1,20 @@
<ion-list *ngIf="textQuestion && (textQuestion.text || textQuestion.text === '')">
<ion-item class="ion-text-wrap addon-qtype-shortanswer-text">
<ion-label>
<core-format-text [component]="component" [componentId]="componentId" [text]="textQuestion.text"
[contextLevel]="contextLevel" [contextInstanceId]="contextInstanceId" [courseId]="courseId">
</core-format-text>
</ion-label>
</ion-item>
<ion-item *ngIf="textQuestion.input && !textQuestion.input.isInline"
class="ion-text-wrap addon-qtype-shortanswer-input core-{{textQuestion.input.correctIconColor}}-item">
<ion-label position="stacked">{{ 'addon.mod_quiz.answercolon' | translate }}</ion-label>
<ion-input type="text" [placeholder]="textQuestion.input.readOnly ? '' : 'core.question.answer' | translate"
[attr.name]="textQuestion.input.name" [value]="textQuestion.input.value" autocorrect="off"
[disabled]="textQuestion.input.readOnly">
</ion-input>
<ion-icon *ngIf="textQuestion.input.correctIcon" class="core-correct-icon" slot="end"
[name]="textQuestion.input.correctIcon" [color]="[textQuestion.input.correctIconColor]">
</ion-icon>
</ion-item>
</ion-list>

@ -0,0 +1,5 @@
:host {
.core-correct-icon {
margin-top: 14px;
}
}

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, ElementRef } from '@angular/core';
import { AddonModQuizTextQuestion, CoreQuestionBaseComponent } from '@features/question/classes/base-question-component';
/**
* Component to render a short answer question.
*/
@Component({
selector: 'addon-qtype-shortanswer',
templateUrl: 'addon-qtype-shortanswer.html',
styleUrls: ['shortanswer.scss'],
})
export class AddonQtypeShortAnswerComponent extends CoreQuestionBaseComponent implements OnInit {
textQuestion?: AddonModQuizTextQuestion;
constructor(elementRef: ElementRef) {
super('AddonQtypeShortAnswerComponent', elementRef);
}
/**
* Component being initialized.
*/
ngOnInit(): void {
this.initInputTextComponent();
this.textQuestion = this.question;
}
}

@ -0,0 +1,109 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton } from '@singletons';
import { AddonQtypeShortAnswerComponent } from '../../component/shortanswer';
/**
* Handler to support short answer question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeShortAnswerHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeShortAnswer';
type = 'qtype_shortanswer';
/**
* 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
return AddonQtypeShortAnswerComponent;
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string, // eslint-disable-line @typescript-eslint/no-unused-vars
componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
): number {
return answers.answer ? 1 : 0;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string,
componentId: string | number,
): number {
return this.isCompleteResponse(question, answers, component, componentId);
}
/**
* Check if two responses are the same.
*
* @param question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
return CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
}
}
export class AddonQtypeShortAnswerHandler extends makeSingleton(AddonQtypeShortAnswerHandlerService) {}

@ -0,0 +1,43 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreSharedModule } from '@/core/shared.module';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeShortAnswerComponent } from './component/shortanswer';
import { AddonQtypeShortAnswerHandler } from './services/handlers/shortanswer';
@NgModule({
declarations: [
AddonQtypeShortAnswerComponent,
],
imports: [
CoreSharedModule,
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeShortAnswerHandler.instance);
},
},
],
exports: [
AddonQtypeShortAnswerComponent,
],
})
export class AddonQtypeShortAnswerModule {}

@ -0,0 +1,132 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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, Type } from '@angular/core';
import { AddonQtypeMultichoiceComponent } from '@addons/qtype/multichoice/component/multichoice';
import { CoreQuestionHandler } from '@features/question/services/question-delegate';
import { CoreQuestionQuestionParsed, CoreQuestionsAnswers } from '@features/question/services/question';
import { CoreUtils } from '@services/utils/utils';
import { AddonModQuizMultichoiceQuestion } from '@features/question/classes/base-question-component';
import { makeSingleton } from '@singletons';
/**
* Handler to support true/false question type.
*/
@Injectable({ providedIn: 'root' })
export class AddonQtypeTrueFalseHandlerService implements CoreQuestionHandler {
name = 'AddonQtypeTrueFalse';
type = 'qtype_truefalse';
/**
* 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 question The question to render.
* @return The component (or promise resolved with component) to use, undefined if not found.
*/
getComponent(): Type<unknown> {
// True/false behaves like a multichoice, use the same component.
return AddonQtypeMultichoiceComponent;
}
/**
* Check if a response is complete.
*
* @param question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if complete, 0 if not complete, -1 if cannot determine.
*/
isCompleteResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string, // eslint-disable-line @typescript-eslint/no-unused-vars
componentId: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars
): number {
return answers.answer ? 1 : 0;
}
/**
* Whether or not the handler is enabled on a site level.
*
* @return True or promise resolved with true if enabled.
*/
async isEnabled(): 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 question The question.
* @param answers Object with the question answers (without prefix).
* @param component The component the question is related to.
* @param componentId Component ID.
* @return 1 if gradable, 0 if not gradable, -1 if cannot determine.
*/
isGradableResponse(
question: CoreQuestionQuestionParsed,
answers: CoreQuestionsAnswers,
component: string,
componentId: string | number,
): number {
return this.isCompleteResponse(question, answers, component, componentId);
}
/**
* Check if two responses are the same.
*
* @param question Question.
* @param prevAnswers Object with the previous question answers.
* @param newAnswers Object with the new question answers.
* @param component The component the question is related to.
* @param componentId Component ID.
* @return Whether they're the same.
*/
isSameResponse(
question: CoreQuestionQuestionParsed,
prevAnswers: CoreQuestionsAnswers,
newAnswers: CoreQuestionsAnswers,
): boolean {
return CoreUtils.instance.sameAtKeyMissingIsBlank(prevAnswers, newAnswers, 'answer');
}
/**
* Prepare and add to answers the data to send to server based in the input. Return promise if async.
*
* @param question Question.
* @param answers The answers retrieved from the form. Prepared answers must be stored in this object.
* @param offline Whether the data should be saved in offline.
* @param component The component the question is related to.
* @param componentId Component ID.
* @param siteId Site ID. If not defined, current site.
* @return Return a promise resolved when done if async, void if sync.
*/
prepareAnswers(
question: AddonModQuizMultichoiceQuestion,
answers: CoreQuestionsAnswers,
): void | Promise<void> {
if (question && answers[question.optionsName!] !== undefined && !answers[question.optionsName!]) {
// The user hasn't answered. Delete the answer to prevent marking one of the answers automatically.
delete answers[question.optionsName!];
}
}
}
export class AddonQtypeTrueFalseHandler extends makeSingleton(AddonQtypeTrueFalseHandlerService) {}

@ -0,0 +1,34 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { APP_INITIALIZER, NgModule } from '@angular/core';
import { CoreQuestionDelegate } from '@features/question/services/question-delegate';
import { AddonQtypeTrueFalseHandler } from './services/handlers/truefalse';
@NgModule({
declarations: [
],
providers: [
{
provide: APP_INITIALIZER,
multi: true,
deps: [],
useFactory: () => () => {
CoreQuestionDelegate.instance.registerHandler(AddonQtypeTrueFalseHandler.instance);
},
},
],
})
export class AddonQtypeTrueFalseModule {}

@ -31,6 +31,7 @@ import { makeSingleton, Translate, MediaCapture, ModalController, Camera } from
import { CoreLogger } from '@singletons/logger'; import { CoreLogger } from '@singletons/logger';
import { CoreEmulatorCaptureMediaComponent } from '@features/emulator/components/capture-media/capture-media'; import { CoreEmulatorCaptureMediaComponent } from '@features/emulator/components/capture-media/capture-media';
import { CoreError } from '@classes/errors/error'; import { CoreError } from '@classes/errors/error';
import { CoreSite } from '@classes/site';
/** /**
* File upload options. * File upload options.
@ -97,6 +98,36 @@ export class CoreFileUploaderProvider {
return false; return false;
} }
/**
* Check if a certain site allows deleting draft files.
*
* @param siteId Site Id. If not defined, use current site.
* @return Promise resolved with true if can delete.
* @since 3.10
*/
async canDeleteDraftFiles(siteId?: string): Promise<boolean> {
try {
const site = await CoreSites.instance.getSite(siteId);
return this.canDeleteDraftFilesInSite(site);
} catch (error) {
return false;
}
}
/**
* Check if a certain site allows deleting draft files.
*
* @param site Site. If not defined, use current site.
* @return Whether draft files can be deleted.
* @since 3.10
*/
canDeleteDraftFilesInSite(site?: CoreSite): boolean {
site = site || CoreSites.instance.getCurrentSite();
return !!(site?.wsAvailable('core_files_delete_draft_files'));
}
/** /**
* Start the audio recorder application and return information about captured audio clip files. * Start the audio recorder application and return information about captured audio clip files.
* *
@ -175,6 +206,25 @@ export class CoreFileUploaderProvider {
}); });
} }
/**
* Delete draft files.
*
* @param draftId Draft ID.
* @param files Files to delete.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deleteDraftFiles(draftId: number, files: { filepath: string; filename: string }[], siteId?: string): Promise<void> {
const site = await CoreSites.instance.getSite(siteId);
const params = {
draftitemid: draftId,
files: files,
};
return site.write('core_files_delete_draft_files', params);
}
/** /**
* Get the upload options for a file taken with the Camera Cordova plugin. * Get the upload options for a file taken with the Camera Cordova plugin.
* *
@ -217,6 +267,35 @@ export class CoreFileUploaderProvider {
return options; return options;
} }
/**
* Given a list of original files and a list of current files, return the list of files to delete.
*
* @param originalFiles Original files.
* @param currentFiles Current files.
* @return List of files to delete.
*/
getFilesToDelete(
originalFiles: CoreWSExternalFile[],
currentFiles: (CoreWSExternalFile | FileEntry)[],
): { filepath: string; filename: string }[] {
const filesToDelete: { filepath: string; filename: string }[] = [];
currentFiles = currentFiles || [];
originalFiles.forEach((file) => {
const stillInList = currentFiles.some((currentFile) => (<CoreWSExternalFile> currentFile).fileurl == file.fileurl);
if (!stillInList) {
filesToDelete.push({
filepath: file.filepath!,
filename: file.filename!,
});
}
});
return filesToDelete;
}
/** /**
* Get the upload options for a file of any type. * Get the upload options for a file of any type.
* *
@ -541,6 +620,46 @@ export class CoreFileUploaderProvider {
return result; return result;
} }
/**
* Given a list of files (either online files or local files), upload the local files to the draft area.
* Local files are not deleted from the device after upload.
*
* @param itemId Draft ID.
* @param files List of files.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the itemId.
*/
async uploadFiles(itemId: number, files: (CoreWSExternalFile | FileEntry)[], siteId?: string): Promise<void> {
siteId = siteId || CoreSites.instance.getCurrentSiteId();
if (!files || !files.length) {
return;
}
// Index the online files by name.
const usedNames: {[name: string]: (CoreWSExternalFile | FileEntry)} = {};
const filesToUpload: FileEntry[] = [];
files.forEach((file) => {
if (CoreUtils.instance.isFileEntry(file)) {
filesToUpload.push(<FileEntry> file);
} else {
// It's an online file.
usedNames[file.filename!.toLowerCase()] = file;
}
});
await Promise.all(filesToUpload.map(async (file) => {
// Make sure the file name is unique in the area.
const name = CoreFile.instance.calculateUniqueName(usedNames, file.name);
usedNames[name] = file;
// Now upload the file.
const options = this.getFileUploadOptions(file.toURL(), name, undefined, false, 'draft', itemId);
await this.uploadFile(file.toURL(), options, undefined, siteId);
}));
}
/** /**
* Upload a file to a draft area and return the draft ID. * Upload a file to a draft area and return the draft ID.
* *

@ -0,0 +1,784 @@
// (C) Copyright 2015 Moodle Pty Ltd.
//
// 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 { Input, Output, EventEmitter, Component, Optional, Inject, ElementRef } from '@angular/core';
import { CoreSites } from '@services/sites';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreTextUtils } from '@services/utils/text';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreWSExternalFile } from '@services/ws';
import { CoreLogger } from '@singletons/logger';
import { CoreQuestionBehaviourButton, CoreQuestionHelper, CoreQuestionQuestion } from '../services/question-helper';
/**
* Base class for components to render a question.
*/
@Component({
template: '',
})
export class CoreQuestionBaseComponent {
@Input() question?: AddonModQuizQuestion; // The question to render.
@Input() component?: string; // The component the question belongs to.
@Input() componentId?: number; // ID of the component the question belongs to.
@Input() attemptId?: number; // Attempt ID.
@Input() offlineEnabled?: boolean | string; // Whether the question can be answered in offline.
@Input() contextLevel?: string; // The context level.
@Input() contextInstanceId?: number; // The instance ID related to the context.
@Input() courseId?: number; // The course the question belongs to (if any).
@Input() review?: boolean; // Whether the user is in review mode.
@Output() buttonClicked = new EventEmitter<CoreQuestionBehaviourButton>(); // Will emit when a behaviour button is clicked.
@Output() onAbort = new EventEmitter<void>(); // Should emit an event if the question should be aborted.
protected logger: CoreLogger;
protected hostElement: HTMLElement;
constructor(@Optional() @Inject('') logName: string, elementRef: ElementRef) {
this.logger = CoreLogger.getInstance(logName);
this.hostElement = elementRef.nativeElement;
}
/**
* Initialize a question component of type calculated or calculated simple.
*
* @return Element containing the question HTML, void if the data is not valid.
*/
initCalculatedComponent(): void | HTMLElement {
// Treat the input text first.
const questionEl = this.initInputTextComponent();
if (!questionEl) {
return;
}
// Check if the question has a select for units.
if (this.treatCalculatedSelectUnits(questionEl)) {
return questionEl;
}
// Check if the question has radio buttons for units.
if (this.treatCalculatedRadioUnits(questionEl)) {
return questionEl;
}
return questionEl;
}
/**
* Treat a calculated question units in case they use radio buttons.
*
* @param questionEl Question HTML element.
* @return True if question has units using radio buttons.
*/
protected treatCalculatedRadioUnits(questionEl: HTMLElement): boolean {
// Check if the question has radio buttons for units.
const radios = <HTMLInputElement[]> Array.from(questionEl.querySelectorAll('input[type="radio"]'));
if (!radios.length) {
return false;
}
const question = <AddonModQuizCalculatedQuestion> this.question!;
question.options = [];
for (const i in radios) {
const radioEl = radios[i];
const option: AddonModQuizQuestionRadioOption = {
id: radioEl.id,
name: radioEl.name,
value: radioEl.value,
checked: radioEl.checked,
disabled: radioEl.disabled,
};
// Get the label with the question text.
const label = <HTMLElement> questionEl.querySelector('label[for="' + option.id + '"]');
question.optionsName = option.name;
if (!label || option.name === undefined || option.value === undefined) {
// Something went wrong when extracting the questions data. Abort.
this.logger.warn('Aborting because of an error parsing options.', question.slot, option.name);
CoreQuestionHelper.instance.showComponentError(this.onAbort);
return true;
}
option.text = label.innerText;
if (radioEl.checked) {
// If the option is checked we use the model to select the one.
question.unit = option.value;
}
question.options.push(option);
}
// Check which one should be displayed first: the options or the input.
if (question.parsedSettings && question.parsedSettings.unitsleft !== null) {
question.optionsFirst = question.parsedSettings.unitsleft == '1';
} else {
const input = questionEl.querySelector('input[type="text"][name*=answer]');
question.optionsFirst =
questionEl.innerHTML.indexOf(input?.outerHTML || '') > questionEl.innerHTML.indexOf(radios[0].outerHTML);
}
return true;
}
/**
* Treat a calculated question units in case they use a select.
*
* @param questionEl Question HTML element.
* @return True if question has units using a select.
*/
protected treatCalculatedSelectUnits(questionEl: HTMLElement): boolean {
// Check if the question has a select for units.
const select = <HTMLSelectElement> questionEl.querySelector('select[name*=unit]');
const options = select && Array.from(select.querySelectorAll('option'));
if (!select || !options?.length) {
return false;
}
const question = <AddonModQuizCalculatedQuestion> this.question!;
const selectModel: AddonModQuizQuestionSelect = {
id: select.id,
name: select.name,
disabled: select.disabled,
options: [],
};
// Treat each option.
for (const i in options) {
const optionEl = options[i];
if (typeof optionEl.value == 'undefined') {
this.logger.warn('Aborting because couldn\'t find input.', this.question?.slot);
CoreQuestionHelper.instance.showComponentError(this.onAbort);
return true;
}
const option: AddonModQuizQuestionSelectOption = {
value: optionEl.value,
label: optionEl.innerHTML,
selected: optionEl.selected,
};
if (optionEl.selected) {
selectModel.selected = option.value;
}
selectModel.options.push(option);
}
if (!selectModel.selected) {
// No selected option, select the first one.
selectModel.selected = selectModel.options[0].value;
}
// Get the accessibility label.
const accessibilityLabel = questionEl.querySelector('label[for="' + select.id + '"]');
selectModel.accessibilityLabel = accessibilityLabel?.innerHTML;
question.select = selectModel;
// Check which one should be displayed first: the select or the input.
if (question.parsedSettings && question.parsedSettings.unitsleft !== null) {
question.selectFirst = question.parsedSettings.unitsleft == '1';
} else {
const input = questionEl.querySelector('input[type="text"][name*=answer]');
question.selectFirst =
questionEl.innerHTML.indexOf(input?.outerHTML || '') > questionEl.innerHTML.indexOf(select.outerHTML);
}
return true;
}
/**
* Initialize the component and the question text.
*
* @return Element containing the question HTML, void if the data is not valid.
*/
initComponent(): void | HTMLElement {
if (!this.question) {
this.logger.warn('Aborting because of no question received.');
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
this.hostElement.classList.add('core-question-container');
const element = CoreDomUtils.instance.convertToElement(this.question.html);
// Extract question text.
this.question.text = CoreDomUtils.instance.getContentsOfElement(element, '.qtext');
if (typeof this.question.text == 'undefined') {
this.logger.warn('Aborting because of an error parsing question.', this.question.slot);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
return element;
}
/**
* Initialize a question component of type essay.
*
* @param review Whether we're in review mode.
* @return Element containing the question HTML, void if the data is not valid.
*/
initEssayComponent(review?: boolean): void | HTMLElement {
const questionEl = this.initComponent();
if (!questionEl) {
return;
}
const question = <AddonModQuizEssayQuestion> this.question!;
const answerDraftIdInput = <HTMLInputElement> questionEl.querySelector('input[name*="_answer:itemid"]');
if (question.parsedSettings) {
question.allowsAttachments = question.parsedSettings.attachments != '0';
question.allowsAnswerFiles = question.parsedSettings.responseformat == 'editorfilepicker';
question.isMonospaced = question.parsedSettings.responseformat == 'monospaced';
question.isPlainText = question.isMonospaced || question.parsedSettings.responseformat == 'plain';
question.hasInlineText = question.parsedSettings.responseformat != 'noinline';
} else {
question.allowsAttachments = !!questionEl.querySelector('div[id*=filemanager]');
question.allowsAnswerFiles = !!answerDraftIdInput;
question.isMonospaced = !!questionEl.querySelector('.qtype_essay_monospaced');
question.isPlainText = question.isMonospaced || !!questionEl.querySelector('.qtype_essay_plain');
}
if (review) {
// Search the answer and the attachments.
question.answer = CoreDomUtils.instance.getContentsOfElement(questionEl, '.qtype_essay_response');
if (question.parsedSettings) {
question.attachments = Array.from(
CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'attachments'),
);
} else {
question.attachments = CoreQuestionHelper.instance.getQuestionAttachmentsFromHtml(
CoreDomUtils.instance.getContentsOfElement(questionEl, '.attachments') || '',
);
}
return questionEl;
}
const textarea = <HTMLTextAreaElement> questionEl.querySelector('textarea[name*=_answer]');
question.hasDraftFiles = question.allowsAnswerFiles && CoreQuestionHelper.instance.hasDraftFileUrls(questionEl.innerHTML);
if (!textarea && (question.hasInlineText || !question.allowsAttachments)) {
// Textarea not found, we might be in review. Search the answer and the attachments.
question.answer = CoreDomUtils.instance.getContentsOfElement(questionEl, '.qtype_essay_response');
question.attachments = CoreQuestionHelper.instance.getQuestionAttachmentsFromHtml(
CoreDomUtils.instance.getContentsOfElement(questionEl, '.attachments') || '',
);
return questionEl;
}
if (textarea) {
const input = <HTMLInputElement> questionEl.querySelector('input[type="hidden"][name*=answerformat]');
let content = CoreTextUtils.instance.decodeHTML(textarea.innerHTML || '');
if (question.hasDraftFiles && question.responsefileareas) {
content = CoreTextUtils.instance.replaceDraftfileUrls(
CoreSites.instance.getCurrentSite()!.getURL(),
content,
CoreQuestionHelper.instance.getResponseFileAreaFiles(question, 'answer'),
).text;
}
question.textarea = {
id: textarea.id,
name: textarea.name,
text: content,
};
if (input) {
question.formatInput = {
name: input.name,
value: input.value,
};
}
}
if (answerDraftIdInput) {
question.answerDraftIdInput = {
name: answerDraftIdInput.name,
value: Number(answerDraftIdInput.value),
};
}
if (question.allowsAttachments) {
const attachmentsInput = <HTMLInputElement> questionEl.querySelector('.attachments input[name*=_attachments]');
const objectElement = <HTMLObjectElement> questionEl.querySelector('.attachments object');
const fileManagerUrl = objectElement && objectElement.data;
if (attachmentsInput) {
question.attachmentsDraftIdInput = {
name: attachmentsInput.name,
value: Number(attachmentsInput.value),
};
}
if (question.parsedSettings) {
question.attachmentsMaxFiles = Number(question.parsedSettings.attachments);
question.attachmentsAcceptedTypes = (<string[] | undefined> question.parsedSettings.filetypeslist)?.join(',');
}
if (fileManagerUrl) {
const params = CoreUrlUtils.instance.extractUrlParams(fileManagerUrl);
const maxBytes = Number(params.maxbytes);
const areaMaxBytes = Number(params.areamaxbytes);
question.attachmentsMaxBytes = maxBytes === -1 || areaMaxBytes === -1 ?
Math.max(maxBytes, areaMaxBytes) : Math.min(maxBytes, areaMaxBytes);
}
}
return questionEl;
}
/**
* Initialize a question component that uses the original question text with some basic treatment.
*
* @param contentSelector The selector to find the question content (text).
* @return Element containing the question HTML, void if the data is not valid.
*/
initOriginalTextComponent(contentSelector: string): void | HTMLElement {
if (!this.question) {
this.logger.warn('Aborting because of no question received.');
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
const element = CoreDomUtils.instance.convertToElement(this.question.html);
// Get question content.
const content = <HTMLElement> element.querySelector(contentSelector);
if (!content) {
this.logger.warn('Aborting because of an error parsing question.', this.question.slot);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
// Remove sequencecheck and validation error.
CoreDomUtils.instance.removeElement(content, 'input[name*=sequencecheck]');
CoreDomUtils.instance.removeElement(content, '.validationerror');
// Replace Moodle's correct/incorrect and feedback classes with our own.
CoreQuestionHelper.instance.replaceCorrectnessClasses(element);
CoreQuestionHelper.instance.replaceFeedbackClasses(element);
// Treat the correct/incorrect icons.
CoreQuestionHelper.instance.treatCorrectnessIcons(element);
// Set the question text.
this.question.text = content.innerHTML;
return element;
}
/**
* Initialize a question component that has an input of type "text".
*
* @return Element containing the question HTML, void if the data is not valid.
*/
initInputTextComponent(): void | HTMLElement {
const questionEl = this.initComponent();
if (!questionEl) {
return;
}
// Get the input element.
const question = <AddonModQuizTextQuestion> this.question!;
const input = <HTMLInputElement> questionEl.querySelector('input[type="text"][name*=answer]');
if (!input) {
this.logger.warn('Aborting because couldn\'t find input.', this.question!.slot);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
question.input = {
id: input.id,
name: input.name,
value: input.value,
readOnly: input.readOnly,
isInline: !!CoreDomUtils.instance.closest(input, '.qtext'), // The answer can be inside the question text.
};
// Check if question is marked as correct.
if (input.classList.contains('incorrect')) {
question.input.correctClass = 'core-question-incorrect';
question.input.correctIcon = 'fa-remove';
question.input.correctIconColor = 'danger';
} else if (input.classList.contains('correct')) {
question.input.correctClass = 'core-question-correct';
question.input.correctIcon = 'fa-check';
question.input.correctIconColor = 'success';
} else if (input.classList.contains('partiallycorrect')) {
question.input.correctClass = 'core-question-partiallycorrect';
question.input.correctIcon = 'fa-check-square';
question.input.correctIconColor = 'warning';
} else {
question.input.correctClass = '';
question.input.correctIcon = '';
question.input.correctIconColor = '';
}
if (question.input.isInline) {
// Handle correct/incorrect classes and icons.
const content = <HTMLElement> questionEl.querySelector('.qtext');
CoreQuestionHelper.instance.replaceCorrectnessClasses(content);
CoreQuestionHelper.instance.treatCorrectnessIcons(content);
question.text = content.innerHTML;
}
return questionEl;
}
/**
* Initialize a question component with a "match" behaviour.
*
* @return Element containing the question HTML, void if the data is not valid.
*/
initMatchComponent(): void | HTMLElement {
const questionEl = this.initComponent();
if (!questionEl) {
return;
}
// Find rows.
const question = <AddonModQuizMatchQuestion> this.question!;
const rows = Array.from(questionEl.querySelectorAll('table.answer tr'));
if (!rows || !rows.length) {
this.logger.warn('Aborting because couldn\'t find any row.', question.slot);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
question.rows = [];
for (const i in rows) {
const row = rows[i];
const columns = Array.from(row.querySelectorAll('td'));
if (!columns || columns.length < 2) {
this.logger.warn('Aborting because couldn\'t the right columns.', question.slot);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
// Get the select and the options.
const select = columns[1].querySelector('select');
const options = Array.from(columns[1].querySelectorAll('option'));
if (!select || !options || !options.length) {
this.logger.warn('Aborting because couldn\'t find select or options.', question.slot);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
const rowModel: AddonModQuizQuestionMatchSelect = {
id: select.id.replace(/:/g, '\\:'),
name: select.name,
disabled: select.disabled,
options: [],
text: columns[0].innerHTML, // Row's text should be in the first column.
};
// Check if answer is correct.
if (columns[1].className.indexOf('incorrect') >= 0) {
rowModel.isCorrect = 0;
} else if (columns[1].className.indexOf('correct') >= 0) {
rowModel.isCorrect = 1;
}
// Treat each option.
for (const j in options) {
const optionEl = options[j];
if (typeof optionEl.value == 'undefined') {
this.logger.warn('Aborting because couldn\'t find the value of an option.', question.slot);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
const option: AddonModQuizQuestionSelectOption = {
value: optionEl.value,
label: optionEl.innerHTML,
selected: optionEl.selected,
};
if (option.selected) {
rowModel.selected = option.value;
}
rowModel.options.push(option);
}
// Get the accessibility label.
const accessibilityLabel = columns[1].querySelector('label.accesshide');
rowModel.accessibilityLabel = accessibilityLabel?.innerHTML;
question.rows.push(rowModel);
}
question.loaded = true;
return questionEl;
}
/**
* Initialize a question component with a multiple choice (checkbox) or single choice (radio).
*
* @return Element containing the question HTML, void if the data is not valid.
*/
initMultichoiceComponent(): void | HTMLElement {
const questionEl = this.initComponent();
if (!questionEl) {
return;
}
// Get the prompt.
const question = <AddonModQuizMultichoiceQuestion> this.question!;
question.prompt = CoreDomUtils.instance.getContentsOfElement(questionEl, '.prompt');
// Search radio buttons first (single choice).
let options = <HTMLInputElement[]> Array.from(questionEl.querySelectorAll('input[type="radio"]'));
if (!options || !options.length) {
// Radio buttons not found, it should be a multi answer. Search for checkbox.
question.multi = true;
options = <HTMLInputElement[]> Array.from(questionEl.querySelectorAll('input[type="checkbox"]'));
if (!options || !options.length) {
// No checkbox found either. Abort.
this.logger.warn('Aborting because of no radio and checkbox found.', question.slot);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
}
question.options = [];
question.disabled = true;
for (const i in options) {
const element = options[i];
const option: AddonModQuizQuestionRadioOption = {
id: element.id,
name: element.name,
value: element.value,
checked: element.checked,
disabled: element.disabled,
};
const parent = element.parentElement;
if (option.value == '-1') {
// It's the clear choice option, ignore it.
continue;
}
question.optionsName = option.name;
question.disabled = question.disabled && element.disabled;
// Get the label with the question text. Try the new format first.
const labelId = element.getAttribute('aria-labelledby');
let label = labelId ? questionEl.querySelector('#' + labelId.replace(/:/g, '\\:')) : undefined;
if (!label) {
// Not found, use the old format.
label = questionEl.querySelector('label[for="' + option.id + '"]');
}
// Check that we were able to successfully extract options required data.
if (!label || option.name === undefined || option.value === undefined) {
// Something went wrong when extracting the questions data. Abort.
this.logger.warn('Aborting because of an error parsing options.', question.slot, option.name);
return CoreQuestionHelper.instance.showComponentError(this.onAbort);
}
option.text = label.innerHTML;
if (element.checked) {
// If the option is checked and it's a single choice we use the model to select the one.
if (!question.multi) {
question.singleChoiceModel = option.value;
}
if (parent) {
// Check if answer is correct.
if (parent && parent.className.indexOf('incorrect') >= 0) {
option.isCorrect = 0;
} else if (parent && parent.className.indexOf('correct') >= 0) {
option.isCorrect = 1;
}
// Search the feedback.
const feedback = parent.querySelector('.specificfeedback');
if (feedback) {
option.feedback = feedback.innerHTML;
}
}
}
question.options.push(option);
}
return questionEl;
}
}
/**
* Any possible types of question.
*/
export type AddonModQuizQuestion = AddonModQuizCalculatedQuestion | AddonModQuizEssayQuestion | AddonModQuizTextQuestion |
AddonModQuizMatchQuestion | AddonModQuizMultichoiceQuestion;
/**
* Basic data for question.
*/
export type AddonModQuizQuestionBasicData = CoreQuestionQuestion & {
text?: string;
};
/**
* Data for calculated question.
*/
export type AddonModQuizCalculatedQuestion = AddonModQuizTextQuestion & {
select?: AddonModQuizQuestionSelect; // Select data if units use a select.
selectFirst?: boolean; // Whether the select is first or after the input.
options?: AddonModQuizQuestionRadioOption[]; // Options if units use radio buttons.
optionsName?: string; // Options name (for radio buttons).
unit?: string; // Option selected (for radio buttons).
optionsFirst?: boolean; // Whether the radio buttons are first or after the input.
};
/**
* Data for a select.
*/
export type AddonModQuizQuestionSelect = {
id: string;
name: string;
disabled: boolean;
options: AddonModQuizQuestionSelectOption[];
selected?: string;
accessibilityLabel?: string;
};
/**
* Data for each option in a select.
*/
export type AddonModQuizQuestionSelectOption = {
value: string;
label: string;
selected: boolean;
};
/**
* Data for radio button.
*/
export type AddonModQuizQuestionRadioOption = {
id: string;
name: string;
value: string;
disabled: boolean;
checked: boolean;
text?: string;
isCorrect?: number;
feedback?: string;
};
/**
* Data for essay question.
*/
export type AddonModQuizEssayQuestion = AddonModQuizQuestionBasicData & {
allowsAttachments?: boolean; // Whether the question allows attachments.
allowsAnswerFiles?: boolean; // Whether the question allows adding files in the answer.
isMonospaced?: boolean; // Whether the answer is monospaced.
isPlainText?: boolean; // Whether the answer is plain text.
hasInlineText?: boolean; // // Whether the answer has inline text
answer?: string; // Question answer text.
attachments?: CoreWSExternalFile[]; // Question answer attachments.
hasDraftFiles?: boolean; // Whether the question has draft files.
textarea?: AddonModQuizQuestionTextarea; // Textarea data.
formatInput?: { name: string; value: string }; // Format input data.
answerDraftIdInput?: { name: string; value: number }; // Answer draft id input data.
attachmentsDraftIdInput?: { name: string; value: number }; // Attachments draft id input data.
attachmentsMaxFiles?: number; // Max number of attachments.
attachmentsAcceptedTypes?: string; // Attachments accepted file types.
attachmentsMaxBytes?: number; // Max bytes for attachments.
};
/**
* Data for textarea.
*/
export type AddonModQuizQuestionTextarea = {
id: string;
name: string;
text: string;
};
/**
* Data for text question.
*/
export type AddonModQuizTextQuestion = AddonModQuizQuestionBasicData & {
input?: AddonModQuizQuestionTextInput;
};
/**
* Data for text input.
*/
export type AddonModQuizQuestionTextInput = {
id: string;
name: string;
value: string;
readOnly: boolean;
isInline: boolean;
correctClass?: string;
correctIcon?: string;
correctIconColor?: string;
};
/**
* Data for match question.
*/
export type AddonModQuizMatchQuestion = AddonModQuizQuestionBasicData & {
loaded?: boolean; // Whether the question is loaded.
rows?: AddonModQuizQuestionMatchSelect[]; // Data for each row.
};
/**
* Each select data for match questions.
*/
export type AddonModQuizQuestionMatchSelect = AddonModQuizQuestionSelect & {
text: string;
isCorrect?: number;
};
/**
* Data for multichoice question.
*/
export type AddonModQuizMultichoiceQuestion = AddonModQuizQuestionBasicData & {
prompt?: string; // Question prompt.
multi?: boolean; // Whether the question allows more than one selected answer.
options?: AddonModQuizQuestionRadioOption[]; // List of options.
disabled?: boolean; // Whether the question is disabled.
optionsName?: string; // Name to use for the options in single choice.
singleChoiceModel?: string; // Model for single choice.
};

@ -1095,7 +1095,6 @@ export class CoreFileProvider {
const entries = await this.getDirectoryContents(dirPath); const entries = await this.getDirectoryContents(dirPath);
const files = {}; const files = {};
let num = 1;
let fileNameWithoutExtension = CoreMimetypeUtils.instance.removeExtension(fileName); let fileNameWithoutExtension = CoreMimetypeUtils.instance.removeExtension(fileName);
let extension = CoreMimetypeUtils.instance.getFileExtension(fileName) || defaultExt; let extension = CoreMimetypeUtils.instance.getFileExtension(fileName) || defaultExt;
@ -1116,26 +1115,40 @@ export class CoreFileProvider {
extension = ''; extension = '';
} }
let newName = fileNameWithoutExtension + extension; return this.calculateUniqueName(files, fileNameWithoutExtension + extension);
if (typeof files[newName.toLowerCase()] == 'undefined') {
// No file with the same name.
return newName;
} else {
// Repeated name. Add a number until we find a free name.
do {
newName = fileNameWithoutExtension + '(' + num + ')' + extension;
num++;
} while (typeof files[newName.toLowerCase()] != 'undefined');
// Ask the user what he wants to do.
return newName;
}
} catch (error) { } catch (error) {
// Folder doesn't exist, name is unique. Clean it and return it. // Folder doesn't exist, name is unique. Clean it and return it.
return CoreTextUtils.instance.removeSpecialCharactersForFiles(CoreTextUtils.instance.decodeURIComponent(fileName)); return CoreTextUtils.instance.removeSpecialCharactersForFiles(CoreTextUtils.instance.decodeURIComponent(fileName));
} }
} }
/**
* Given a file name and a set of already used names, calculate a unique name.
*
* @param usedNames Object with names already used as keys.
* @param name Name to check.
* @return Unique name.
*/
calculateUniqueName(usedNames: Record<string, unknown>, name: string): string {
if (typeof usedNames[name.toLowerCase()] == 'undefined') {
// No file with the same name.
return name;
}
// Repeated name. Add a number until we find a free name.
const nameWithoutExtension = CoreMimetypeUtils.instance.removeExtension(name);
let extension = CoreMimetypeUtils.instance.getFileExtension(name);
let num = 1;
extension = extension ? '.' + extension : '';
do {
name = nameWithoutExtension + '(' + num + ')' + extension;
num++;
} while (typeof usedNames[name.toLowerCase()] != 'undefined');
return name;
}
/** /**
* Remove app temporary folder. * Remove app temporary folder.
* *

@ -600,6 +600,8 @@ export class CoreDomUtilsProvider {
* @param positionParentClass Parent Class where to stop calculating the position. Default inner-scroll. * @param positionParentClass Parent Class where to stop calculating the position. Default inner-scroll.
* @return positionLeft, positionTop of the element relative to. * @return positionLeft, positionTop of the element relative to.
*/ */
getElementXY(container: HTMLElement, selector: undefined, positionParentClass?: string): number[];
getElementXY(container: HTMLElement, selector: string, positionParentClass?: string): number[] | null;
getElementXY(container: HTMLElement, selector?: string, positionParentClass?: string): number[] | null { getElementXY(container: HTMLElement, selector?: string, positionParentClass?: string): number[] | null {
let element: HTMLElement | null = <HTMLElement> (selector ? container.querySelector(selector) : container); let element: HTMLElement | null = <HTMLElement> (selector ? container.querySelector(selector) : container);
let positionTop = 0; let positionTop = 0;

@ -740,6 +740,63 @@ export class CoreTextUtilsProvider {
return text.replace(/(?:\r\n|\r|\n)/g, newValue); return text.replace(/(?:\r\n|\r|\n)/g, newValue);
} }
/**
* Replace draftfile URLs with the equivalent pluginfile URL.
*
* @param siteUrl URL of the site.
* @param text Text to treat, including draftfile URLs.
* @param files List of files of the area, using pluginfile URLs.
* @return Treated text and map with the replacements.
*/
replaceDraftfileUrls(
siteUrl: string,
text: string,
files: CoreWSExternalFile[],
): { text: string; replaceMap?: {[url: string]: string} } {
if (!text || !files || !files.length) {
return { text };
}
const draftfileUrl = this.concatenatePaths(siteUrl, 'draftfile.php');
const matches = text.match(new RegExp(this.escapeForRegex(draftfileUrl) + '[^\'" ]+', 'ig'));
if (!matches || !matches.length) {
return { text };
}
// Index the pluginfile URLs by file name.
const pluginfileMap: {[name: string]: string} = {};
files.forEach((file) => {
pluginfileMap[file.filename!] = file.fileurl;
});
// Replace each draftfile with the corresponding pluginfile URL.
const replaceMap: {[url: string]: string} = {};
matches.forEach((url) => {
if (replaceMap[url]) {
// URL already treated, same file embedded more than once.
return;
}
// Get the filename from the URL.
let filename = url.substr(url.lastIndexOf('/') + 1);
if (filename.indexOf('?') != -1) {
filename = filename.substr(0, filename.indexOf('?'));
}
if (pluginfileMap[filename]) {
replaceMap[url] = pluginfileMap[filename];
text = text.replace(new RegExp(this.escapeForRegex(url), 'g'), pluginfileMap[filename]);
}
});
return {
text,
replaceMap,
};
}
/** /**
* Replace @@PLUGINFILE@@ wildcards with the real URL in a text. * Replace @@PLUGINFILE@@ wildcards with the real URL in a text.
* *
@ -758,6 +815,37 @@ export class CoreTextUtilsProvider {
return text; return text;
} }
/**
* Restore original draftfile URLs.
*
* @param text Text to treat, including pluginfile URLs.
* @param replaceMap Map of the replacements that were done.
* @return Treated text.
*/
restoreDraftfileUrls(siteUrl: string, treatedText: string, originalText: string, files: CoreWSExternalFile[]): string {
if (!treatedText || !files || !files.length) {
return treatedText;
}
const draftfileUrl = this.concatenatePaths(siteUrl, 'draftfile.php');
const draftfileUrlRegexPrefix = this.escapeForRegex(draftfileUrl) + '/[^/]+/[^/]+/[^/]+/[^/]+/';
files.forEach((file) => {
// Search the draftfile URL in the original text.
const matches = originalText.match(
new RegExp(draftfileUrlRegexPrefix + this.escapeForRegex(file.filename!) + '[^\'" ]*', 'i'),
);
if (!matches || !matches[0]) {
return; // Original URL not found, skip.
}
treatedText = treatedText.replace(new RegExp(this.escapeForRegex(file.fileurl), 'g'), matches[0]);
});
return treatedText;
}
/** /**
* Replace pluginfile URLs with @@PLUGINFILE@@ wildcards. * Replace pluginfile URLs with @@PLUGINFILE@@ wildcards.
* *