From 916dc1440167029cbe7740d2a3e849537066e1ce Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 15 Feb 2021 11:35:41 +0100 Subject: [PATCH] MOBILE-3651 qtype: Implement all question types --- src/addons/addons.module.ts | 2 + .../qtype/calculated/calculated.module.ts | 43 + .../component/addon-qtype-calculated.html | 75 ++ .../qtype/calculated/component/calculated.ts | 43 + .../services/handlers/calculated.ts | 237 +++++ .../calculatedmulti/calculatedmulti.module.ts | 34 + .../services/handlers/calculatedmulti.ts | 109 ++ .../calculatedsimple.module.ts | 34 + .../services/handlers/calculatedsimple.ts | 115 +++ .../ddimageortext/classes/ddimageortext.ts | 848 +++++++++++++++ .../component/addon-qtype-ddimageortext.html | 24 + .../component/ddimageortext.scss | 114 ++ .../ddimageortext/component/ddimageortext.ts | 145 +++ .../ddimageortext/ddimageortext.module.ts | 43 + .../services/handlers/ddimageortext.ts | 136 +++ src/addons/qtype/ddmarker/classes/ddmarker.ts | 972 ++++++++++++++++++ .../qtype/ddmarker/classes/graphics_api.ts | 96 ++ .../component/addon-qtype-ddmarker.html | 22 + .../qtype/ddmarker/component/ddmarker.scss | 150 +++ .../qtype/ddmarker/component/ddmarker.ts | 188 ++++ src/addons/qtype/ddmarker/ddmarker.module.ts | 43 + .../ddmarker/services/handlers/ddmarker.ts | 155 +++ src/addons/qtype/ddwtos/classes/ddwtos.ts | 585 +++++++++++ .../ddwtos/component/addon-qtype-ddwtos.html | 23 + src/addons/qtype/ddwtos/component/ddwtos.scss | 132 +++ src/addons/qtype/ddwtos/component/ddwtos.ts | 167 +++ src/addons/qtype/ddwtos/ddwtos.module.ts | 43 + .../qtype/ddwtos/services/handlers/ddwtos.ts | 134 +++ .../component/addon-qtype-description.html | 12 + .../description/component/description.ts | 53 + .../qtype/description/description.module.ts | 43 + .../services/handlers/description.ts | 77 ++ .../essay/component/addon-qtype-essay.html | 87 ++ src/addons/qtype/essay/component/essay.ts | 96 ++ src/addons/qtype/essay/essay.module.ts | 45 + .../qtype/essay/services/handlers/essay.ts | 464 +++++++++ .../component/addon-qtype-gapselect.html | 10 + .../qtype/gapselect/component/gapselect.scss | 23 + .../qtype/gapselect/component/gapselect.ts | 55 + .../qtype/gapselect/gapselect.module.ts | 43 + .../gapselect/services/handlers/gapselect.ts | 136 +++ .../match/component/addon-qtype-match.html | 29 + src/addons/qtype/match/component/match.scss | 13 + src/addons/qtype/match/component/match.ts | 43 + src/addons/qtype/match/match.module.ts | 43 + .../qtype/match/services/handlers/match.ts | 136 +++ .../component/addon-qtype-multianswer.html | 10 + .../multianswer/component/multianswer.scss | 38 + .../multianswer/component/multianswer.ts | 54 + .../qtype/multianswer/multianswer.module.ts | 43 + .../services/handlers/multianswer.ts | 162 +++ .../component/addon-qtype-multichoice.html | 67 ++ .../multichoice/component/multichoice.scss | 8 + .../multichoice/component/multichoice.ts | 50 + .../qtype/multichoice/multichoice.module.ts | 43 + .../services/handlers/multichoice.ts | 201 ++++ .../qtype/numerical/numerical.module.ts | 35 + .../numerical/services/handlers/numerical.ts | 32 + src/addons/qtype/qtype.module.ts | 57 + .../randomsamatch/randomsamatch.module.ts | 34 + .../services/handlers/randomsamatch.ts | 31 + .../component/addon-qtype-shortanswer.html | 20 + .../shortanswer/component/shortanswer.scss | 5 + .../shortanswer/component/shortanswer.ts | 43 + .../services/handlers/shortanswer.ts | 109 ++ .../qtype/shortanswer/shortanswer.module.ts | 43 + .../truefalse/services/handlers/truefalse.ts | 132 +++ .../qtype/truefalse/truefalse.module.ts | 34 + .../fileuploader/services/fileuploader.ts | 119 +++ .../classes/base-question-component.ts | 784 ++++++++++++++ src/core/services/file.ts | 43 +- src/core/services/utils/dom.ts | 2 + src/core/services/utils/text.ts | 88 ++ 73 files changed, 8392 insertions(+), 15 deletions(-) create mode 100644 src/addons/qtype/calculated/calculated.module.ts create mode 100644 src/addons/qtype/calculated/component/addon-qtype-calculated.html create mode 100644 src/addons/qtype/calculated/component/calculated.ts create mode 100644 src/addons/qtype/calculated/services/handlers/calculated.ts create mode 100644 src/addons/qtype/calculatedmulti/calculatedmulti.module.ts create mode 100644 src/addons/qtype/calculatedmulti/services/handlers/calculatedmulti.ts create mode 100644 src/addons/qtype/calculatedsimple/calculatedsimple.module.ts create mode 100644 src/addons/qtype/calculatedsimple/services/handlers/calculatedsimple.ts create mode 100644 src/addons/qtype/ddimageortext/classes/ddimageortext.ts create mode 100644 src/addons/qtype/ddimageortext/component/addon-qtype-ddimageortext.html create mode 100644 src/addons/qtype/ddimageortext/component/ddimageortext.scss create mode 100644 src/addons/qtype/ddimageortext/component/ddimageortext.ts create mode 100644 src/addons/qtype/ddimageortext/ddimageortext.module.ts create mode 100644 src/addons/qtype/ddimageortext/services/handlers/ddimageortext.ts create mode 100644 src/addons/qtype/ddmarker/classes/ddmarker.ts create mode 100644 src/addons/qtype/ddmarker/classes/graphics_api.ts create mode 100644 src/addons/qtype/ddmarker/component/addon-qtype-ddmarker.html create mode 100644 src/addons/qtype/ddmarker/component/ddmarker.scss create mode 100644 src/addons/qtype/ddmarker/component/ddmarker.ts create mode 100644 src/addons/qtype/ddmarker/ddmarker.module.ts create mode 100644 src/addons/qtype/ddmarker/services/handlers/ddmarker.ts create mode 100644 src/addons/qtype/ddwtos/classes/ddwtos.ts create mode 100644 src/addons/qtype/ddwtos/component/addon-qtype-ddwtos.html create mode 100644 src/addons/qtype/ddwtos/component/ddwtos.scss create mode 100644 src/addons/qtype/ddwtos/component/ddwtos.ts create mode 100644 src/addons/qtype/ddwtos/ddwtos.module.ts create mode 100644 src/addons/qtype/ddwtos/services/handlers/ddwtos.ts create mode 100644 src/addons/qtype/description/component/addon-qtype-description.html create mode 100644 src/addons/qtype/description/component/description.ts create mode 100644 src/addons/qtype/description/description.module.ts create mode 100644 src/addons/qtype/description/services/handlers/description.ts create mode 100644 src/addons/qtype/essay/component/addon-qtype-essay.html create mode 100644 src/addons/qtype/essay/component/essay.ts create mode 100644 src/addons/qtype/essay/essay.module.ts create mode 100644 src/addons/qtype/essay/services/handlers/essay.ts create mode 100644 src/addons/qtype/gapselect/component/addon-qtype-gapselect.html create mode 100644 src/addons/qtype/gapselect/component/gapselect.scss create mode 100644 src/addons/qtype/gapselect/component/gapselect.ts create mode 100644 src/addons/qtype/gapselect/gapselect.module.ts create mode 100644 src/addons/qtype/gapselect/services/handlers/gapselect.ts create mode 100644 src/addons/qtype/match/component/addon-qtype-match.html create mode 100644 src/addons/qtype/match/component/match.scss create mode 100644 src/addons/qtype/match/component/match.ts create mode 100644 src/addons/qtype/match/match.module.ts create mode 100644 src/addons/qtype/match/services/handlers/match.ts create mode 100644 src/addons/qtype/multianswer/component/addon-qtype-multianswer.html create mode 100644 src/addons/qtype/multianswer/component/multianswer.scss create mode 100644 src/addons/qtype/multianswer/component/multianswer.ts create mode 100644 src/addons/qtype/multianswer/multianswer.module.ts create mode 100644 src/addons/qtype/multianswer/services/handlers/multianswer.ts create mode 100644 src/addons/qtype/multichoice/component/addon-qtype-multichoice.html create mode 100644 src/addons/qtype/multichoice/component/multichoice.scss create mode 100644 src/addons/qtype/multichoice/component/multichoice.ts create mode 100644 src/addons/qtype/multichoice/multichoice.module.ts create mode 100644 src/addons/qtype/multichoice/services/handlers/multichoice.ts create mode 100644 src/addons/qtype/numerical/numerical.module.ts create mode 100644 src/addons/qtype/numerical/services/handlers/numerical.ts create mode 100644 src/addons/qtype/qtype.module.ts create mode 100644 src/addons/qtype/randomsamatch/randomsamatch.module.ts create mode 100644 src/addons/qtype/randomsamatch/services/handlers/randomsamatch.ts create mode 100644 src/addons/qtype/shortanswer/component/addon-qtype-shortanswer.html create mode 100644 src/addons/qtype/shortanswer/component/shortanswer.scss create mode 100644 src/addons/qtype/shortanswer/component/shortanswer.ts create mode 100644 src/addons/qtype/shortanswer/services/handlers/shortanswer.ts create mode 100644 src/addons/qtype/shortanswer/shortanswer.module.ts create mode 100644 src/addons/qtype/truefalse/services/handlers/truefalse.ts create mode 100644 src/addons/qtype/truefalse/truefalse.module.ts create mode 100644 src/core/features/question/classes/base-question-component.ts 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. *