diff --git a/src/addons/addons.module.ts b/src/addons/addons.module.ts
index ad92c6f09..408a1d3a6 100644
--- a/src/addons/addons.module.ts
+++ b/src/addons/addons.module.ts
@@ -25,6 +25,7 @@ import { AddonMessageOutputModule } from './messageoutput/messageoutput.module';
import { AddonMessagesModule } from './messages/messages.module';
import { AddonModModule } from './mod/mod.module';
import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module';
+import { AddonQtypeModule } from './qtype/qtype.module';
@NgModule({
imports: [
@@ -39,6 +40,7 @@ import { AddonQbehaviourModule } from './qbehaviour/qbehaviour.module';
AddonMessageOutputModule,
AddonModModule,
AddonQbehaviourModule,
+ AddonQtypeModule,
],
})
export class AddonsModule {}
diff --git a/src/addons/qtype/calculated/calculated.module.ts b/src/addons/qtype/calculated/calculated.module.ts
new file mode 100644
index 000000000..ce8213b38
--- /dev/null
+++ b/src/addons/qtype/calculated/calculated.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/calculated/component/addon-qtype-calculated.html b/src/addons/qtype/calculated/component/addon-qtype-calculated.html
new file mode 100644
index 000000000..06421260d
--- /dev/null
+++ b/src/addons/qtype/calculated/component/addon-qtype-calculated.html
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'addon.mod_quiz.answercolon' | translate }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{option.label}}
+
+
+
+
+
+
+
+
+
+ {{ option.text }}
+
+
+
+
+
+
+
+
diff --git a/src/addons/qtype/calculated/component/calculated.ts b/src/addons/qtype/calculated/component/calculated.ts
new file mode 100644
index 000000000..5a3aff42d
--- /dev/null
+++ b/src/addons/qtype/calculated/component/calculated.ts
@@ -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;
+ }
+
+}
diff --git a/src/addons/qtype/calculated/services/handlers/calculated.ts b/src/addons/qtype/calculated/services/handlers/calculated.ts
new file mode 100644
index 000000000..6115b425f
--- /dev/null
+++ b/src/addons/qtype/calculated/services/handlers/calculated.ts
@@ -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 {
+ 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, answers.answer);
+ if (answer === null) {
+ return 0;
+ }
+
+ if (!question.parsedSettings) {
+ if (this.hasSeparateUnitField(question)) {
+ return this.isValidValue( 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( 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 {
+ 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( 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) {}
diff --git a/src/addons/qtype/calculatedmulti/calculatedmulti.module.ts b/src/addons/qtype/calculatedmulti/calculatedmulti.module.ts
new file mode 100644
index 000000000..3ae73755f
--- /dev/null
+++ b/src/addons/qtype/calculatedmulti/calculatedmulti.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/calculatedmulti/services/handlers/calculatedmulti.ts b/src/addons/qtype/calculatedmulti/services/handlers/calculatedmulti.ts
new file mode 100644
index 000000000..832271cde
--- /dev/null
+++ b/src/addons/qtype/calculatedmulti/services/handlers/calculatedmulti.ts
@@ -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 {
+ // 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 {
+ 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) {}
diff --git a/src/addons/qtype/calculatedsimple/calculatedsimple.module.ts b/src/addons/qtype/calculatedsimple/calculatedsimple.module.ts
new file mode 100644
index 000000000..c467476d9
--- /dev/null
+++ b/src/addons/qtype/calculatedsimple/calculatedsimple.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/calculatedsimple/services/handlers/calculatedsimple.ts b/src/addons/qtype/calculatedsimple/services/handlers/calculatedsimple.ts
new file mode 100644
index 000000000..ddc86597e
--- /dev/null
+++ b/src/addons/qtype/calculatedsimple/services/handlers/calculatedsimple.ts
@@ -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 {
+ // 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 {
+ 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) {}
diff --git a/src/addons/qtype/ddimageortext/classes/ddimageortext.ts b/src/addons/qtype/ddimageortext/classes/ddimageortext.ts
new file mode 100644
index 000000000..2f829f107
--- /dev/null
+++ b/src/addons/qtype/ddimageortext/classes/ddimageortext.ts
@@ -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 {
+ // 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 = {};
+
+ // 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 = 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(`input#${targetInputId}`);
+
+ // Check if the draggable item is already assigned to an input and if it's the same as the one of the drop zone.
+ const originInputId = drag.getAttribute('inputid');
+ if (originInputId && originInputId != targetInputId) {
+ // Remove it from the previous place.
+ const originInputNode = this.doc.topNode?.querySelector(`input#${originInputId}`);
+ originInputNode?.setAttribute('value', '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(`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(`.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(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(`.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(`.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('.addon-qtype-ddimageortext-container');
+ this.dragItemsArea = this.topNode?.querySelector('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('div.dragitems');
+ }
+ }
+
+ querySelector(element: HTMLElement | null, selector: string): T | null {
+ if (!element) {
+ return null;
+ }
+
+ return 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 = dragHomeImg.cloneNode(true);
+
+ // Create a div and put the image in it.
+ divDrag = document.createElement('div');
+ divDrag.appendChild(drag);
+ divDrag.className = dragHome.className;
+ drag.className = '';
+ } else {
+ // The drag item doesn't have an image, just clone it.
+ divDrag = dragHome.cloneNode(true);
+ }
+
+ // Set the right classes and styles.
+ divDrag.classList.remove(`dragitemhomes${dragItemNo}`);
+ divDrag.classList.remove('draghome');
+ divDrag.classList.add(`dragitems${dragItemNo}`);
+ divDrag.classList.add(`draginstance${dragInstanceNo}`);
+ divDrag.classList.add('drag');
+
+ divDrag.style.visibility = 'inherit';
+ divDrag.style.position = 'absolute';
+ divDrag.setAttribute('draginstanceno', String(dragInstanceNo));
+ divDrag.setAttribute('dragitemno', String(dragItemNo));
+ divDrag.setAttribute('tabindex', '0');
+
+ // Insert the new drag after the dragHome.
+ dragHome.parentElement?.insertBefore(divDrag, dragHome.nextSibling);
+
+ return divDrag;
+ }
+
+};
diff --git a/src/addons/qtype/ddimageortext/component/addon-qtype-ddimageortext.html b/src/addons/qtype/ddimageortext/component/addon-qtype-ddimageortext.html
new file mode 100644
index 000000000..1b3c7ad50
--- /dev/null
+++ b/src/addons/qtype/ddimageortext/component/addon-qtype-ddimageortext.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+ {{ 'core.question.howtodraganddrop' | translate }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/addons/qtype/ddimageortext/component/ddimageortext.scss b/src/addons/qtype/ddimageortext/component/ddimageortext.scss
new file mode 100644
index 000000000..8487bbcdf
--- /dev/null
+++ b/src/addons/qtype/ddimageortext/component/ddimageortext.scss
@@ -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);
+ }
+ }
+}
diff --git a/src/addons/qtype/ddimageortext/component/ddimageortext.ts b/src/addons/qtype/ddimageortext/component/ddimageortext.ts
new file mode 100644
index 000000000..72a69ea38
--- /dev/null
+++ b/src/addons/qtype/ddimageortext/component/ddimageortext.ts
@@ -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 = 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 = 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;
+};
diff --git a/src/addons/qtype/ddimageortext/ddimageortext.module.ts b/src/addons/qtype/ddimageortext/ddimageortext.module.ts
new file mode 100644
index 000000000..77562e67d
--- /dev/null
+++ b/src/addons/qtype/ddimageortext/ddimageortext.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/ddimageortext/services/handlers/ddimageortext.ts b/src/addons/qtype/ddimageortext/services/handlers/ddimageortext.ts
new file mode 100644
index 000000000..cceff37ce
--- /dev/null
+++ b/src/addons/qtype/ddimageortext/services/handlers/ddimageortext.ts
@@ -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 {
+ 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 {
+ 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) {}
diff --git a/src/addons/qtype/ddmarker/classes/ddmarker.ts b/src/addons/qtype/ddmarker/classes/ddmarker.ts
new file mode 100644
index 000000000..88d2ebafb
--- /dev/null
+++ b/src/addons/qtype/ddmarker/classes/ddmarker.ts
@@ -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 = 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('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(`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('.addon-qtype-ddmarker-container');
+ this.dragItemsArea = this.topNode?.querySelector('div.dragitems, div.draghomes') || null;
+ }
+
+ querySelector(element: HTMLElement | null, selector: string): T | null {
+ if (!element) {
+ return null;
+ }
+
+ return 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');
+ }
+
+}
diff --git a/src/addons/qtype/ddmarker/classes/graphics_api.ts b/src/addons/qtype/ddmarker/classes/graphics_api.ts
new file mode 100644
index 000000000..4d69d0108
--- /dev/null
+++ b/src/addons/qtype/ddmarker/classes/graphics_api.ts
@@ -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('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 = document.createElementNS(this.NS, 'svg');
+ dropZones.appendChild(this.dropZone);
+ } else {
+ // Remove all children.
+ while (this.dropZone.firstChild) {
+ this.dropZone.removeChild(this.dropZone.firstChild);
+ }
+ }
+
+ this.dropZone.style.width = bgImg.width + 'px';
+ this.dropZone.style.height = bgImg.height + 'px';
+
+ this.instance.shapes = [];
+ }
+
+}
diff --git a/src/addons/qtype/ddmarker/component/addon-qtype-ddmarker.html b/src/addons/qtype/ddmarker/component/addon-qtype-ddmarker.html
new file mode 100644
index 000000000..eec783f34
--- /dev/null
+++ b/src/addons/qtype/ddmarker/component/addon-qtype-ddmarker.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+ {{ 'core.question.howtodraganddrop' | translate }}
+
+
+
+
+
+
+
+
+
diff --git a/src/addons/qtype/ddmarker/component/ddmarker.scss b/src/addons/qtype/ddmarker/component/ddmarker.scss
new file mode 100644
index 000000000..5a71d8cc2
--- /dev/null
+++ b/src/addons/qtype/ddmarker/component/ddmarker.scss
@@ -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;
+ }
+ }
+}
diff --git a/src/addons/qtype/ddmarker/component/ddmarker.ts b/src/addons/qtype/ddmarker/component/ddmarker.ts
new file mode 100644
index 000000000..cb61f6d1a
--- /dev/null
+++ b/src/addons/qtype/ddmarker/component/ddmarker.ts
@@ -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 = 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 = 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 = 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 {
+ 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;
+};
diff --git a/src/addons/qtype/ddmarker/ddmarker.module.ts b/src/addons/qtype/ddmarker/ddmarker.module.ts
new file mode 100644
index 000000000..fed857dfd
--- /dev/null
+++ b/src/addons/qtype/ddmarker/ddmarker.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/ddmarker/services/handlers/ddmarker.ts b/src/addons/qtype/ddmarker/services/handlers/ddmarker.ts
new file mode 100644
index 000000000..b602dc2c2
--- /dev/null
+++ b/src/addons/qtype/ddmarker/services/handlers/ddmarker.ts
@@ -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 {
+ 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 {
+ 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) {}
diff --git a/src/addons/qtype/ddwtos/classes/ddwtos.ts b/src/addons/qtype/ddwtos/classes/ddwtos.ts
new file mode 100644
index 000000000..93ffb9d5d
--- /dev/null
+++ b/src/addons/qtype/ddwtos/classes/ddwtos.ts
@@ -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 = 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 = 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 = 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 {
+ this.selectors = new AddonQtypeDdwtosQuestionCSSSelectors();
+
+ const container = 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 = 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(this.selectors.dropForPlace(parseInt(placeNo, 10) + 1));
+ const groupNo = this.getGroup(drop) ?? -1;
+ const drag = this.container.querySelector(
+ this.selectors.unplacedDragsForChoiceInGroup(choiceNo, groupNo),
+ );
+
+ this.placeDragInDrop(drag, drop);
+ this.positionDragItem(drag);
+ }
+ }
+ }
+
+ /**
+ * Make an element "draggable". In the mobile app, items are "dragged" using tap and drop.
+ *
+ * @param 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 = Array.from(this.container.querySelectorAll(this.selectors.drops()));
+ drops.forEach((drop) => {
+ this.makeDropZone(drop);
+ });
+
+ // If home answer zone is clicked, return drag home.
+ const home = this.container.querySelector(this.selectors.topNode() + ' .answercontainer');
+
+ home.addEventListener('click', () => {
+ const drag = this.selected;
+ if (!drag) {
+ // No element selected, nothing to do.
+ return;
+ }
+
+ // Not placed yet, deselect.
+ if (drag.classList.contains('unplaced')) {
+ this.deselectDrags();
+
+ return;
+ }
+
+ // Remove, deselect and move back home in this order.
+ this.removeDragFromDrop(drag);
+ this.deselectDrags();
+ this.positionDragItem(drag);
+ });
+ }
+
+ /**
+ * Set the width and height of an element.
+ *
+ * @param 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 = 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 {
+ const drag = 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();
+
+ 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 = 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 = 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 = 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}`;
+ }
+
+}
diff --git a/src/addons/qtype/ddwtos/component/addon-qtype-ddwtos.html b/src/addons/qtype/ddwtos/component/addon-qtype-ddwtos.html
new file mode 100644
index 000000000..113ff3572
--- /dev/null
+++ b/src/addons/qtype/ddwtos/component/addon-qtype-ddwtos.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+ {{ 'core.question.howtodraganddrop' | translate }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/addons/qtype/ddwtos/component/ddwtos.scss b/src/addons/qtype/ddwtos/component/ddwtos.scss
new file mode 100644
index 000000000..a1643e2e9
--- /dev/null
+++ b/src/addons/qtype/ddwtos/component/ddwtos.scss
@@ -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;
+ }
+ }
+}
diff --git a/src/addons/qtype/ddwtos/component/ddwtos.ts b/src/addons/qtype/ddwtos/component/ddwtos.ts
new file mode 100644
index 000000000..3d6f8b3a4
--- /dev/null
+++ b/src/addons/qtype/ddwtos/component/ddwtos.ts
@@ -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 = 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 {
+ 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;
+};
diff --git a/src/addons/qtype/ddwtos/ddwtos.module.ts b/src/addons/qtype/ddwtos/ddwtos.module.ts
new file mode 100644
index 000000000..2aeddc069
--- /dev/null
+++ b/src/addons/qtype/ddwtos/ddwtos.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/ddwtos/services/handlers/ddwtos.ts b/src/addons/qtype/ddwtos/services/handlers/ddwtos.ts
new file mode 100644
index 000000000..20999b302
--- /dev/null
+++ b/src/addons/qtype/ddwtos/services/handlers/ddwtos.ts
@@ -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 {
+ 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 {
+ 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) {}
diff --git a/src/addons/qtype/description/component/addon-qtype-description.html b/src/addons/qtype/description/component/addon-qtype-description.html
new file mode 100644
index 000000000..d323be103
--- /dev/null
+++ b/src/addons/qtype/description/component/addon-qtype-description.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/addons/qtype/description/component/description.ts b/src/addons/qtype/description/component/description.ts
new file mode 100644
index 000000000..5422b1ff5
--- /dev/null
+++ b/src/addons/qtype/description/component/description.ts
@@ -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 = questionEl.querySelector('input[type="hidden"][name*=seen]');
+ if (input) {
+ this.seenInput = {
+ name: input.name,
+ value: input.value,
+ };
+ }
+ }
+
+}
diff --git a/src/addons/qtype/description/description.module.ts b/src/addons/qtype/description/description.module.ts
new file mode 100644
index 000000000..f03cf74c8
--- /dev/null
+++ b/src/addons/qtype/description/description.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/description/services/handlers/description.ts b/src/addons/qtype/description/services/handlers/description.ts
new file mode 100644
index 000000000..52b359f24
--- /dev/null
+++ b/src/addons/qtype/description/services/handlers/description.ts
@@ -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 {
+ 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 {
+ 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) {}
diff --git a/src/addons/qtype/essay/component/addon-qtype-essay.html b/src/addons/qtype/essay/component/addon-qtype-essay.html
new file mode 100644
index 000000000..85cf617cc
--- /dev/null
+++ b/src/addons/qtype/essay/component/addon-qtype-essay.html
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'core.question.errorembeddedfilesnotsupportedinsite' | translate }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'core.question.errorattachmentsnotsupportedinsite' | translate }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/addons/qtype/essay/component/essay.ts b/src/addons/qtype/essay/component/essay.ts
new file mode 100644
index 000000000..a3c0d3b4d
--- /dev/null
+++ b/src/addons/qtype/essay/component/essay.ts
@@ -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 {
+ 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 = 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,
+ );
+ }
+
+}
diff --git a/src/addons/qtype/essay/essay.module.ts b/src/addons/qtype/essay/essay.module.ts
new file mode 100644
index 000000000..7e394279f
--- /dev/null
+++ b/src/addons/qtype/essay/essay.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/essay/services/handlers/essay.ts b/src/addons/qtype/essay/services/handlers/essay.ts
new file mode 100644
index 000000000..bc333e5bd
--- /dev/null
+++ b/src/addons/qtype/essay/services/handlers/essay.ts
@@ -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 {
+ 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 || []), []);
+ }
+
+ /**
+ * 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 {
+ 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 {
+ 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 {
+
+ const element = CoreDomUtils.instance.convertToElement(question.html);
+ const attachmentsInput = element.querySelector('.attachments input[name*=_attachments]');
+
+ // Search the textarea to get its name.
+ const textarea = 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 {
+
+ // 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 {
+
+ const element = CoreDomUtils.instance.convertToElement(question.html);
+ const attachmentsInput = 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(
+ 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 =
+ 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 {
+ 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(),
+ 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( answers[textarea.name]);
+ }
+ }
+
+}
+
+export class AddonQtypeEssayHandler extends makeSingleton(AddonQtypeEssayHandlerService) {}
diff --git a/src/addons/qtype/gapselect/component/addon-qtype-gapselect.html b/src/addons/qtype/gapselect/component/addon-qtype-gapselect.html
new file mode 100644
index 000000000..b6ebd47da
--- /dev/null
+++ b/src/addons/qtype/gapselect/component/addon-qtype-gapselect.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
diff --git a/src/addons/qtype/gapselect/component/gapselect.scss b/src/addons/qtype/gapselect/component/gapselect.scss
new file mode 100644
index 000000000..5060bcf32
--- /dev/null
+++ b/src/addons/qtype/gapselect/component/gapselect.scss
@@ -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;
+ // }
+ }
+}
diff --git a/src/addons/qtype/gapselect/component/gapselect.ts b/src/addons/qtype/gapselect/component/gapselect.ts
new file mode 100644
index 000000000..8c1872bae
--- /dev/null
+++ b/src/addons/qtype/gapselect/component/gapselect.ts
@@ -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,
+ );
+ }
+
+}
diff --git a/src/addons/qtype/gapselect/gapselect.module.ts b/src/addons/qtype/gapselect/gapselect.module.ts
new file mode 100644
index 000000000..1dd6c49ca
--- /dev/null
+++ b/src/addons/qtype/gapselect/gapselect.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/gapselect/services/handlers/gapselect.ts b/src/addons/qtype/gapselect/services/handlers/gapselect.ts
new file mode 100644
index 000000000..a817375d8
--- /dev/null
+++ b/src/addons/qtype/gapselect/services/handlers/gapselect.ts
@@ -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 {
+ 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 {
+ 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) {}
diff --git a/src/addons/qtype/match/component/addon-qtype-match.html b/src/addons/qtype/match/component/addon-qtype-match.html
new file mode 100644
index 000000000..f388fff84
--- /dev/null
+++ b/src/addons/qtype/match/component/addon-qtype-match.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{option.label}}
+
+
+
+
+
+
diff --git a/src/addons/qtype/match/component/match.scss b/src/addons/qtype/match/component/match.scss
new file mode 100644
index 000000000..da749c46f
--- /dev/null
+++ b/src/addons/qtype/match/component/match.scss
@@ -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);
+ }
+}
diff --git a/src/addons/qtype/match/component/match.ts b/src/addons/qtype/match/component/match.ts
new file mode 100644
index 000000000..8a15cb210
--- /dev/null
+++ b/src/addons/qtype/match/component/match.ts
@@ -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;
+ }
+
+}
diff --git a/src/addons/qtype/match/match.module.ts b/src/addons/qtype/match/match.module.ts
new file mode 100644
index 000000000..543968a91
--- /dev/null
+++ b/src/addons/qtype/match/match.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/match/services/handlers/match.ts b/src/addons/qtype/match/services/handlers/match.ts
new file mode 100644
index 000000000..69ad099f1
--- /dev/null
+++ b/src/addons/qtype/match/services/handlers/match.ts
@@ -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 {
+ 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 {
+ 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) {}
diff --git a/src/addons/qtype/multianswer/component/addon-qtype-multianswer.html b/src/addons/qtype/multianswer/component/addon-qtype-multianswer.html
new file mode 100644
index 000000000..a8e0b18df
--- /dev/null
+++ b/src/addons/qtype/multianswer/component/addon-qtype-multianswer.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
diff --git a/src/addons/qtype/multianswer/component/multianswer.scss b/src/addons/qtype/multianswer/component/multianswer.scss
new file mode 100644
index 000000000..ef8b92cff
--- /dev/null
+++ b/src/addons/qtype/multianswer/component/multianswer.scss
@@ -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;
+ }
+}
diff --git a/src/addons/qtype/multianswer/component/multianswer.ts b/src/addons/qtype/multianswer/component/multianswer.ts
new file mode 100644
index 000000000..19e7e567b
--- /dev/null
+++ b/src/addons/qtype/multianswer/component/multianswer.ts
@@ -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,
+ );
+ }
+
+}
diff --git a/src/addons/qtype/multianswer/multianswer.module.ts b/src/addons/qtype/multianswer/multianswer.module.ts
new file mode 100644
index 000000000..b66445d56
--- /dev/null
+++ b/src/addons/qtype/multianswer/multianswer.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/multianswer/services/handlers/multianswer.ts b/src/addons/qtype/multianswer/services/handlers/multianswer.ts
new file mode 100644
index 000000000..0c0fd3271
--- /dev/null
+++ b/src/addons/qtype/multianswer/services/handlers/multianswer.ts
@@ -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 {
+ 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(
+ 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 {
+ 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) {}
diff --git a/src/addons/qtype/multichoice/component/addon-qtype-multichoice.html b/src/addons/qtype/multichoice/component/addon-qtype-multichoice.html
new file mode 100644
index 000000000..0abd17a77
--- /dev/null
+++ b/src/addons/qtype/multichoice/component/addon-qtype-multichoice.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
{{ multiQuestion.prompt }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'addon.mod_quiz.clearchoice' | translate }}
+
+
+
+
+
+
diff --git a/src/addons/qtype/multichoice/component/multichoice.scss b/src/addons/qtype/multichoice/component/multichoice.scss
new file mode 100644
index 000000000..17f1f2577
--- /dev/null
+++ b/src/addons/qtype/multichoice/component/multichoice.scss
@@ -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;
+ }
+}
diff --git a/src/addons/qtype/multichoice/component/multichoice.ts b/src/addons/qtype/multichoice/component/multichoice.ts
new file mode 100644
index 000000000..db3a3d139
--- /dev/null
+++ b/src/addons/qtype/multichoice/component/multichoice.ts
@@ -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;
+ }
+
+}
diff --git a/src/addons/qtype/multichoice/multichoice.module.ts b/src/addons/qtype/multichoice/multichoice.module.ts
new file mode 100644
index 000000000..e04a66ae1
--- /dev/null
+++ b/src/addons/qtype/multichoice/multichoice.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/multichoice/services/handlers/multichoice.ts b/src/addons/qtype/multichoice/services/handlers/multichoice.ts
new file mode 100644
index 000000000..c185959c7
--- /dev/null
+++ b/src/addons/qtype/multichoice/services/handlers/multichoice.ts
@@ -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 {
+ 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 {
+ 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) {}
diff --git a/src/addons/qtype/numerical/numerical.module.ts b/src/addons/qtype/numerical/numerical.module.ts
new file mode 100644
index 000000000..6099c867e
--- /dev/null
+++ b/src/addons/qtype/numerical/numerical.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/numerical/services/handlers/numerical.ts b/src/addons/qtype/numerical/services/handlers/numerical.ts
new file mode 100644
index 000000000..de5c79477
--- /dev/null
+++ b/src/addons/qtype/numerical/services/handlers/numerical.ts
@@ -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) {}
diff --git a/src/addons/qtype/qtype.module.ts b/src/addons/qtype/qtype.module.ts
new file mode 100644
index 000000000..716b5b586
--- /dev/null
+++ b/src/addons/qtype/qtype.module.ts
@@ -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 { }
diff --git a/src/addons/qtype/randomsamatch/randomsamatch.module.ts b/src/addons/qtype/randomsamatch/randomsamatch.module.ts
new file mode 100644
index 000000000..004d8b82a
--- /dev/null
+++ b/src/addons/qtype/randomsamatch/randomsamatch.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/randomsamatch/services/handlers/randomsamatch.ts b/src/addons/qtype/randomsamatch/services/handlers/randomsamatch.ts
new file mode 100644
index 000000000..18470962d
--- /dev/null
+++ b/src/addons/qtype/randomsamatch/services/handlers/randomsamatch.ts
@@ -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) {}
diff --git a/src/addons/qtype/shortanswer/component/addon-qtype-shortanswer.html b/src/addons/qtype/shortanswer/component/addon-qtype-shortanswer.html
new file mode 100644
index 000000000..d4c2b2a9f
--- /dev/null
+++ b/src/addons/qtype/shortanswer/component/addon-qtype-shortanswer.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+ {{ 'addon.mod_quiz.answercolon' | translate }}
+
+
+
+
+
+
diff --git a/src/addons/qtype/shortanswer/component/shortanswer.scss b/src/addons/qtype/shortanswer/component/shortanswer.scss
new file mode 100644
index 000000000..bac1b66f7
--- /dev/null
+++ b/src/addons/qtype/shortanswer/component/shortanswer.scss
@@ -0,0 +1,5 @@
+:host {
+ .core-correct-icon {
+ margin-top: 14px;
+ }
+}
diff --git a/src/addons/qtype/shortanswer/component/shortanswer.ts b/src/addons/qtype/shortanswer/component/shortanswer.ts
new file mode 100644
index 000000000..77cfcb83e
--- /dev/null
+++ b/src/addons/qtype/shortanswer/component/shortanswer.ts
@@ -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;
+ }
+
+}
diff --git a/src/addons/qtype/shortanswer/services/handlers/shortanswer.ts b/src/addons/qtype/shortanswer/services/handlers/shortanswer.ts
new file mode 100644
index 000000000..5cc0398f5
--- /dev/null
+++ b/src/addons/qtype/shortanswer/services/handlers/shortanswer.ts
@@ -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 {
+ 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 {
+ 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) {}
diff --git a/src/addons/qtype/shortanswer/shortanswer.module.ts b/src/addons/qtype/shortanswer/shortanswer.module.ts
new file mode 100644
index 000000000..8faf8f6aa
--- /dev/null
+++ b/src/addons/qtype/shortanswer/shortanswer.module.ts
@@ -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 {}
diff --git a/src/addons/qtype/truefalse/services/handlers/truefalse.ts b/src/addons/qtype/truefalse/services/handlers/truefalse.ts
new file mode 100644
index 000000000..33a00db9d
--- /dev/null
+++ b/src/addons/qtype/truefalse/services/handlers/truefalse.ts
@@ -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 {
+ // 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 {
+ 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 {
+ 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) {}
diff --git a/src/addons/qtype/truefalse/truefalse.module.ts b/src/addons/qtype/truefalse/truefalse.module.ts
new file mode 100644
index 000000000..2599299c0
--- /dev/null
+++ b/src/addons/qtype/truefalse/truefalse.module.ts
@@ -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 {}
diff --git a/src/core/features/fileuploader/services/fileuploader.ts b/src/core/features/fileuploader/services/fileuploader.ts
index 6cbc4bb91..e9bcdcc5a 100644
--- a/src/core/features/fileuploader/services/fileuploader.ts
+++ b/src/core/features/fileuploader/services/fileuploader.ts
@@ -31,6 +31,7 @@ import { makeSingleton, Translate, MediaCapture, ModalController, Camera } from
import { CoreLogger } from '@singletons/logger';
import { CoreEmulatorCaptureMediaComponent } from '@features/emulator/components/capture-media/capture-media';
import { CoreError } from '@classes/errors/error';
+import { CoreSite } from '@classes/site';
/**
* File upload options.
@@ -97,6 +98,36 @@ export class CoreFileUploaderProvider {
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 {
+ 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.
*
@@ -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 {
+ 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.
*
@@ -217,6 +267,35 @@ export class CoreFileUploaderProvider {
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) => ( 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.
*
@@ -541,6 +620,46 @@ export class CoreFileUploaderProvider {
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 {
+ 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( 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.
*
diff --git a/src/core/features/question/classes/base-question-component.ts b/src/core/features/question/classes/base-question-component.ts
new file mode 100644
index 000000000..b6f21aef4
--- /dev/null
+++ b/src/core/features/question/classes/base-question-component.ts
@@ -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(); // Will emit when a behaviour button is clicked.
+ @Output() onAbort = new EventEmitter(); // 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 = Array.from(questionEl.querySelectorAll('input[type="radio"]'));
+ if (!radios.length) {
+ return false;
+ }
+
+ const question = 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 = 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 = questionEl.querySelector('select[name*=unit]');
+ const options = select && Array.from(select.querySelectorAll('option'));
+
+ if (!select || !options?.length) {
+ return false;
+ }
+
+ const question = 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 = this.question!;
+ const answerDraftIdInput = 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 = 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 = 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 = questionEl.querySelector('.attachments input[name*=_attachments]');
+ const objectElement = 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 = ( 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 = 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 = this.question!;
+ const input = 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 = 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 = 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 = this.question!;
+ question.prompt = CoreDomUtils.instance.getContentsOfElement(questionEl, '.prompt');
+
+ // Search radio buttons first (single choice).
+ let options = 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 = 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.
+};
diff --git a/src/core/services/file.ts b/src/core/services/file.ts
index 539aea31e..529b96334 100644
--- a/src/core/services/file.ts
+++ b/src/core/services/file.ts
@@ -1095,7 +1095,6 @@ export class CoreFileProvider {
const entries = await this.getDirectoryContents(dirPath);
const files = {};
- let num = 1;
let fileNameWithoutExtension = CoreMimetypeUtils.instance.removeExtension(fileName);
let extension = CoreMimetypeUtils.instance.getFileExtension(fileName) || defaultExt;
@@ -1116,26 +1115,40 @@ export class CoreFileProvider {
extension = '';
}
- let newName = 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;
- }
+ return this.calculateUniqueName(files, fileNameWithoutExtension + extension);
} catch (error) {
// Folder doesn't exist, name is unique. Clean it and return it.
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, 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.
*
diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts
index 6d75cdd1c..f09b70c54 100644
--- a/src/core/services/utils/dom.ts
+++ b/src/core/services/utils/dom.ts
@@ -600,6 +600,8 @@ export class CoreDomUtilsProvider {
* @param positionParentClass Parent Class where to stop calculating the position. Default inner-scroll.
* @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 {
let element: HTMLElement | null = (selector ? container.querySelector(selector) : container);
let positionTop = 0;
diff --git a/src/core/services/utils/text.ts b/src/core/services/utils/text.ts
index 247749821..7063dec43 100644
--- a/src/core/services/utils/text.ts
+++ b/src/core/services/utils/text.ts
@@ -740,6 +740,63 @@ export class CoreTextUtilsProvider {
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.
*
@@ -758,6 +815,37 @@ export class CoreTextUtilsProvider {
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.
*